feat: enhance Tauri charts with deduped gridline labels and add tests for bar and line charts
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { TauriBarChart, TauriLineChart, type ChartPoint } from './TauriCharts';
|
||||||
|
|
||||||
|
const zero: ChartPoint[] = Array.from({ length: 7 }, (_, i) => ({ label: `روز ${i}`, value: 0 }));
|
||||||
|
const real: ChartPoint[] = [
|
||||||
|
{ label: '۷ خرداد', value: 15 },
|
||||||
|
{ label: '۸ خرداد', value: 40 },
|
||||||
|
{ label: '۹ خرداد', value: 48 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMPTY = 'دادهای برای نمایش نیست';
|
||||||
|
|
||||||
|
describe('TauriBarChart', () => {
|
||||||
|
it('shows the empty state when every value is zero', () => {
|
||||||
|
render(<TauriBarChart data={zero} />);
|
||||||
|
expect(screen.getByText(EMPTY)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the empty state when there is no data', () => {
|
||||||
|
render(<TauriBarChart data={[]} />);
|
||||||
|
expect(screen.getByText(EMPTY)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the chart (x labels, no empty state) with real data', () => {
|
||||||
|
render(<TauriBarChart data={real} />);
|
||||||
|
expect(screen.queryByText(EMPTY)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('۸ خرداد')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TauriLineChart', () => {
|
||||||
|
it('shows the empty state when every value is zero', () => {
|
||||||
|
render(<TauriLineChart data={zero} />);
|
||||||
|
expect(screen.getByText(EMPTY)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the empty state with fewer than two points', () => {
|
||||||
|
render(<TauriLineChart data={[{ label: 'x', value: 5 }]} />);
|
||||||
|
expect(screen.getByText(EMPTY)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the chart with real data', () => {
|
||||||
|
const { container } = render(<TauriLineChart data={real} />);
|
||||||
|
expect(screen.queryByText(EMPTY)).not.toBeInTheDocument();
|
||||||
|
expect(container.querySelector('svg path')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,7 +13,8 @@ export interface ChartPoint {
|
|||||||
value: number;
|
value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ~5 rounded gridline ticks covering [0, max], top → bottom. */
|
/** ~5 rounded gridline ticks covering [0, max], top → bottom. Deduped so tiny
|
||||||
|
* integer ranges (e.g. max=1) never render repeated labels. */
|
||||||
function niceTicks(max: number, count = 4): number[] {
|
function niceTicks(max: number, count = 4): number[] {
|
||||||
const safeMax = max > 0 ? max : 1;
|
const safeMax = max > 0 ? max : 1;
|
||||||
const rawStep = safeMax / count;
|
const rawStep = safeMax / count;
|
||||||
@@ -21,10 +22,11 @@ function niceTicks(max: number, count = 4): number[] {
|
|||||||
const norm = rawStep / mag;
|
const norm = rawStep / mag;
|
||||||
const niceNorm = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10;
|
const niceNorm = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10;
|
||||||
const step = niceNorm * mag;
|
const step = niceNorm * mag;
|
||||||
const top = step * count;
|
|
||||||
const ticks: number[] = [];
|
const ticks: number[] = [];
|
||||||
for (let i = count; i >= 0; i--) ticks.push(Math.round(step * i));
|
for (let i = count; i >= 0; i--) ticks.push(Math.round(step * i));
|
||||||
return ticks; // e.g. [400,300,200,100,0]
|
// collapse duplicate rounded labels (keeps gridlines proportional via ticks[0])
|
||||||
|
const deduped = ticks.filter((t, i) => i === 0 || t !== ticks[i - 1]);
|
||||||
|
return deduped.length >= 2 ? deduped : [ticks[0], 0];
|
||||||
}
|
}
|
||||||
|
|
||||||
const faNum = new Intl.NumberFormat('fa-IR');
|
const faNum = new Intl.NumberFormat('fa-IR');
|
||||||
@@ -85,7 +87,7 @@ function ChartFrame({
|
|||||||
|
|
||||||
/** Bar chart — thin #5559CE columns (source: BarPlot, categoryGapRatio 0.7). */
|
/** Bar chart — thin #5559CE columns (source: BarPlot, categoryGapRatio 0.7). */
|
||||||
export function TauriBarChart({ data }: { data: ChartPoint[] }) {
|
export function TauriBarChart({ data }: { data: ChartPoint[] }) {
|
||||||
if (!data.length) {
|
if (!data.length || !data.some((d) => d.value > 0)) {
|
||||||
return <EmptyChart />;
|
return <EmptyChart />;
|
||||||
}
|
}
|
||||||
const max = Math.max(...data.map((d) => d.value), 1);
|
const max = Math.max(...data.map((d) => d.value), 1);
|
||||||
@@ -115,7 +117,7 @@ export function TauriBarChart({ data }: { data: ChartPoint[] }) {
|
|||||||
|
|
||||||
/** Line + area chart — #5559CE line over a #3A6FF8 gradient (source: LinePlot). */
|
/** Line + area chart — #5559CE line over a #3A6FF8 gradient (source: LinePlot). */
|
||||||
export function TauriLineChart({ data }: { data: ChartPoint[] }) {
|
export function TauriLineChart({ data }: { data: ChartPoint[] }) {
|
||||||
if (data.length < 2) {
|
if (data.length < 2 || !data.some((d) => d.value > 0)) {
|
||||||
return <EmptyChart />;
|
return <EmptyChart />;
|
||||||
}
|
}
|
||||||
const values = data.map((d) => d.value);
|
const values = data.map((d) => d.value);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function ChartTitle({ text }: { text: string }) {
|
|||||||
|
|
||||||
function ChartCard({ title, selectorOptions, children }: { title: string; selectorOptions: string[]; children: React.ReactNode }) {
|
function ChartCard({ title, selectorOptions, children }: { title: string; selectorOptions: string[]; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full lg:w-1/2 rounded-[8px] border border-solid border-[#EFEFEF] dark:border-transparent bg-[#FFF] dark:bg-[#222433]">
|
<div className="w-full rounded-[8px] border border-solid border-[#EFEFEF] dark:border-transparent bg-[#FFF] dark:bg-[#222433]">
|
||||||
<div className="flex px-[20px] py-[12px] md:py-[14px] lg:py-[16px] items-center justify-between gap-[12px]">
|
<div className="flex px-[20px] py-[12px] md:py-[14px] lg:py-[16px] items-center justify-between gap-[12px]">
|
||||||
<ChartTitle text={title} />
|
<ChartTitle text={title} />
|
||||||
<SmSelector options={selectorOptions} />
|
<SmSelector options={selectorOptions} />
|
||||||
@@ -75,12 +75,13 @@ export function TauriDashboardView({
|
|||||||
formatRial,
|
formatRial,
|
||||||
}: TauriDashboardViewProps) {
|
}: TauriDashboardViewProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-y-[24px]">
|
<div className="flex flex-col items-stretch gap-y-[24px] w-full">
|
||||||
{/* Cards */}
|
{/* Cards */}
|
||||||
<TauriStatCards stats={stats} formatNumber={formatNumber} formatRial={formatRial} />
|
<TauriStatCards stats={stats} formatNumber={formatNumber} formatRial={formatRial} />
|
||||||
|
|
||||||
{/* Charts */}
|
{/* Charts — grid-2 (1fr 1fr, collapses to 1fr on narrow) for reliable
|
||||||
<div className="flex flex-col lg:flex-row items-stretch justify-center gap-[16px]">
|
full-width equal columns, matching the rest of the admin dashboard */}
|
||||||
|
<div className="grid-2">
|
||||||
<ChartCard title="نمودار تعداد بیماران" selectorOptions={MONTHS}>
|
<ChartCard title="نمودار تعداد بیماران" selectorOptions={MONTHS}>
|
||||||
<TauriBarChart data={patientBars} />
|
<TauriBarChart data={patientBars} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
@@ -90,7 +91,7 @@ export function TauriDashboardView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* New appointments list */}
|
{/* New appointments list */}
|
||||||
<div className="rounded-[8px] border border-solid border-transparent md:border-[#EFEFEF] dark:border-transparent bg-transparent md:bg-[#FFF] md:dark:bg-[#222433]">
|
<div className="w-full rounded-[8px] border border-solid border-transparent md:border-[#EFEFEF] dark:border-transparent bg-transparent md:bg-[#FFF] md:dark:bg-[#222433]">
|
||||||
<div className="md:pt-[18px] md:px-[24px] md:pb-[20px] flex items-center justify-between">
|
<div className="md:pt-[18px] md:px-[24px] md:pb-[20px] flex items-center justify-between">
|
||||||
<p className="text-[#525252] dark:text-[#D7D8ED] text-[14px] md:text-[16px] font-bold">لیست نوبتهای جدید</p>
|
<p className="text-[#525252] dark:text-[#D7D8ED] text-[14px] md:text-[16px] font-bold">لیست نوبتهای جدید</p>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export function TauriStatCards({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="grid justify-center grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-[16px] sm:gap-[23px] md:gap-[30px] lg:gap-[36px]">
|
<ul className="grid w-full grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-[16px] sm:gap-[23px] md:gap-[30px] lg:gap-[36px]">
|
||||||
{cards.map((c) => (
|
{cards.map((c) => (
|
||||||
<TauriStatCard key={c.label} data={c} />
|
<TauriStatCard key={c.label} data={c} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user