/** * Central date/time formatting — pinned to Israel time (Asia/Jerusalem). * * WHY THIS EXISTS (INV-UI9 / G2): * Storage stays UTC end-to-end (Postgres TIMESTAMPTZ, API returns ISO-UTC). * Only the *display* layer is localized, and it is localized **deterministically**: * every human-facing timestamp renders in `Asia/Jerusalem`, regardless of the * host/container/browser timezone. The legal-ai container runs in UTC, so an * ad-hoc `new Date(iso).toLocaleDateString("he-IL")` shows UTC under SSR (and * UTC again on any non-Israel runtime) — that drift is exactly what this module * removes. Pinning `timeZone: "Asia/Jerusalem"` makes SSR and the browser agree. * * This is the ONE date formatter for the UI. Do not format timestamps ad-hoc * with `new Date(...).toLocaleDateString(...)` / `.toLocaleString(...)` / * `.getHours()` rendering elsewhere — route through these helpers so the tz is * always Israel and nothing drifts. * * Number formatting (e.g. `n.toLocaleString("he-IL")`) is NOT a timestamp and is * intentionally out of scope here. */ const TZ = "Asia/Jerusalem"; const LOCALE = "he-IL"; /** Coerce an ISO string / epoch-ms / Date into a valid Date, or null. */ function toDate(value: string | number | Date | null | undefined): Date | null { if (value == null) return null; const d = value instanceof Date ? value : new Date(value); return Number.isNaN(d.getTime()) ? null : d; } /** * Date only — `dd/MM/yyyy` in Israel time (e.g. "30/06/2026"). * Matches the prior `toLocaleDateString("he-IL", {day,month:"2-digit",year:"numeric"})`. */ export function formatDate( value: string | number | Date | null | undefined, fallback = "—", ): string { const d = toDate(value); if (!d) return fallback; return new Intl.DateTimeFormat(LOCALE, { timeZone: TZ, day: "2-digit", month: "2-digit", year: "numeric", }).format(d); } /** * Date only — long month form (e.g. "30 ביוני 2026") in Israel time. * Matches the prior `toLocaleDateString("he-IL", {day:"numeric",month:"long",year:"numeric"})`. */ export function formatDateLong( value: string | number | Date | null | undefined, fallback = "", ): string { const d = toDate(value); if (!d) return fallback; return new Intl.DateTimeFormat(LOCALE, { timeZone: TZ, day: "numeric", month: "long", year: "numeric", }).format(d); } /** * Date only — short form, locale default (e.g. "30.6.2026") in Israel time. * Matches the prior bare `toLocaleDateString("he-IL")`. */ export function formatDateShort( value: string | number | Date | null | undefined, fallback = "—", ): string { const d = toDate(value); if (!d) return fallback; return new Intl.DateTimeFormat(LOCALE, { timeZone: TZ }).format(d); } /** * Date + time — `d בMMM yyyy, HH:mm` style in Israel time. * Matches the prior `{day:"numeric",month:"short",year:"numeric",hour,minute}`. */ export function formatDateTime( value: string | number | Date | null | undefined, fallback = "—", ): string { const d = toDate(value); if (!d) return fallback; return new Intl.DateTimeFormat(LOCALE, { timeZone: TZ, day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", }).format(d); } /** * Full date + time — locale default with time, Israel time. * Matches the prior bare `toLocaleString("he-IL")`. */ export function formatDateTimeFull( value: string | number | Date | null | undefined, fallback = "—", ): string { const d = toDate(value); if (!d) return fallback; return new Intl.DateTimeFormat(LOCALE, { timeZone: TZ, dateStyle: "short", timeStyle: "short", }).format(d); } /** * Time only — `HH:mm` in Israel time (e.g. "14:05"). * Matches the prior `toLocaleTimeString("he-IL", {hour,minute})`. */ export function formatTime( value: string | number | Date | null | undefined, fallback = "—", ): string { const d = toDate(value); if (!d) return fallback; return new Intl.DateTimeFormat(LOCALE, { timeZone: TZ, hour: "2-digit", minute: "2-digit", }).format(d); } /** * ISO calendar date `YYYY-MM-DD` for the Israel-time day of the instant. * Deterministic replacement for `new Date(iso).toISOString().slice(0,10)`, * which yields the UTC day (off-by-one near midnight Israel time). */ export function formatIsoDate( value: string | number | Date | null | undefined, fallback = "—", ): string { const d = toDate(value); if (!d) return fallback; // en-CA's short date is ISO-shaped (YYYY-MM-DD); pin the tz so it's the Israel day. return new Intl.DateTimeFormat("en-CA", { timeZone: TZ, year: "numeric", month: "2-digit", day: "2-digit", }).format(d); } /** * The calendar year (number) of the Israel-time day for an instant. * Deterministic replacement for `new Date(iso).getFullYear()` (which uses the * runtime tz). Returns null when the value is absent/unparseable. */ export function getIsraelYear( value: string | number | Date | null | undefined, ): number | null { const d = toDate(value); if (!d) return null; const y = new Intl.DateTimeFormat("en-CA", { timeZone: TZ, year: "numeric" }).format(d); const n = Number(y); return Number.isNaN(n) ? null : n; } /** * The Israel-time calendar day of an instant as a sortable integer YYYYMMDD, * or null when absent/unparseable. Use this to compare two dates at *day* * precision in Israel time (e.g. hearing-date vs. today) without runtime-tz drift. */ export function israelDayKey( value: string | number | Date | null | undefined, ): number | null { const iso = formatIsoDate(value, ""); // "YYYY-MM-DD" or "" if (!iso) return null; const n = Number(iso.replace(/-/g, "")); return Number.isNaN(n) ? null : n; } /** Today's Israel-time calendar day as a sortable integer YYYYMMDD. */ export function todayIsraelDayKey(): number { return israelDayKey(new Date()) as number; } /** * The UTC-ms of midnight (00:00) of the instant's *Israel* calendar day. * Day-precision pivot for ms-based comparisons/diffs (e.g. hearing date vs. * today) that must not drift with the runtime timezone. Returns null when the * value is absent/unparseable. Built from the en-CA YYYY-MM-DD of the Israel day * combined with the Israel UTC offset at that instant, so two values from the * same Israel day always reduce to the same ms. */ export function israelMidnightMs( value: string | number | Date | null | undefined, ): number | null { const d = toDate(value); if (!d) return null; const iso = formatIsoDate(d, ""); // YYYY-MM-DD of the Israel day if (!iso) return null; const [y, m, day] = iso.split("-").map(Number); // Israel wall-clock midnight as a UTC instant: take naive UTC midnight of that // calendar day and subtract Israel's offset (Israel is ahead of UTC). const naiveUtcMidnight = Date.UTC(y, m - 1, day); const utc = new Date(d.toLocaleString("en-US", { timeZone: "UTC" })); const isr = new Date(d.toLocaleString("en-US", { timeZone: TZ })); const offsetMs = isr.getTime() - utc.getTime(); return naiveUtcMidnight - offsetMs; } /** UTC-ms of midnight of *today's* Israel calendar day. */ export function todayIsraelMidnightMs(): number { return israelMidnightMs(new Date()) as number; } /** * Numeric date/time components of an instant in Israel time. For the rare call * site that builds a bespoke string from parts (e.g. a custom weekday + dd/MM * HH:mm deadline label) and must do so in Israel time rather than the runtime * tz. `weekday` is 0=Sunday … 6=Saturday (matching `Date.getDay()`). * Returns null when the value is absent/unparseable. */ export function israelParts( value: string | number | Date | null | undefined, ): { year: number; month: number; day: number; hour: number; minute: number; weekday: number } | null { const d = toDate(value); if (!d) return null; const parts = new Intl.DateTimeFormat("en-US", { timeZone: TZ, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", weekday: "short", hour12: false, }).formatToParts(d); const get = (t: string) => parts.find((p) => p.type === t)?.value ?? ""; const WD: Record = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }; let hour = Number(get("hour")); if (hour === 24) hour = 0; // some engines emit "24" for midnight under hour12:false return { year: Number(get("year")), month: Number(get("month")), day: Number(get("day")), hour, minute: Number(get("minute")), weekday: WD[get("weekday")] ?? 0, }; } /** * Relative "time ago" in Hebrew. Timezone-agnostic (it's a diff between two * absolute instants), but centralized here so all relative rendering lives in * one place. `units` selects the existing wording per call site so visible text * is unchanged: * - "long": עכשיו / לפני N דקות / לפני N שעות / לפני N ימים * - "short": עכשיו / לפני N דק׳ / לפני N שע׳ / לפני N ימים * - "tight": לפני Nד' / לפני Nש' / לפני N ימים (no "now" bucket) */ export function formatRelative( value: string | number | Date | null | undefined, opts: { units?: "long" | "short" | "tight"; fallback?: string } = {}, ): string { const { units = "short", fallback = "" } = opts; const d = toDate(value); if (!d) return fallback; const diffMs = Date.now() - d.getTime(); const mins = Math.floor(diffMs / 60_000); const hours = Math.floor(mins / 60); const days = Math.floor(hours / 24); if (units === "tight") { if (mins < 60) return `לפני ${mins}ד'`; if (hours < 24) return `לפני ${hours}ש'`; return `לפני ${days} ימים`; } const now = "עכשיו"; if (units === "long") { if (mins < 1) return now; if (mins < 60) return `לפני ${mins} דקות`; if (hours < 24) return `לפני ${hours} שעות`; return `לפני ${days} ימים`; } // "short" if (mins < 1) return now; if (mins < 60) return `לפני ${mins} דק׳`; if (hours < 24) return `לפני ${hours} שע׳`; return `לפני ${days} ימים`; }