feat(dashboard): implement Jalali month start calculation and add related tests
This commit is contained in:
@@ -11,6 +11,40 @@ const real: ChartPoint[] = [
|
||||
|
||||
const EMPTY = 'دادهای برای نمایش نیست';
|
||||
|
||||
/**
|
||||
* صفحه RTL است، پس در یک ردیفِ flex «اولین فرزند» سمت راست رندر میشود. ستون مقادیر
|
||||
* باید سمت چپ بنشیند، یعنی باید فرزند **آخر** باشد و فاصلهٔ ردیف برچسبها هم از چپ
|
||||
* گرفته شود. جابهجا شدن این ترتیب، اعداد را بیصدا به سمت راست برمیگرداند.
|
||||
*/
|
||||
describe('ChartFrame — جای محور مقادیر', () => {
|
||||
const frameOf = (container: HTMLElement) => {
|
||||
const root = container.firstElementChild as HTMLElement;
|
||||
return {
|
||||
plotRow: root.firstElementChild as HTMLElement,
|
||||
labelRow: root.lastElementChild as HTMLElement,
|
||||
};
|
||||
};
|
||||
|
||||
it('ستون مقادیر آخرین فرزند ردیف است تا در RTL سمت چپ بیفتد', () => {
|
||||
const { container } = render(<TauriLineChart data={real} />);
|
||||
const { plotRow } = frameOf(container);
|
||||
|
||||
const ticks = plotRow.lastElementChild as HTMLElement;
|
||||
expect(ticks.textContent).toContain('۰');
|
||||
expect(ticks.className).toContain('flex-col');
|
||||
// نمودار خودش فرزند اول است، پس سمت راست میماند.
|
||||
expect((plotRow.firstElementChild as HTMLElement).className).toContain('overflow-hidden');
|
||||
});
|
||||
|
||||
it('ردیف برچسبها به اندازهٔ ستون مقادیر از چپ فاصله میگیرد', () => {
|
||||
const { container } = render(<TauriLineChart data={real} />);
|
||||
const { labelRow } = frameOf(container);
|
||||
|
||||
expect(labelRow.style.paddingLeft).not.toBe('');
|
||||
expect(labelRow.style.paddingRight).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TauriBarChart', () => {
|
||||
it('shows the empty state when every value is zero', () => {
|
||||
render(<TauriBarChart data={zero} />);
|
||||
@@ -165,3 +199,59 @@ describe('TauriLineChart — ادامهٔ پیشبینی', () => {
|
||||
expect(container.querySelector('.td-forecast')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* محور زمان راستبهچپ است: برچسبها در جریان RTL چیده میشوند، پس فروردین سمت راست
|
||||
* مینشیند. SVG جریان RTL ندارد و اگر اندیس ۰ به `x=0` برود، خط نسبت به برچسبهایش
|
||||
* آینه میشود — دقیقاً باگی که دادهٔ واقعیِ ابتدای سال را روی ماههای پایانی میانداخت.
|
||||
*/
|
||||
describe('TauriLineChart — جهت محور زمان', () => {
|
||||
const year: ChartPoint[] = Array.from({ length: 12 }, (_, i) => ({
|
||||
label: `ماه ${i + 1}`,
|
||||
value: i < 4 ? (i + 1) * 10 : 0,
|
||||
}));
|
||||
|
||||
/** ستونِ هاورِ هر نقطه دقیقاً حول همان نقطه مینشیند، پس `left`ش جای نقطه را میگوید. */
|
||||
const hitCenters = (container: HTMLElement): number[] =>
|
||||
Array.from(container.querySelectorAll<HTMLElement>('.td-hit')).map((el) => {
|
||||
const left = parseFloat(el.style.left);
|
||||
const width = parseFloat(el.style.width);
|
||||
return left + width / 2;
|
||||
});
|
||||
|
||||
it('اولین نقطه سمت راست و آخرین نقطه سمت چپ مینشیند', () => {
|
||||
const { container } = render(<TauriLineChart data={year} actualCount={4} />);
|
||||
const centers = hitCenters(container);
|
||||
|
||||
expect(centers[0]).toBeCloseTo(100, 5);
|
||||
expect(centers[centers.length - 1]).toBeCloseTo(0, 5);
|
||||
});
|
||||
|
||||
it('نقطهها به ترتیب زمان از راست به چپ پیش میروند', () => {
|
||||
const { container } = render(<TauriLineChart data={year} actualCount={4} />);
|
||||
const centers = hitCenters(container);
|
||||
|
||||
for (let i = 1; i < centers.length; i++) {
|
||||
expect(centers[i]).toBeLessThan(centers[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
/** بخش واقعی سمت راست است و پیشبینی سمت چپ، نه برعکس. */
|
||||
it('بخش واقعی راستتر از بخش پیشبینی است', () => {
|
||||
const { container } = render(<TauriLineChart data={year} actualCount={4} />);
|
||||
const centers = hitCenters(container);
|
||||
|
||||
const lastActual = centers[3];
|
||||
const firstForecast = centers[4];
|
||||
expect(lastActual).toBeGreaterThan(firstForecast);
|
||||
});
|
||||
|
||||
/** نقطه باید روی خط بنشیند، نه داخل ستونِ خودش سُر بخورد. */
|
||||
it('نقطه در مرکز ستون هاورِ خودش است', () => {
|
||||
const { container } = render(<TauriLineChart data={year} actualCount={4} />);
|
||||
|
||||
for (const dot of Array.from(container.querySelectorAll<HTMLElement>('.td-dot'))) {
|
||||
expect(dot.style.left).toBe('50%');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,18 +88,11 @@ function ChartFrame({
|
||||
// column to zero height. Inline `alignItems` is the only reliable override.
|
||||
return (
|
||||
<div className="h-[300px] w-full flex flex-col px-[20px] pb-[12px]" style={{ alignItems: 'stretch' }}>
|
||||
{/* ستونِ مقادیر سمت چپ مینشیند و نمودار سمت راست. جریان صفحه RTL است، پس
|
||||
«اول در JSX» یعنی «راست روی صفحه» — ترتیب عمداً برعکسِ ترتیب دیداری است.
|
||||
با `direction: ltr` هم میشد، ولی آن به میلههای نمودار ستونی ارث میرسید و
|
||||
ترتیبشان را نسبت به برچسبها وارونه میکرد. */}
|
||||
<div className="flex-1 flex min-h-0" style={{ alignItems: 'stretch' }}>
|
||||
{/* y-axis ticks, aligned to gridlines */}
|
||||
<div
|
||||
className="flex flex-col justify-between text-[14px] font-normal text-[var(--text-2)] text-left pl-[4px] shrink-0"
|
||||
style={{ width: yWidth, alignItems: 'flex-start' }}
|
||||
>
|
||||
{ticks.map((t, i) => (
|
||||
<span key={i} className="leading-none -translate-y-1/2 first:translate-y-0 last:translate-y-0">
|
||||
{faNum.format(t)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* plot area */}
|
||||
{/* overflow-hidden keeps a dense series (a 31-day month) inside the card */}
|
||||
<div className="relative flex-1 min-w-0 overflow-hidden">
|
||||
@@ -112,9 +105,20 @@ function ChartFrame({
|
||||
))}
|
||||
{children}
|
||||
</div>
|
||||
{/* y-axis ticks, aligned to gridlines — چسبیده به لبهٔ نمودار، پس راستچین */}
|
||||
<div
|
||||
className="flex flex-col justify-between text-[14px] font-normal text-[var(--text-2)] text-right pr-[4px] shrink-0"
|
||||
style={{ width: yWidth, alignItems: 'flex-end' }}
|
||||
>
|
||||
{ticks.map((t, i) => (
|
||||
<span key={i} className="leading-none -translate-y-1/2 first:translate-y-0 last:translate-y-0">
|
||||
{faNum.format(t)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* x-axis labels */}
|
||||
<div className="flex pt-[8px]" style={{ paddingRight: yWidth, alignItems: 'flex-start' }}>
|
||||
<div className="flex pt-[8px]" style={{ paddingLeft: yWidth, alignItems: 'flex-start' }}>
|
||||
{labels.map((l, i) => (
|
||||
<span key={i} className="flex-1 min-w-0 overflow-hidden text-center text-[10px] font-normal text-[var(--text-2)] whitespace-nowrap">
|
||||
{l}
|
||||
@@ -189,7 +193,11 @@ export function TauriLineChart({ data, actualCount }: { data: ChartPoint[]; actu
|
||||
const W = 100;
|
||||
const H = 100;
|
||||
const stepX = data.length > 1 ? W / (data.length - 1) : W;
|
||||
const pts = values.map((v, i) => [i * stepX, H - (v / top) * H] as [number, number]);
|
||||
// محور زمان راستبهچپ است — `ChartFrame` برچسبها را در جریان RTL میچیند، پس
|
||||
// فروردین سمت راست مینشیند. SVG جریان RTL ندارد، بنابراین اندیس ۰ باید صریحاً به
|
||||
// لبهٔ راست نگاشت شود؛ وگرنه خط نسبت به برچسبهایش آینه میشود.
|
||||
const xOf = (i: number) => W - i * stepX;
|
||||
const pts = values.map((v, i) => [xOf(i), H - (v / top) * H] as [number, number]);
|
||||
// خط پیشبینی از آخرین نقطهٔ واقعی شروع میشود تا وصلهی دو بخش دیده نشود.
|
||||
const solid = smoothPath(hasForecast ? pts.slice(0, nActual) : pts);
|
||||
const dashed = hasForecast ? smoothPath(pts.slice(nActual - 1)) : '';
|
||||
@@ -204,8 +212,9 @@ export function TauriLineChart({ data, actualCount }: { data: ChartPoint[]; actu
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
{/* رنگِ خط در طول محور افقی از برند به اکسنت میرود (سبک دموی apex) */}
|
||||
<linearGradient id="tdIncomeStroke" x1="0" y1="0" x2="1" y2="0">
|
||||
{/* رنگِ خط در طول زمان از برند به اکسنت میرود (سبک دموی apex). چون زمان
|
||||
راستبهچپ میرود، گرادیان هم از راست شروع میشود. */}
|
||||
<linearGradient id="tdIncomeStroke" x1="1" y1="0" x2="0" y2="0">
|
||||
<stop offset="0%" stopColor="var(--primary)" />
|
||||
<stop offset="55%" stopColor="var(--primary-600)" />
|
||||
<stop offset="100%" stopColor="var(--accent)" />
|
||||
@@ -254,28 +263,38 @@ export function TauriLineChart({ data, actualCount }: { data: ChartPoint[]; actu
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* لایهٔ تعامل: هر ستون یک نقطه را هاور میکند (بدون state، فقط CSS) */}
|
||||
<div className="absolute inset-0 flex" style={{ alignItems: 'stretch' }}>
|
||||
{/* لایهٔ تعامل: هر ستون یک نقطه را هاور میکند (بدون state، فقط CSS).
|
||||
ستونها با `left` فیزیکی و دقیقاً حولِ نقطهٔ خودشان مینشینند، نه با flex:
|
||||
در flex، هم ترتیبشان به جهت متن گره میخورد و هم `left`ِ نقطه نسبت به ستون
|
||||
حساب میشد نه نسبت به نمودار — یعنی نقطه داخل ستون سُر میخورد. */}
|
||||
<div className="absolute inset-0">
|
||||
{data.map((p, i) => {
|
||||
const isForecast = hasForecast && i >= nActual;
|
||||
const colW = 100 / data.length;
|
||||
const xPct = (pts[i][0] / W) * 100;
|
||||
const yPct = (pts[i][1] / H) * 100;
|
||||
return (
|
||||
<div key={i} className="td-hit relative flex-1 min-w-0">
|
||||
<div
|
||||
key={i}
|
||||
className="td-hit absolute top-0 bottom-0"
|
||||
style={{ left: `${xPct - colW / 2}%`, width: `${colW}%` }}
|
||||
>
|
||||
<span
|
||||
className="td-dot absolute block rounded-full border-2 border-[var(--surface)]"
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
background: isForecast ? 'var(--accent)' : 'var(--primary)',
|
||||
left: `${(i * stepX / W) * 100}%`,
|
||||
top: `${(pts[i][1] / H) * 100}%`,
|
||||
left: '50%',
|
||||
top: `${yPct}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="td-tip absolute whitespace-nowrap rounded-[var(--r-xs)] px-2 py-1 text-[11px] font-bold"
|
||||
style={{
|
||||
left: `${(i * stepX / W) * 100}%`,
|
||||
top: `${(pts[i][1] / H) * 100}%`,
|
||||
left: '50%',
|
||||
top: `${yPct}%`,
|
||||
transform: 'translate(-50%, calc(-100% - 12px))',
|
||||
background: isForecast ? 'var(--accent)' : 'var(--text)',
|
||||
color: 'var(--on-primary)',
|
||||
|
||||
Reference in New Issue
Block a user