refactor: normalize date handling to Tehran timezone

- Updated date handling in BlogSeoFields and ScheduleSection to use Tehran timezone utilities for consistency.
- Introduced `toTehranClockTime`, `tehranWallClockToUnix`, and `todayIso` functions for accurate date representation.
- Modified various components to utilize these new utilities, ensuring that date strings are correctly formatted and timestamps are accurately converted.
- Enhanced API documentation to clarify the handling of date fields, emphasizing the importance of server-local midnight.
- Added tests to verify that date overrides and holidays maintain the correct day without shifting due to timezone discrepancies.
This commit is contained in:
hamed
2026-07-27 19:37:44 +03:30
parent 2963e2ac74
commit b423a0ae4d
21 changed files with 267 additions and 57 deletions
@@ -10,7 +10,7 @@ import {
import { toast } from 'sonner';
import { api, ApiError } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import { formatNumber, digitsOnly } from '../../lib/utils';
import { formatNumber, digitsOnly, todayIso, unixToIso } from '../../lib/utils';
import Modal from '../ui/Modal';
import ConfirmDialog from '../ui/ConfirmDialog';
import GlobalSearchableSelect from '../ui/SearchableSelect';
@@ -37,8 +37,10 @@ export interface AddressData {
// ── Schedule types ─────────────────────────────────────────────────────────
interface SlotConfig { start: string; end: string; duration: number; }
interface DateOverrideData { uuid: string; doctor_uuid: string; date: number; active: boolean; reason: string | null; custom_slots: SessionConfig[]; }
interface HolidayData { uuid: string; doctor_uuid: string; start_date: number; end_date: number; active: boolean; reason: string | null; }
// `*_string` فیلدهای «Y-m-d» سرور هستند؛ برای نمایش همیشه اولویت با آن‌هاست تا
// تبدیل timestamp در مرورگر تاریخ را یک روز جابه‌جا نکند.
interface DateOverrideData { uuid: string; doctor_uuid: string; date: number; date_string?: string; active: boolean; reason: string | null; custom_slots: SessionConfig[]; }
interface HolidayData { uuid: string; doctor_uuid: string; start_date: number; end_date: number; start_date_string?: string; end_date_string?: string; active: boolean; reason: string | null; }
interface SessionConfig {
active: boolean;
@@ -109,7 +111,7 @@ function jFirstDayOfWeek(jy: number, jm: number): number {
const dow = new Date(gy, gm - 1, gd).getDay(); // 0=Sunday
return (dow + 1) % 7; // 0=Saturday, 1=Sunday, ..., 6=Friday
}
function todayGregorian(): string { return new Date().toISOString().slice(0, 10); }
function todayGregorian(): string { return todayIso(); }
// ── Persian Date Picker ────────────────────────────────────────────────────
@@ -285,7 +287,11 @@ function calcSlotCount(session: SessionConfig): number {
return count;
}
function tsToDate(ts: number): string {
return new Date(ts * 1000).toISOString().slice(0, 10);
return unixToIso(ts);
}
/** رشتهٔ `Y-m-d` سرور در اولویت است؛ timestamp فقط fallback رکوردهای قدیمی پاسخ است. */
function dayString(serverString: string | undefined, ts: number): string {
return serverString || tsToDate(ts);
}
// ── Schedule components ────────────────────────────────────────────────────
@@ -925,12 +931,12 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, clinicUuid, on
useEffect(() => {
if (open) {
if (existing) {
setDateStr(tsToDate(existing.date));
setDateStr(dayString(existing.date_string, existing.date));
setOverrideType(existing.active ? 'custom' : 'closed');
setReason(existing.reason ?? '');
setSlots((existing.custom_slots ?? []).map(toSession));
} else {
setDateStr(new Date().toISOString().slice(0, 10));
setDateStr(todayGregorian());
setOverrideType('closed'); setReason(''); setSlots([]);
}
}
@@ -1088,7 +1094,7 @@ function DateOverridesTab({ doctorUuid, clinicUuid, addresses, readOnly = false
<div key={ov.uuid} className="flex items-center gap-3 p-3.5 rounded-xl border border-[var(--border)] bg-[var(--surface)]">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-[var(--text)]">{formatPersianDate(tsToDate(ov.date))}</span>
<span className="text-sm font-medium text-[var(--text)]">{formatPersianDate(dayString(ov.date_string, ov.date))}</span>
{ov.active ? (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-[var(--warning-bg)] text-[var(--warning)]">
{ov.custom_slots.length > 0 ? `${ov.custom_slots.length} بازه سفارشی` : 'ساعات خاص'}
@@ -1141,11 +1147,11 @@ function HolidayModal({ open, onClose, existing, doctorUuid, clinicUuid, onSaved
useEffect(() => {
if (open) {
if (existing) {
setStartDate(tsToDate(existing.start_date));
setEndDate(tsToDate(existing.end_date));
setStartDate(dayString(existing.start_date_string, existing.start_date));
setEndDate(dayString(existing.end_date_string, existing.end_date));
setReason(existing.reason ?? '');
} else {
const today = new Date().toISOString().slice(0, 10);
const today = todayGregorian();
setStartDate(today); setEndDate(today); setReason('');
}
}
@@ -1254,13 +1260,15 @@ function HolidaysTab({ doctorUuid, clinicUuid, readOnly = false }: { doctorUuid:
const now = Math.floor(Date.now() / 1000);
const isPast = h.end_date < now;
const isCurrent = h.start_date <= now && h.end_date >= now;
const sameDay = tsToDate(h.start_date) === tsToDate(h.end_date);
const startDay = dayString(h.start_date_string, h.start_date);
const endDay = dayString(h.end_date_string, h.end_date);
const sameDay = startDay === endDay;
return (
<div key={h.uuid} className={`flex items-center gap-3 p-3.5 rounded-xl border transition-colors ${isCurrent ? 'border-[var(--danger)] bg-[var(--danger-bg)]/40' : isPast ? 'border-[var(--border)] bg-[var(--surface-2)]/40 opacity-70' : 'border-[var(--accent)] bg-[var(--accent-bg)]/30'}`}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-sm font-medium ${isPast ? 'text-[var(--text-2)]' : 'text-[var(--text)]'}`}>
{sameDay ? formatPersianDate(tsToDate(h.start_date)) : `${formatPersianDate(tsToDate(h.start_date))} تا ${formatPersianDate(tsToDate(h.end_date))}`}
{sameDay ? formatPersianDate(startDay) : `${formatPersianDate(startDay)} تا ${formatPersianDate(endDay)}`}
</span>
{isCurrent && h.active && (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-[var(--danger-bg)] text-[var(--danger)] animate-pulse">در جریان</span>