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:
@@ -2,6 +2,7 @@ import { Controller, useFieldArray } from 'react-hook-form';
|
||||
import type { Control, UseFormRegister } from 'react-hook-form';
|
||||
import type { BlogFormData } from '../lib/blogForm';
|
||||
import PersianDatePicker from './ui/PersianDatePicker';
|
||||
import { toGregorianDate, toTehranClockTime, tehranWallClockToUnix } from '../lib/utils';
|
||||
|
||||
interface Props {
|
||||
control: Control<BlogFormData>;
|
||||
@@ -90,16 +91,16 @@ export default function BlogSeoFields({ control, register }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Gregorian date + time → unix seconds, and back. */
|
||||
/** Gregorian date + time → unix seconds, and back — همیشه به وقت ایران. */
|
||||
function ScheduleField({ value, onChange }: { value: number | null; onChange: (v: number | null) => void }) {
|
||||
const d = value ? new Date(value * 1000) : null;
|
||||
const dateStr = d ? d.toISOString().slice(0, 10) : '';
|
||||
const timeStr = d ? d.toTimeString().slice(0, 5) : '00:00';
|
||||
const dateStr = d ? toGregorianDate(d) : '';
|
||||
const timeStr = d ? toTehranClockTime(d) : '00:00';
|
||||
|
||||
const emit = (nextDate: string, nextTime: string) => {
|
||||
if (!nextDate) return onChange(null);
|
||||
const ms = Date.parse(`${nextDate}T${nextTime || '00:00'}:00`);
|
||||
onChange(Number.isNaN(ms) ? null : Math.floor(ms / 1000));
|
||||
const ts = tehranWallClockToUnix(nextDate, nextTime || '00:00');
|
||||
onChange(Number.isNaN(ts) ? null : ts);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,7 +10,7 @@ import SearchableSelect from '../ui/SearchableSelect';
|
||||
import PersianDateInput from '../ui/PersianDateInput';
|
||||
import { UserTick, FilesServiceAddCard, ClockP, TrashRed } from '../icons/FilesServiceIcons';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { digitsOnly } from '../../lib/utils';
|
||||
import { digitsOnly, todayIso } from '../../lib/utils';
|
||||
import {
|
||||
DEFAULT_SERVICE_CATEGORY, contractPercentFor, patientShareOf,
|
||||
type CoverageRule as Rule, type TenantContract,
|
||||
@@ -29,7 +29,7 @@ const VISIT_SERVICE_CATEGORY = DEFAULT_SERVICE_CATEGORY;
|
||||
// مصرفکنندگان قبلی (و تستها) نشکنند.
|
||||
export { patientShareOf };
|
||||
|
||||
const todayISO = () => new Date().toISOString().slice(0, 10);
|
||||
const todayISO = todayIso;
|
||||
const nowHHMM = () => {
|
||||
const d = new Date();
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { toast } from 'sonner';
|
||||
import { api } from '../../lib/api';
|
||||
import type { ApiResponse } from '../../lib/api';
|
||||
import { PencilIcon, TrashIcon } from '@heroicons/react/24/outline';
|
||||
import { formatRial, formatDateTime, tomanToRial, rialToToman } from '../../lib/utils';
|
||||
import { formatRial, formatDateTime, tomanToRial, rialToToman, todayIso } from '../../lib/utils';
|
||||
import type { DiscountSuggestion } from '../../types';
|
||||
import type { SessionCardData } from '../SessionServiceCard';
|
||||
import SearchableSelect from '../ui/SearchableSelect';
|
||||
@@ -26,7 +26,7 @@ export const METHOD_LABELS: Record<string, string> = {
|
||||
wallet: 'پرداخت از کیف پول', pos: 'پرداخت کارتخوان', cash: 'پرداخت نقدی', card: 'کارت به کارت',
|
||||
};
|
||||
|
||||
const todayISO = () => new Date().toISOString().slice(0, 10);
|
||||
const todayISO = todayIso;
|
||||
/** YYYY-MM-DD → unix (ظهر همان روز تا با هر timezone یک روز بماند) */
|
||||
const isoToUnix = (iso: string) => Math.floor(new Date(`${iso}T12:00:00`).getTime() / 1000);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronRightIcon, ChevronLeftIcon } from '@heroicons/react/24/outline';
|
||||
import { todayIso } from '../../lib/utils';
|
||||
|
||||
interface Props {
|
||||
value: string; // YYYY-MM-DD Gregorian
|
||||
@@ -70,7 +71,8 @@ const JALALI_MONTHS = ['فروردین','اردیبهشت','خرداد','تیر'
|
||||
export default function PersianCalendar({ value, onChange, onClose, enableYearPicker = false }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const todayGreg = new Date().toISOString().slice(0, 10);
|
||||
// «امروز» به وقت ایران، نه UTC: `toISOString` بعد از ۲۰:۳۰ فردا را امروز نشان میداد.
|
||||
const todayGreg = todayIso();
|
||||
const todayJ = toJalali(new Date(todayGreg + 'T12:00:00'));
|
||||
const valueJ = value ? toJalali(new Date(value + 'T12:00:00')) : todayJ;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user