feat(dashboard): implement Jalali month start calculation and add related tests

This commit is contained in:
hamed
2026-08-04 20:31:08 +03:30
parent df8a43f8b2
commit 9d767a6c63
4 changed files with 240 additions and 24 deletions
@@ -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)',
@@ -57,7 +57,7 @@ class DashboardController extends BaseController
$clinicId = $clinic->getId();
$todayStart = strtotime('today midnight');
$todayEnd = strtotime('tomorrow midnight') - 1;
$monthStart = strtotime('first day of this month midnight');
$monthStart = $this->currentJalaliMonthStart();
$from = $request->query->get('from') ? (int) $request->query->get('from') : $monthStart;
$to = $request->query->get('to') ? (int) $request->query->get('to') : time();
@@ -214,7 +214,7 @@ class DashboardController extends BaseController
$todayEnd = strtotime('tomorrow midnight') - 1;
$tmrStart = strtotime('tomorrow midnight');
$tmrEnd = strtotime('tomorrow midnight') + 86399;
$monthStart = strtotime('first day of this month midnight');
$monthStart = $this->currentJalaliMonthStart();
$from = $request->query->get('from') ? (int) $request->query->get('from') : $monthStart;
$to = $request->query->get('to') ? (int) $request->query->get('to') : time();
@@ -404,6 +404,20 @@ class DashboardController extends BaseController
];
}
/**
* آغاز ماه شمسی جاری.
*
* «این ماه» در محصولی که تقویمش شمسی است یعنی ماه شمسی، نه گرگوری. با
* `strtotime('first day of this month')` آمارِ داشبورد اولِ ژانویه/فوریه/… صفر
* می‌شد — وسط مرداد یا آبان — و هیچ‌کجای پنل چنین مرزی را نشان نمی‌دهد.
*/
private function currentJalaliMonthStart(): int
{
[$year, $month] = $this->currentJalaliYearMonth();
return $this->jalaliMonthRange($year, $month)['start'];
}
/**
* بازه‌ی یونیکس یک ماه شمسی و تعداد روزهای آن.
* @return array{start:int, end:int, days:int}
@@ -0,0 +1,93 @@
<?php
namespace App\Tests\Dashboard;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
/**
* `this_month_appointments` باید سر ماه **شمسی** صفر شود، نه سر ماه گرگوری.
*
* قبلاً مرزش `strtotime('first day of this month')` بود؛ یعنی آمار داشبورد وسط
* مرداد یا آبان ریست می‌شد — مرزی که هیچ‌جای این محصولِ شمسی دیده نمی‌شود.
*
* تست به تاریخِ اجرا وابسته نیست: دو نوبت درست در دو طرفِ مرزِ ماه شمسی ساخته
* می‌شوند. چون آغاز ماه شمسی و آغاز ماه گرگوری هیچ‌وقت بر هم منطبق نیستند، کدِ
* گرگوری همیشه دست‌کم یکی از این دو را اشتباه می‌شمارد.
*/
class DashboardJalaliMonthTest extends ApiTestCase
{
/** آغاز ماه شمسی جاری به یونیکس — همان محاسبه‌ای که کنترلر باید بکند. */
private function jalaliMonthStart(): int
{
$cal = \IntlCalendar::createInstance(new \DateTimeZone('Asia/Tehran'), 'fa_IR@calendar=persian');
$cal->set(\IntlCalendar::FIELD_DAY_OF_MONTH, 1);
$cal->set(\IntlCalendar::FIELD_HOUR_OF_DAY, 0);
$cal->set(\IntlCalendar::FIELD_MINUTE, 0);
$cal->set(\IntlCalendar::FIELD_SECOND, 0);
$cal->set(\IntlCalendar::FIELD_MILLISECOND, 0);
return (int) ($cal->getTime() / 1000);
}
/** یک نوبت در لحظهٔ داده‌شده. */
private function appointmentAt(Doctor $doctor, User $patient, int $start): void
{
$this->em->persist($this->newAppointment($doctor, $patient, $start, $start + 1_800));
$this->em->flush();
}
public function testDoctorMonthCounterResetsOnTheJalaliMonthBoundary(): void
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'حمیدی');
$this->em->persist($doctor);
$this->em->flush();
$start = $this->jalaliMonthStart();
// یکی داخل ماه شمسی جاری، یکی درست پیش از آغازش.
$this->appointmentAt($doctor, $this->createUser(['ROLE_USER']), $start + 3_600);
$this->appointmentAt($doctor, $this->createUser(['ROLE_USER']), $start - 3_600);
$res = $this->authJson('GET', '/api/v1/dashboard/doctor', $owner);
self::assertSame(200, $this->responseCode());
self::assertSame(
1,
$res['data']['stats']['this_month_appointments'],
'فقط نوبتِ پس از آغاز ماه شمسی باید شمرده شود',
);
}
public function testClinicMonthCounterResetsOnTheJalaliMonthBoundary(): void
{
$clinicOwner = $this->createUser(['ROLE_CLINIC']);
$clinic = new Clinic($clinicOwner);
$clinic->setName('کلینیک نمونه');
$docOwner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($docOwner, 'حمیدی');
$clinic->getDoctors()->add($doctor);
$this->em->persist($doctor);
$this->em->persist($clinic);
$this->em->flush();
$start = $this->jalaliMonthStart();
$this->appointmentAt($doctor, $this->createUser(['ROLE_USER']), $start + 3_600);
$this->appointmentAt($doctor, $this->createUser(['ROLE_USER']), $start - 3_600);
$res = $this->authJson('GET', '/api/v1/dashboard/clinic', $clinicOwner);
self::assertSame(200, $this->responseCode());
self::assertSame(
1,
$res['data']['stats']['this_month_appointments'],
'فقط نوبتِ پس از آغاز ماه شمسی باید شمرده شود',
);
}
}