From 9d767a6c63d8de192a38fc754530351efc7b4354 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 4 Aug 2026 20:31:08 +0330 Subject: [PATCH] feat(dashboard): implement Jalali month start calculation and add related tests --- .../components/dashboard/TauriCharts.test.tsx | 90 ++++++++++++++++++ .../components/dashboard/TauriCharts.tsx | 63 ++++++++----- .../Controller/DashboardController.php | 18 +++- tests/Dashboard/DashboardJalaliMonthTest.php | 93 +++++++++++++++++++ 4 files changed, 240 insertions(+), 24 deletions(-) create mode 100644 tests/Dashboard/DashboardJalaliMonthTest.php diff --git a/assets/admin/components/dashboard/TauriCharts.test.tsx b/assets/admin/components/dashboard/TauriCharts.test.tsx index 3d214553..9d8aa6b8 100644 --- a/assets/admin/components/dashboard/TauriCharts.test.tsx +++ b/assets/admin/components/dashboard/TauriCharts.test.tsx @@ -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(); + 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(); + 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(); @@ -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('.td-hit')).map((el) => { + const left = parseFloat(el.style.left); + const width = parseFloat(el.style.width); + return left + width / 2; + }); + + it('اولین نقطه سمت راست و آخرین نقطه سمت چپ می‌نشیند', () => { + const { container } = render(); + const centers = hitCenters(container); + + expect(centers[0]).toBeCloseTo(100, 5); + expect(centers[centers.length - 1]).toBeCloseTo(0, 5); + }); + + it('نقطه‌ها به ترتیب زمان از راست به چپ پیش می‌روند', () => { + const { container } = render(); + const centers = hitCenters(container); + + for (let i = 1; i < centers.length; i++) { + expect(centers[i]).toBeLessThan(centers[i - 1]); + } + }); + + /** بخش واقعی سمت راست است و پیش‌بینی سمت چپ، نه برعکس. */ + it('بخش واقعی راست‌تر از بخش پیش‌بینی است', () => { + const { container } = render(); + const centers = hitCenters(container); + + const lastActual = centers[3]; + const firstForecast = centers[4]; + expect(lastActual).toBeGreaterThan(firstForecast); + }); + + /** نقطه باید روی خط بنشیند، نه داخل ستونِ خودش سُر بخورد. */ + it('نقطه در مرکز ستون هاورِ خودش است', () => { + const { container } = render(); + + for (const dot of Array.from(container.querySelectorAll('.td-dot'))) { + expect(dot.style.left).toBe('50%'); + } + }); +}); diff --git a/assets/admin/components/dashboard/TauriCharts.tsx b/assets/admin/components/dashboard/TauriCharts.tsx index 23fb1f53..a850543b 100644 --- a/assets/admin/components/dashboard/TauriCharts.tsx +++ b/assets/admin/components/dashboard/TauriCharts.tsx @@ -88,18 +88,11 @@ function ChartFrame({ // column to zero height. Inline `alignItems` is the only reliable override. return (
+ {/* ستونِ مقادیر سمت چپ می‌نشیند و نمودار سمت راست. جریان صفحه RTL است، پس + «اول در JSX» یعنی «راست روی صفحه» — ترتیب عمداً برعکسِ ترتیب دیداری است. + با `direction: ltr` هم می‌شد، ولی آن به میله‌های نمودار ستونی ارث می‌رسید و + ترتیبشان را نسبت به برچسب‌ها وارونه می‌کرد. */}
- {/* y-axis ticks, aligned to gridlines */} -
- {ticks.map((t, i) => ( - - {faNum.format(t)} - - ))} -
{/* plot area */} {/* overflow-hidden keeps a dense series (a 31-day month) inside the card */}
@@ -112,9 +105,20 @@ function ChartFrame({ ))} {children}
+ {/* y-axis ticks, aligned to gridlines — چسبیده به لبهٔ نمودار، پس راست‌چین */} +
+ {ticks.map((t, i) => ( + + {faNum.format(t)} + + ))} +
{/* x-axis labels */} -
+
{labels.map((l, i) => ( {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" > - {/* رنگِ خط در طول محور افقی از برند به اکسنت می‌رود (سبک دموی apex) */} - + {/* رنگِ خط در طول زمان از برند به اکسنت می‌رود (سبک دموی apex). چون زمان + راست‌به‌چپ می‌رود، گرادیان هم از راست شروع می‌شود. */} + @@ -254,28 +263,38 @@ export function TauriLineChart({ data, actualCount }: { data: ChartPoint[]; actu )} - {/* لایهٔ تعامل: هر ستون یک نقطه را هاور می‌کند (بدون state، فقط CSS) */} -
+ {/* لایهٔ تعامل: هر ستون یک نقطه را هاور می‌کند (بدون state، فقط CSS). + ستون‌ها با `left` فیزیکی و دقیقاً حولِ نقطهٔ خودشان می‌نشینند، نه با flex: + در flex، هم ترتیبشان به جهت متن گره می‌خورد و هم `left`ِ نقطه نسبت به ستون + حساب می‌شد نه نسبت به نمودار — یعنی نقطه داخل ستون سُر می‌خورد. */} +
{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 ( -
+
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} diff --git a/tests/Dashboard/DashboardJalaliMonthTest.php b/tests/Dashboard/DashboardJalaliMonthTest.php new file mode 100644 index 00000000..ceb02415 --- /dev/null +++ b/tests/Dashboard/DashboardJalaliMonthTest.php @@ -0,0 +1,93 @@ +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'], + 'فقط نوبتِ پس از آغاز ماه شمسی باید شمرده شود', + ); + } +}