From 2f060bd5be686d9e1a7431bd4023525a3d83e4d9 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 16 Jul 2026 00:25:21 +0330 Subject: [PATCH] feat(timezone): implement Tehran timezone handling across the application --- .../admin/components/AppointmentActions.tsx | 5 +-- .../admin/components/NewAppointmentDrawer.tsx | 4 +- .../dashboard/NewAppointmentsTable.tsx | 2 +- assets/admin/lib/utils.ts | 44 +++++++++++++++++-- assets/admin/pages/AppointmentCreatePage.tsx | 4 +- assets/admin/pages/AppointmentsPage.tsx | 8 ++-- composer.json | 5 ++- config/bootstrap_tz.php | 9 ++++ liara_php.ini | 1 + tests/Shared/TimezoneTest.php | 25 +++++++++++ 10 files changed, 90 insertions(+), 17 deletions(-) create mode 100644 config/bootstrap_tz.php create mode 100644 tests/Shared/TimezoneTest.php diff --git a/assets/admin/components/AppointmentActions.tsx b/assets/admin/components/AppointmentActions.tsx index 9424409e..ec6f8690 100644 --- a/assets/admin/components/AppointmentActions.tsx +++ b/assets/admin/components/AppointmentActions.tsx @@ -20,7 +20,7 @@ import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import type { ApiResponse } from "../lib/api"; import { api } from "../lib/api"; -import { formatRial } from "../lib/utils"; +import { formatRial, tehranWallClockToUnix } from "../lib/utils"; import type { Appointment } from "../types"; import AppointmentStatusDropdown from "./ui/AppointmentStatusDropdown"; import Modal from "./ui/Modal"; @@ -30,8 +30,7 @@ import PriceInput from "./ui/PriceInput"; /** Row actions for the appointments table (Figma عملیات menu). */ type ModalKind = null | "info" | "move" | "transfer" | "replace"; -const toEpoch = (isoDate: string, time: string) => - Math.floor(new Date(`${isoDate}T${time || "00:00"}`).getTime() / 1000); +const toEpoch = (isoDate: string, time: string) => tehranWallClockToUnix(isoDate, time); /** * Resolve the patient-record uuid behind an appointment via the patient list diff --git a/assets/admin/components/NewAppointmentDrawer.tsx b/assets/admin/components/NewAppointmentDrawer.tsx index e48b0989..730e398c 100644 --- a/assets/admin/components/NewAppointmentDrawer.tsx +++ b/assets/admin/components/NewAppointmentDrawer.tsx @@ -8,12 +8,12 @@ import Modal from './ui/Modal'; import PersianDateInput from './ui/PersianDateInput'; import PriceInput from './ui/PriceInput'; import { WalletChargeLink } from './AppointmentActions'; +import { tehranWallClockToUnix } from '../lib/utils'; interface Option { uuid: string; name?: string; full_name?: string } interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string } -const toEpoch = (isoDate: string, time: string) => - Math.floor(new Date(`${isoDate}T${time || '00:00'}`).getTime() / 1000); +const toEpoch = (isoDate: string, time: string) => tehranWallClockToUnix(isoDate, time); const addMinutes = (time: string, min: number) => { const [h, m] = time.split(':').map(Number); const t = h * 60 + m + min; diff --git a/assets/admin/components/dashboard/NewAppointmentsTable.tsx b/assets/admin/components/dashboard/NewAppointmentsTable.tsx index 610961c2..d47bc04a 100644 --- a/assets/admin/components/dashboard/NewAppointmentsTable.tsx +++ b/assets/admin/components/dashboard/NewAppointmentsTable.tsx @@ -61,7 +61,7 @@ function StatusChip({ status }: { status: string }) { function formatTime(ts?: number | null): string { if (!ts) return '—'; - return new Date(ts * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }); + return new Intl.DateTimeFormat('fa-IR', { timeZone: 'Asia/Tehran', hour: '2-digit', minute: '2-digit' }).format(new Date(ts * 1000)); } const HEAD = ['ردیف', 'نام بیمار', 'شماره تماس', 'شروع', 'پایان', 'سرویس', 'پرسنل', 'وضعیت', 'عملیات']; diff --git a/assets/admin/lib/utils.ts b/assets/admin/lib/utils.ts index 152095c4..0a318b55 100644 --- a/assets/admin/lib/utils.ts +++ b/assets/admin/lib/utils.ts @@ -14,6 +14,10 @@ export function formatNumber(n: number): string { return new Intl.NumberFormat('fa-IR').format(n); } +// تایم‌زون رسمی سراسری برنامه = ایران. همهٔ نمایش/تبدیل تاریخ باید با این tz باشد، +// مستقل از تایم‌زون مرورگرِ کاربر (اجباری). +export const APP_TZ = 'Asia/Tehran'; + export function toDate(val: string | number | null | undefined): Date | null { if (val == null || val === '') return null; if (typeof val === 'number') return new Date(val * 1000); @@ -26,6 +30,7 @@ export function formatDate(val: string | number | null | undefined): string { const d = toDate(val); if (!d || isNaN(d.getTime())) return '—'; return new Intl.DateTimeFormat('fa-IR-u-ca-persian', { + timeZone: APP_TZ, year: 'numeric', month: '2-digit', day: '2-digit', }).format(d); } @@ -34,16 +39,47 @@ export function formatDateTime(val: string | number | null | undefined): string const d = toDate(val); if (!d || isNaN(d.getTime())) return '—'; return new Intl.DateTimeFormat('fa-IR-u-ca-persian', { + timeZone: APP_TZ, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }).format(d); } +// ساعت (HH:MM) یک timestamp ثانیه‌ای به وقت ایران — برای تایم‌لاین/جدول نوبت‌ها. +export function formatTime(tsSeconds: number): string { + return new Intl.DateTimeFormat('fa-IR', { + timeZone: APP_TZ, hour: '2-digit', minute: '2-digit', + }).format(new Date(tsSeconds * 1000)); +} + +// اختلاف دقیقه‌ایِ یک تایم‌زون با UTC در لحظهٔ مشخص (ایران ثابت +03:30 است ولی این +// روش عمومی و درست است). +function tzOffsetMinutes(date: Date, tz: string): number { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: tz, hour12: false, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + }).formatToParts(date).reduce>((a, p) => (a[p.type] = p.value, a), {}); + const asUTC = Date.UTC(+parts.year, +parts.month - 1, +parts.day, +parts.hour, +parts.minute, +parts.second); + return Math.round((asUTC - date.getTime()) / 60000); +} + +// «ساعت دیواریِ ایران» (تاریخ Y-m-d + HH:MM) → Unix ثانیه. مستقل از tz مرورگر، تا +// ثبت نوبت با ساعت واردشده دقیقاً همان لحظه به وقت ایران باشد. +export function tehranWallClockToUnix(isoDate: string, time: string): number { + const [Y, M, D] = isoDate.split('-').map(Number); + const [h, m] = (time || '00:00').split(':').map(Number); + const asUtc = Date.UTC(Y, (M || 1) - 1, D || 1, h || 0, m || 0, 0); + const offset = tzOffsetMinutes(new Date(asUtc), APP_TZ); + return Math.floor((asUtc - offset * 60000) / 1000); +} + +// تاریخ تقویمیِ میلادی (Y-m-d) یک لحظه، به وقت ایران. export function toGregorianDate(d: Date): string { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, '0'); - const day = String(d.getDate()).padStart(2, '0'); - return `${y}-${m}-${day}`; + // en-CA → قالب YYYY-MM-DD + return new Intl.DateTimeFormat('en-CA', { + timeZone: APP_TZ, year: 'numeric', month: '2-digit', day: '2-digit', + }).format(d); } // API stores contract dates as Unix seconds; the date input speaks Y-m-d strings. diff --git a/assets/admin/pages/AppointmentCreatePage.tsx b/assets/admin/pages/AppointmentCreatePage.tsx index 95f656fe..30238ed3 100644 --- a/assets/admin/pages/AppointmentCreatePage.tsx +++ b/assets/admin/pages/AppointmentCreatePage.tsx @@ -12,6 +12,7 @@ import SearchableSelect from '../components/ui/SearchableSelect'; import { WalletChargeLink } from '../components/AppointmentActions'; import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices'; import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker'; +import { tehranWallClockToUnix } from '../lib/utils'; /** * افزودن نوبت — صفحهٔ کامل (بازسازی `CreateTurn.jsx` طرح tauri). از همان endpointهای @@ -22,8 +23,7 @@ import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker'; interface Option { uuid: string; name?: string; full_name?: string } interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string } -const toEpoch = (isoDate: string, time: string) => - Math.floor(new Date(`${isoDate}T${time || '00:00'}`).getTime() / 1000); +const toEpoch = (isoDate: string, time: string) => tehranWallClockToUnix(isoDate, time); const addMinutes = (time: string, min: number) => { const [h, m] = time.split(':').map(Number); const t = h * 60 + m + min; diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index be9df220..4825990f 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -9,7 +9,7 @@ import { toast } from 'sonner'; import { api } from '../lib/api'; import type { PaginatedResponse, ApiResponse } from '../lib/api'; import type { Appointment } from '../types'; -import { formatDate, toGregorianDate } from '../lib/utils'; +import { formatDate, toGregorianDate, formatTime } from '../lib/utils'; import { useAuthStore } from '../stores/authStore'; import Pagination from '../components/ui/Pagination'; import AppointmentFiltersModal, { applyAppointmentFilters, EMPTY_FILTERS } from '../components/AppointmentFiltersModal'; @@ -432,7 +432,7 @@ export default function AppointmentsPage() { // حالت سرویسی: اسلات ثابت وجود ندارد — برای هر شیفتِ کاری، نوبت‌های رزروشده // نمایش داده می‌شوند و باقیِ زمان به‌صورت بازه‌(های) خالیِ قابل‌رزرو بین آن‌ها. if (serviceMode) { - const fmt = (ts: number) => new Date(ts * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }); + const fmt = (ts: number) => formatTime(ts); const parseHM = (t: string) => { const [h, m] = (t ?? '00:00').split(':').map(Number); return (h * 3600) + (m * 60); }; const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? []; const booked = [...activeByStart.values()].sort((a, b) => Number(a.slot_start) - Number(b.slot_start)); @@ -479,8 +479,8 @@ export default function AppointmentsPage() { out.push({ start: slotStart, end: slotEnd, - start_time: s.start_time ?? new Date(slotStart * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), - end_time: s.end_time ?? new Date(slotEnd * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), + start_time: s.start_time ?? formatTime(slotStart), + end_time: s.end_time ?? formatTime(slotEnd), is_available: s.is_available as boolean, appointment: activeByStart.get(slotStart) ?? null, cancelled_appointment: cancelledByStart.get(slotStart) ?? null, diff --git a/composer.json b/composer.json index fd628552..3d566543 100644 --- a/composer.json +++ b/composer.json @@ -54,7 +54,10 @@ "autoload": { "psr-4": { "App\\": "src/" - } + }, + "files": [ + "config/bootstrap_tz.php" + ] }, "autoload-dev": { "psr-4": { diff --git a/config/bootstrap_tz.php b/config/bootstrap_tz.php new file mode 100644 index 00000000..2c871e89 --- /dev/null +++ b/config/bootstrap_tz.php @@ -0,0 +1,9 @@ +assertSame('Asia/Tehran', date_default_timezone_get()); + } + + public function testDateFunctionsUseTehran(): void + { + // یک لحظهٔ مشخص UTC → ساعت ایران (+03:30). + $ts = 1750000000; // 2025-06-15 15:06:40 UTC + $this->assertSame('+0330', date('O', $ts)); + $this->assertSame('18:36', date('H:i', $ts)); + } +}