Files
legal-ai/web-ui/src/lib/format-date.ts
Chaim 67c2c43777
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
fix(web-ui): display all human-facing timestamps in Asia/Jerusalem (deterministic)
Storage stays UTC (DB TIMESTAMPTZ, API ISO-UTC) — only the display layer is
localized, and now deterministically: every timestamp renders pinned to
Asia/Jerusalem via a single Intl-based formatter, so SSR (UTC container) and
the browser agree on any runtime. No layout/visible-format change — only the tz.

- New single date formatter web-ui/src/lib/format-date.ts (G2): formatDate /
  formatDateShort / formatDateLong / formatDateTime / formatDateTimeFull /
  formatTime / formatIsoDate + Israel helpers getIsraelYear / israelDayKey /
  israelMidnightMs / israelParts + formatRelative (long/short/tight wording).
- Routed all ad-hoc toLocaleDateString/toLocaleTimeString/toLocaleString +
  hand-rolled new Date(...).get*() / toISOString().slice(0,10) timestamp call
  sites (30 files) through it. Number toLocaleString left untouched.
- Spec: added INV-UI9 to docs/spec/X6 (UTC storage, Asia/Jerusalem display).

Display-only; no layout/design/IA change → Claude Design gate N/A.
Invariants: G2 (single date formatter, no parallel ad-hoc formatting), INV-UI9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:18:09 +00:00

297 lines
10 KiB
TypeScript

/**
* 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<string, number> = { 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} ימים`;
}