feat(timezone): implement Tehran timezone handling across the application

This commit is contained in:
hamed
2026-07-16 00:25:21 +03:30
parent 8cd1253b81
commit 2f060bd5be
10 changed files with 90 additions and 17 deletions
@@ -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
@@ -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;
@@ -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 = ['ردیف', 'نام بیمار', 'شماره تماس', 'شروع', 'پایان', 'سرویس', 'پرسنل', 'وضعیت', 'عملیات'];
+40 -4
View File
@@ -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<Record<string, string>>((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.
+2 -2
View File
@@ -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;
+4 -4
View File
@@ -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,
+4 -1
View File
@@ -54,7 +54,10 @@
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"files": [
"config/bootstrap_tz.php"
]
},
"autoload-dev": {
"psr-4": {
+9
View File
@@ -0,0 +1,9 @@
<?php
/**
* تایم‌زون سراسری اپلیکیشن = ایران (Asia/Tehran). اجباری و مستقل از php.ini سرور.
* از طریق composer autoload.files روی همهٔ entrypointها (HTTP، console، worker،
* تست) پیش از بوتِ Kernel اجرا می‌شود تا تمام توابع تاریخ/ساعت (strtotime، date،
* DateTime، محاسبهٔ نوبت‌دهی سرویسی) با ساعت رسمی ایران کار کنند.
*/
date_default_timezone_set('Asia/Tehran');
+1
View File
@@ -1,4 +1,5 @@
file_uploads = On
date.timezone = Asia/Tehran
memory_limit = 1024M
upload_max_filesize = 16M
post_max_size = 24M
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Tests\Shared;
use PHPUnit\Framework\TestCase;
/**
* تایم‌زون سراسری اپلیکیشن باید ایران باشد (اجباری، مستقل از php.ini سرور).
* توسط config/bootstrap_tz.php از طریق composer autoload.files تضمین می‌شود.
*/
class TimezoneTest extends TestCase
{
public function testDefaultTimezoneIsTehran(): void
{
$this->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));
}
}