diff --git a/docs/spec/X6-ui-api-contract.md b/docs/spec/X6-ui-api-contract.md
index 28c7c8a..173b320 100644
--- a/docs/spec/X6-ui-api-contract.md
+++ b/docs/spec/X6-ui-api-contract.md
@@ -105,13 +105,36 @@ TanStack Query — *Important Defaults* (staleTime/refetch) (https://tanstack.co
**אכיפה:** `halacha_backlog` מרונדר ב-/operations (לא נזרק); `findings_approved` (review_status, צרכן אמיתי)
החליף את `findings_applied` (דגל מת); מוני-סוכנים מסמנים "חלקי" כשחברה לא-נטענה (גל-1 #130).
+### INV-UI9: זמן-מאוחסן ב-UTC · תצוגה אנושית דטרמיניסטית ב-Asia/Jerusalem
+**כלל:** **האחסון נשאר UTC** מקצה-לקצה — Postgres `TIMESTAMPTZ`, וה-API מחזיר ISO-UTC; אין נרמול-tz
+בשכבת-האחסון/ה-API. **שכבת-התצוגה בלבד** ממירה, ובאופן **דטרמיניסטי**: כל חותמת-זמן אנושית מרונדרת
+ב-`timeZone: "Asia/Jerusalem"` (locale `he-IL`), בלי תלות ב-tz של ה-host/קונטיינר/דפדפן. כיוון
+שהקונטיינר רץ ב-UTC, פירמוט ad-hoc (`new Date(iso).toLocaleDateString("he-IL")`, `.getHours()`,
+`toISOString().slice(0,10)`) מציג UTC תחת SSR (ושוב UTC בכל runtime לא-ישראלי) → drift. נעיצת
+`Asia/Jerusalem` מבטיחה ש-SSR והדפדפן מסכימים. **פורמט-התצוגה (צורת התאריך/שעה) נשמר זהה — רק ה-tz
+מנורמל.** מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
+(מפרמט-תאריך **יחיד**, אין פירמוט-זמן מקביל-מפוזר) בשכבת-התצוגה.
+**מקורות:** ECMAScript Internationalization API — `Intl.DateTimeFormat` `timeZone` option
+(https://tc39.es/ecma402/#datetimeformat-objects) · MDN — *Intl.DateTimeFormat* (IANA time zones)
+(https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat) ·
+Next.js — *Rendering* (SSR משתמש בשעון-השרת אם לא ננעץ)
+(https://nextjs.org/docs/app/building-your-application/rendering) | סטטוס: verified
+**אכיפה:** מפרמט-תאריך יחיד ב-[web-ui/src/lib/format-date.ts](../../web-ui/src/lib/format-date.ts)
+(`formatDate`/`formatDateShort`/`formatDateLong`/`formatDateTime`/`formatDateTimeFull`/`formatTime`/
+`formatIsoDate`/`formatRelative` + עוזרי-Israel `getIsraelYear`/`israelDayKey`/`israelMidnightMs`/
+`israelParts`), כולם נעוצים ל-`Asia/Jerusalem`; כל משטחי-ה-UI מייבאים ממנו ולא מפרמטים tz ad-hoc.
+פירמוט-**מספרים** (`n.toLocaleString`) מחוץ-לתחום (אינו חותמת-זמן). **חריג מוצהר:** קלט
+`` (בקרת-burst ב-`/operations`) הוא ערך הניתן-לכתיבה ע"י המפעיל, לא תצוגת-
+מאוחסן — נשאר בנייה מקומית. **אודיט-נלווה (לא מיושם):** הגדרת `TZ=Asia/Jerusalem` בקונטיינר בטוחה רק
+אם אף לוגיקה עסקית אינה תלויה בשעון-ה-process כ-UTC.
+
---
## 3. כללי-עיצוב (Design Rules) — נגזרים מה-invariants
- **SSoT ל-enums/תוויות/tones:** כל enum (CaseStatus, PracticeArea, AppealSubtype, DocType, outcome) +
תוויותיו + צבעיו מוגדרים **פעם אחת** ונצרכים מיבוא — לא משוכפלים בין דפים/רכיבים (מופע UI1/G2).
-- **helpers משותפים:** פירמוט-תאריך, builder ל-FormData (העלאות), רכיב-שגיאה, query-config (intervals) —
- משותפים, לא מועתקים.
+- **helpers משותפים:** פירמוט-תאריך (מפרמט-יחיד נעוץ-Asia/Jerusalem ב-[format-date.ts](../../web-ui/src/lib/format-date.ts),
+ INV-UI9), builder ל-FormData (העלאות), רכיב-שגיאה, query-config (intervals) — משותפים, לא מועתקים.
- **חוזי-טופס:** ראה INV-UI6 ([X8](X8-field-provenance.md)).
הממצאים הקונקרטיים (כפילויות, הגדרות-שגויות, redundancy) ב-[ui-audit.md](ui-audit.md); התיקון — **FU-10**.
diff --git a/web-ui/src/app/approvals/page.tsx b/web-ui/src/app/approvals/page.tsx
index 646bac5..40d6b7f 100644
--- a/web-ui/src/app/approvals/page.tsx
+++ b/web-ui/src/app/approvals/page.tsx
@@ -9,6 +9,7 @@ import {
type ApprovalCategory,
type ApprovalSeverity,
} from "@/lib/api/chair";
+import { formatDateLong } from "@/lib/format-date";
/**
* מרכז אישורים — דפנה (INV-G10).
@@ -35,18 +36,7 @@ const SEVERITY_RAIL: Record = {
ok: "border-s-success",
};
-function formatDate(iso?: string | null): string {
- if (!iso) return "";
- try {
- return new Date(iso).toLocaleDateString("he-IL", {
- day: "numeric",
- month: "long",
- year: "numeric",
- });
- } catch {
- return "";
- }
-}
+const formatDate = (iso?: string | null) => formatDateLong(iso);
function ApprovalCard({ cat }: { cat: ApprovalCategory }) {
const cleared = cat.count === 0;
diff --git a/web-ui/src/app/archive/page.tsx b/web-ui/src/app/archive/page.tsx
index d900043..0627007 100644
--- a/web-ui/src/app/archive/page.tsx
+++ b/web-ui/src/app/archive/page.tsx
@@ -28,19 +28,7 @@ import {
import { useCases, useRestoreCase, type Case } from "@/lib/api/cases";
import { subtypeOf } from "@/components/cases/appeal-type-bars";
import { APPEAL_SUBTYPE_LABELS, type AppealSubtype } from "@/lib/practice-area";
-
-function formatDate(iso?: string | null) {
- if (!iso) return "—";
- try {
- return new Date(iso).toLocaleDateString("he-IL", {
- day: "2-digit",
- month: "2-digit",
- year: "numeric",
- });
- } catch {
- return iso;
- }
-}
+import { formatDate, getIsraelYear } from "@/lib/format-date";
// type chip styling per mockup 05 (.t-lic / .t-bet / .t-comp)
const TYPE_CHIP: Record = {
@@ -177,8 +165,8 @@ export default function ArchivePage() {
const set = new Set();
for (const c of rows) {
if (!c.archived_at) continue;
- const y = new Date(c.archived_at).getFullYear();
- if (!Number.isNaN(y)) set.add(String(y));
+ const y = getIsraelYear(c.archived_at);
+ if (y !== null) set.add(String(y));
}
return [...set].sort((a, b) => Number(b) - Number(a));
}, [rows]);
@@ -209,7 +197,7 @@ export default function ArchivePage() {
if (yearFilter !== "all") {
all = all.filter((r) => {
const iso = r.original.archived_at;
- return iso != null && String(new Date(iso).getFullYear()) === yearFilter;
+ return iso != null && String(getIsraelYear(iso)) === yearFilter;
});
}
return all;
diff --git a/web-ui/src/app/feedback/page.tsx b/web-ui/src/app/feedback/page.tsx
index b89c65d..5942e1d 100644
--- a/web-ui/src/app/feedback/page.tsx
+++ b/web-ui/src/app/feedback/page.tsx
@@ -16,6 +16,7 @@ import {
type ChairFeedback,
type FeedbackCategory,
} from "@/lib/api/feedback";
+import { formatDateLong } from "@/lib/format-date";
/**
* מרכז הערות יו"ר — הדף המרכזי לטיפול בכל הערות דפנה שנרשמו על טיוטות
@@ -33,18 +34,7 @@ const CAT_CHIP: Record = {
other: "bg-rule-soft text-ink-soft",
};
-function formatDate(iso?: string | null): string {
- if (!iso) return "";
- try {
- return new Date(iso).toLocaleDateString("he-IL", {
- day: "numeric",
- month: "long",
- year: "numeric",
- });
- } catch {
- return "";
- }
-}
+const formatDate = (iso?: string | null) => formatDateLong(iso);
function FeedbackCard({ fb }: { fb: ChairFeedback }) {
const resolve = useResolveFeedback();
diff --git a/web-ui/src/app/operations/page.tsx b/web-ui/src/app/operations/page.tsx
index b2e686f..d9f696b 100644
--- a/web-ui/src/app/operations/page.tsx
+++ b/web-ui/src/app/operations/page.tsx
@@ -36,6 +36,7 @@ import {
type SubscriptionUsage,
type UsageWindow,
} from "@/lib/api/operations";
+import { formatTime, israelParts } from "@/lib/format-date";
function mb(bytes: number): string {
return `${Math.round((bytes || 0) / 1024 / 1024)}MB`;
@@ -46,8 +47,7 @@ function mb(bytes: number): string {
// and resumes on its own when a window resets.
function usageResetLabel(iso: string | null): string {
if (!iso) return "—";
- const d = new Date(iso);
- return `איפוס ${d.toLocaleTimeString("he-IL", { hour: "2-digit", minute: "2-digit" })}`;
+ return `איפוס ${formatTime(iso)}`;
}
function UsageMeter({ label, w }: { label: string; w?: UsageWindow | null }) {
@@ -195,8 +195,9 @@ function nextSaturday18(): Date {
}
const HE_DAYS = ["א׳", "ב׳", "ג׳", "ד׳", "ה׳", "ו׳", "ש׳"];
function fmtDeadline(iso: string): string {
- const d = new Date(iso);
- return `${HE_DAYS[d.getDay()]} ${pad2(d.getDate())}/${pad2(d.getMonth() + 1)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
+ const p = israelParts(iso);
+ if (!p) return "—";
+ return `${HE_DAYS[p.weekday]} ${pad2(p.day)}/${pad2(p.month)} ${pad2(p.hour)}:${pad2(p.minute)}`;
}
function BurstControl({ s }: { s: OpsService }) {
diff --git a/web-ui/src/app/scripts/page.tsx b/web-ui/src/app/scripts/page.tsx
index 9a4c359..6ffd28a 100644
--- a/web-ui/src/app/scripts/page.tsx
+++ b/web-ui/src/app/scripts/page.tsx
@@ -26,6 +26,7 @@ import {
useRunScript,
type ScriptRunResult,
} from "@/lib/api/scripts";
+import { formatDateLong } from "@/lib/format-date";
/*
* /scripts — catalog of everything under scripts/, rendered as the
@@ -354,11 +355,7 @@ export default function ScriptsPage() {
const lastModified =
data?.last_modified != null
- ? new Date(data.last_modified * 1000).toLocaleDateString("he-IL", {
- year: "numeric",
- month: "long",
- day: "numeric",
- })
+ ? formatDateLong(data.last_modified * 1000)
: null;
const giteaBase = data?.gitea_url ?? null;
diff --git a/web-ui/src/app/settings/_components/agents-tab.tsx b/web-ui/src/app/settings/_components/agents-tab.tsx
index 22eecc7..75a1e37 100644
--- a/web-ui/src/app/settings/_components/agents-tab.tsx
+++ b/web-ui/src/app/settings/_components/agents-tab.tsx
@@ -19,6 +19,7 @@ import {
type DriftEntry,
type PaperclipAgent,
} from "@/lib/api/paperclip-agents";
+import { formatDateShort } from "@/lib/format-date";
const ROLE_LABEL: Record = {
ceo: "CEO",
@@ -211,7 +212,7 @@ function PairCard({ pair }: { pair: AgentPair }) {
{pair.master?.updated_at && (
- עודכן: {new Date(pair.master.updated_at).toLocaleDateString("he-IL")}
+ עודכן: {formatDateShort(pair.master.updated_at)}
)}
diff --git a/web-ui/src/app/skills/page.tsx b/web-ui/src/app/skills/page.tsx
index 0b17f79..ac94e6f 100644
--- a/web-ui/src/app/skills/page.tsx
+++ b/web-ui/src/app/skills/page.tsx
@@ -13,6 +13,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { useSkills, type Skill } from "@/lib/api/skills";
+import { formatIsoDate } from "@/lib/format-date";
function formatChars(skill: Skill): string {
// Mockup column = "גודל (תווים)" — the DB markdown char count, grouped.
@@ -21,13 +22,8 @@ function formatChars(skill: Skill): string {
}
function formatUpdated(iso: string | null): string {
- if (!iso) return "—";
- try {
- // ISO-style date to match the mockup (2026-06-09), tabular.
- return new Date(iso).toISOString().slice(0, 10);
- } catch {
- return "—";
- }
+ // ISO-style date to match the mockup (2026-06-09), tabular — Israel-time day.
+ return formatIsoDate(iso);
}
/**
diff --git a/web-ui/src/components/cases/agent-activity-feed.tsx b/web-ui/src/components/cases/agent-activity-feed.tsx
index 87dfd45..43961da 100644
--- a/web-ui/src/components/cases/agent-activity-feed.tsx
+++ b/web-ui/src/components/cases/agent-activity-feed.tsx
@@ -17,6 +17,7 @@ import type {
InteractionTask,
PaperclipComment,
} from "@/lib/api/agents";
+import { formatRelative } from "@/lib/format-date";
import { toast } from "sonner";
import {
Bot,
@@ -83,15 +84,7 @@ function issueStatusLabel(status: string) {
/* ── Time formatting ─────────────────────────────────────────── */
function timeAgo(iso: string | null): string {
- if (!iso) return "";
- const diff = Date.now() - new Date(iso).getTime();
- const mins = Math.floor(diff / 60_000);
- if (mins < 1) return "עכשיו";
- if (mins < 60) return `לפני ${mins} דק׳`;
- const hours = Math.floor(mins / 60);
- if (hours < 24) return `לפני ${hours} שע׳`;
- const days = Math.floor(hours / 24);
- return `לפני ${days} ימים`;
+ return formatRelative(iso, { units: "short", fallback: "" });
}
/* ── Issue identifier → find matching identifier ─────────────── */
diff --git a/web-ui/src/components/cases/agent-activity-preview.tsx b/web-ui/src/components/cases/agent-activity-preview.tsx
index feb553c..dd266d0 100644
--- a/web-ui/src/components/cases/agent-activity-preview.tsx
+++ b/web-ui/src/components/cases/agent-activity-preview.tsx
@@ -4,6 +4,7 @@ import Link from "next/link";
import { Loader2, CheckCircle2, Clock } from "lucide-react";
import { useAgentActivity } from "@/lib/api/agents";
import type { PaperclipIssue } from "@/lib/api/agents";
+import { formatRelative } from "@/lib/format-date";
const STATUS_DOT: Record = {
in_progress: "bg-gold",
@@ -22,13 +23,7 @@ const STATUS_LABEL: Record = {
};
function timeAgo(iso: string | null): string {
- if (!iso) return "—";
- const diffMs = Date.now() - new Date(iso).getTime();
- const m = Math.floor(diffMs / 60_000);
- if (m < 60) return `לפני ${m}ד'`;
- const h = Math.floor(m / 60);
- if (h < 24) return `לפני ${h}ש'`;
- return `לפני ${Math.floor(h / 24)} ימים`;
+ return formatRelative(iso, { units: "tight", fallback: "—" });
}
function IssueRow({ issue }: { issue: PaperclipIssue }) {
diff --git a/web-ui/src/components/cases/case-header.tsx b/web-ui/src/components/cases/case-header.tsx
index 5bcc24c..0b8180a 100644
--- a/web-ui/src/components/cases/case-header.tsx
+++ b/web-ui/src/components/cases/case-header.tsx
@@ -10,21 +10,9 @@ import {
APPEAL_SUBTYPE_LABELS,
isBlamSubtype,
} from "@/lib/practice-area";
+import { formatDate } from "@/lib/format-date";
import type { CaseDetail } from "@/lib/api/cases";
-function formatDate(iso?: string | null) {
- if (!iso) return "—";
- try {
- return new Date(iso).toLocaleDateString("he-IL", {
- day: "2-digit",
- month: "2-digit",
- year: "numeric",
- });
- } catch {
- return iso ?? "—";
- }
-}
-
function partiesLine(data?: CaseDetail): string | null {
const appellant = data?.appellants?.filter(Boolean) ?? [];
const respondent = data?.respondents?.filter(Boolean) ?? [];
diff --git a/web-ui/src/components/cases/cases-table.tsx b/web-ui/src/components/cases/cases-table.tsx
index 9b37bb3..472482e 100644
--- a/web-ui/src/components/cases/cases-table.tsx
+++ b/web-ui/src/components/cases/cases-table.tsx
@@ -24,32 +24,16 @@ import {
import { StatusBadge } from "@/components/cases/status-badge";
import { isBlamSubtype } from "@/lib/practice-area";
import type { Case } from "@/lib/api/cases";
+import { formatDate, israelMidnightMs, todayIsraelMidnightMs } from "@/lib/format-date";
-function formatDate(iso?: string) {
- if (!iso) return "—";
- try {
- return new Date(iso).toLocaleDateString("he-IL", {
- day: "2-digit",
- month: "2-digit",
- year: "numeric",
- });
- } catch {
- return iso;
- }
-}
-
-/** Midnight today, in ms — the pivot between an upcoming and a past hearing. */
+/** Midnight today in Israel time, in ms — pivot between an upcoming and a past hearing. */
function startOfToday() {
- const d = new Date();
- d.setHours(0, 0, 0, 0);
- return d.getTime();
+ return todayIsraelMidnightMs();
}
-/** Day-precision ms for a hearing date, or null when absent/unparseable. */
+/** Day-precision ms (Israel time) for a hearing date, or null when absent/unparseable. */
function hearingMs(iso?: string | null): number | null {
- if (!iso) return null;
- const t = new Date(iso).setHours(0, 0, 0, 0);
- return Number.isNaN(t) ? null : t;
+ return israelMidnightMs(iso);
}
type HearingClass = "soon" | "upcoming" | "past" | "none";
diff --git a/web-ui/src/components/cases/decision-blocks-panel.tsx b/web-ui/src/components/cases/decision-blocks-panel.tsx
index 36274d3..875166e 100644
--- a/web-ui/src/components/cases/decision-blocks-panel.tsx
+++ b/web-ui/src/components/cases/decision-blocks-panel.tsx
@@ -17,6 +17,7 @@ import {
type BlockStatus,
} from "@/lib/api/decision-blocks";
import { BLOCK_LABELS } from "@/lib/api/feedback";
+import { formatTime } from "@/lib/format-date";
import { AlertTriangle, Pencil, FileText } from "lucide-react";
/* ── status badge styling ─────────────────────────────── */
@@ -233,10 +234,7 @@ function SaveIndicator({ state }: { state: SaveState }) {
return ⏳ שומר…;
}
if (state.kind === "saved") {
- const time = state.at.toLocaleTimeString("he-IL", {
- hour: "2-digit",
- minute: "2-digit",
- });
+ const time = formatTime(state.at);
return ✓ נשמר {time};
}
return ⚠ {state.message};
diff --git a/web-ui/src/components/cases/documents-panel.tsx b/web-ui/src/components/cases/documents-panel.tsx
index 4d0052a..24924da 100644
--- a/web-ui/src/components/cases/documents-panel.tsx
+++ b/web-ui/src/components/cases/documents-panel.tsx
@@ -24,6 +24,7 @@ import { apiRequest } from "@/lib/api/client";
import { casesKeys } from "@/lib/api/cases";
import type { CaseDetail, CaseDocument } from "@/lib/api/cases";
import { DocumentTypeEditor } from "@/components/cases/document-type-editor";
+import { formatDateShort as formatDate } from "@/lib/format-date";
/*
* Document list for the case detail "מסמכים" tab. Uses the real document
@@ -76,15 +77,6 @@ function StatusIcon({ status }: { status: string }) {
}
}
-function formatDate(iso: string) {
- if (!iso) return "—";
- try {
- return new Date(iso).toLocaleDateString("he-IL");
- } catch {
- return iso;
- }
-}
-
function filenameFromPath(path: string): string {
const parts = path.split("/");
return parts[parts.length - 1] || path;
diff --git a/web-ui/src/components/cases/drafts-panel.tsx b/web-ui/src/components/cases/drafts-panel.tsx
index bb74dd3..5fa7bdc 100644
--- a/web-ui/src/components/cases/drafts-panel.tsx
+++ b/web-ui/src/components/cases/drafts-panel.tsx
@@ -38,6 +38,7 @@ import {
import { useCaseCitations } from "@/lib/api/citations";
import { learningKeys } from "@/lib/api/learning";
import { LearningStatusBadges } from "@/components/cases/learning-status-badges";
+import { formatDateTime, formatDateShort } from "@/lib/format-date";
import type { CaseStatus } from "@/lib/api/cases";
import { toast } from "sonner";
import {
@@ -72,13 +73,7 @@ function formatSize(bytes: number): string {
}
function formatDate(epoch: number): string {
- return new Date(epoch * 1000).toLocaleDateString("he-IL", {
- day: "numeric",
- month: "short",
- year: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- });
+ return formatDateTime(epoch * 1000);
}
/* ── Main component ─────────────────────────────────── */
@@ -568,9 +563,7 @@ export function DraftsPanel({
)}
- {fb.created_at
- ? new Date(fb.created_at).toLocaleDateString("he-IL")
- : ""}
+ {fb.created_at ? formatDateShort(fb.created_at) : ""}
{fb.feedback_text}
diff --git a/web-ui/src/components/cases/status-hero.tsx b/web-ui/src/components/cases/status-hero.tsx
index a00bddb..3cd734e 100644
--- a/web-ui/src/components/cases/status-hero.tsx
+++ b/web-ui/src/components/cases/status-hero.tsx
@@ -6,6 +6,7 @@ import { StatusChanger } from "@/components/cases/status-changer";
import { StatusGuide } from "@/components/cases/status-guide";
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
import { expectedOutcomes } from "@/lib/schemas/case";
+import { formatDate } from "@/lib/format-date";
import type { CaseDetail } from "@/lib/api/cases";
/*
@@ -21,19 +22,6 @@ const EXPECTED_OUTCOME_LABELS: Record = Object.fromEntries(
expectedOutcomes.map((o) => [o.value, o.label]),
);
-function formatDate(iso?: string | null): string {
- if (!iso) return "—";
- try {
- return new Date(iso).toLocaleDateString("he-IL", {
- day: "2-digit",
- month: "2-digit",
- year: "numeric",
- });
- } catch {
- return iso ?? "—";
- }
-}
-
function phaseIndexOf(status?: CaseStatus): number {
if (!status) return -1;
return PHASES.findIndex((p) => p.statuses.includes(status));
diff --git a/web-ui/src/components/cases/sync-indicator.tsx b/web-ui/src/components/cases/sync-indicator.tsx
index 7fc1593..0cbf647 100644
--- a/web-ui/src/components/cases/sync-indicator.tsx
+++ b/web-ui/src/components/cases/sync-indicator.tsx
@@ -2,17 +2,7 @@
import { useGitStatus } from "@/lib/api/cases";
import { CheckCircle2, AlertCircle, Clock, CloudOff } from "lucide-react";
-
-function formatRelative(iso: string): string {
- const diff = Date.now() - new Date(iso).getTime();
- const mins = Math.floor(diff / 60_000);
- if (mins < 1) return "עכשיו";
- if (mins < 60) return `לפני ${mins} דק׳`;
- const hours = Math.floor(mins / 60);
- if (hours < 24) return `לפני ${hours} שע׳`;
- const days = Math.floor(hours / 24);
- return `לפני ${days} ימים`;
-}
+import { formatRelative } from "@/lib/format-date";
export function SyncIndicator({ caseNumber }: { caseNumber?: string }) {
const { data, isLoading } = useGitStatus(caseNumber);
diff --git a/web-ui/src/components/compose/chair-editor.tsx b/web-ui/src/components/compose/chair-editor.tsx
index 61cc86d..5ce0b6c 100644
--- a/web-ui/src/components/compose/chair-editor.tsx
+++ b/web-ui/src/components/compose/chair-editor.tsx
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { useSaveChairPosition } from "@/lib/api/research";
+import { formatTime } from "@/lib/format-date";
/*
* Chair-position editor for a single threshold claim or issue.
@@ -87,10 +88,7 @@ function SaveIndicator({ state }: { state: SaveState }) {
return ⏳ שומר…;
}
if (state.kind === "saved") {
- const time = state.at.toLocaleTimeString("he-IL", {
- hour: "2-digit",
- minute: "2-digit",
- });
+ const time = formatTime(state.at);
return ✓ נשמר {time};
}
return ⚠ {state.message};
diff --git a/web-ui/src/components/digests/digest-card.tsx b/web-ui/src/components/digests/digest-card.tsx
index 722ad7c..8b889c6 100644
--- a/web-ui/src/components/digests/digest-card.tsx
+++ b/web-ui/src/components/digests/digest-card.tsx
@@ -5,15 +5,7 @@ import type { ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { practiceAreaLabel } from "@/components/precedents/practice-area";
import type { Digest } from "@/lib/api/digests";
-
-function formatDate(iso: string | null) {
- if (!iso) return "—";
- try {
- return new Date(iso).toLocaleDateString("he-IL");
- } catch {
- return iso;
- }
-}
+import { formatDateShort as formatDate } from "@/lib/format-date";
/**
* Presentational card for a single digest ("כל יום" radar entry).
diff --git a/web-ui/src/components/graph/graph-filter-panel.tsx b/web-ui/src/components/graph/graph-filter-panel.tsx
index f057cec..1633daf 100644
--- a/web-ui/src/components/graph/graph-filter-panel.tsx
+++ b/web-ui/src/components/graph/graph-filter-panel.tsx
@@ -26,6 +26,7 @@ import {
} from "@/components/ui/select";
import type { GraphFacets } from "@/lib/api/graph";
import type { ColorBy, SizeBy } from "@/components/graph/graph-canvas";
+import { getIsraelYear } from "@/lib/format-date";
export type GraphControls = {
practiceArea: string;
@@ -51,7 +52,7 @@ const ALL = "__all__";
// Floor covers the oldest dated precedent in the corpus (currently ע"א 725/81 →
// 1982); ceiling tracks the current year so the range never ages out.
const YEAR_FLOOR = 1980;
-const CURRENT_YEAR = new Date().getFullYear();
+const CURRENT_YEAR = getIsraelYear(new Date()) ?? new Date().getFullYear();
const YEARS = Array.from(
{ length: CURRENT_YEAR - YEAR_FLOOR + 1 },
(_, i) => CURRENT_YEAR - i,
diff --git a/web-ui/src/components/missing-precedents/missing-precedents-table.tsx b/web-ui/src/components/missing-precedents/missing-precedents-table.tsx
index 594608d..2c0074f 100644
--- a/web-ui/src/components/missing-precedents/missing-precedents-table.tsx
+++ b/web-ui/src/components/missing-precedents/missing-precedents-table.tsx
@@ -20,15 +20,7 @@ import {
type MissingPrecedentStatus,
} from "@/lib/api/missing-precedents";
import { MissingPrecedentDetailDrawer } from "./missing-precedent-detail-drawer";
-
-function formatDate(iso: string | null) {
- if (!iso) return "—";
- try {
- return new Date(iso).toLocaleDateString("he-IL");
- } catch {
- return iso;
- }
-}
+import { formatDateShort as formatDate } from "@/lib/format-date";
/** Status chip — mockup 09 tones (open=warn, uploaded=info, closed=success,
* irrelevant=muted). Pill-shaped, whitespace-nowrap. */
diff --git a/web-ui/src/components/operations/system-health-section.tsx b/web-ui/src/components/operations/system-health-section.tsx
index e0a7da9..ad19107 100644
--- a/web-ui/src/components/operations/system-health-section.tsx
+++ b/web-ui/src/components/operations/system-health-section.tsx
@@ -14,6 +14,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { useDiagnostics, type DiagDoc } from "@/lib/api/system";
+import { formatDateShort, formatRelative } from "@/lib/format-date";
const TABLE_LABELS: Record = {
cases: "תיקים",
@@ -25,16 +26,10 @@ const TABLE_LABELS: Record = {
function formatRelativeTime(iso: string | null) {
if (!iso) return "—";
- const then = new Date(iso);
- const diffMs = Date.now() - then.getTime();
- const min = Math.floor(diffMs / 60000);
- if (min < 1) return "עכשיו";
- if (min < 60) return `לפני ${min} דקות`;
- const hr = Math.floor(min / 60);
- if (hr < 24) return `לפני ${hr} שעות`;
- const days = Math.floor(hr / 24);
- if (days < 30) return `לפני ${days} ימים`;
- return then.toLocaleDateString("he-IL");
+ const min = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
+ // Past 30 days, fall back to an absolute Israel-time date.
+ if (min >= 30 * 24 * 60) return formatDateShort(iso);
+ return formatRelative(iso, { units: "long" });
}
function DocRow({ doc, tone }: { doc: DiagDoc; tone: "danger" | "warn" }) {
diff --git a/web-ui/src/components/precedents/halacha-review-panel.tsx b/web-ui/src/components/precedents/halacha-review-panel.tsx
index 1004216..5e55a6e 100644
--- a/web-ui/src/components/precedents/halacha-review-panel.tsx
+++ b/web-ui/src/components/precedents/halacha-review-panel.tsx
@@ -15,6 +15,7 @@ import {
type Halacha, type CanonicalInstance,
} from "@/lib/api/precedent-library";
import { AuthorityBadge, ruleTypeLabel } from "./halacha-meta";
+import { formatDateShort as formatDate } from "@/lib/format-date";
/** #81 strict-rubric flags — why an item was held back from auto-approval. */
const QUALITY_FLAG_LABELS: Record = {
@@ -28,15 +29,6 @@ const QUALITY_FLAG_LABELS: Record = {
nevo_preamble_leak: "דליפת רציו נבו",
};
-function formatDate(iso: string | null | undefined) {
- if (!iso) return "—";
- try {
- return new Date(iso).toLocaleDateString("he-IL");
- } catch {
- return iso;
- }
-}
-
/* Strip Unicode bidi marks that render as zero-width but shift visual position. */
function cleanCitation(s: string | null | undefined): string {
if (!s) return "—";
diff --git a/web-ui/src/components/precedents/library-list-panel.tsx b/web-ui/src/components/precedents/library-list-panel.tsx
index c4a94d0..15049c2 100644
--- a/web-ui/src/components/precedents/library-list-panel.tsx
+++ b/web-ui/src/components/precedents/library-list-panel.tsx
@@ -31,15 +31,7 @@ import {
FormattedCitation,
CitationCopyButton,
} from "./formatted-citation";
-
-function formatDate(iso: string | null) {
- if (!iso) return "—";
- try {
- return new Date(iso).toLocaleDateString("he-IL");
- } catch {
- return iso;
- }
-}
+import { formatDateShort as formatDate } from "@/lib/format-date";
function cleanCitation(s: string | null | undefined): string {
if (!s) return "—";
diff --git a/web-ui/src/components/precedents/library-search-panel.tsx b/web-ui/src/components/precedents/library-search-panel.tsx
index dafc8f8..c7b4e48 100644
--- a/web-ui/src/components/precedents/library-search-panel.tsx
+++ b/web-ui/src/components/precedents/library-search-panel.tsx
@@ -14,15 +14,7 @@ import {
} from "@/lib/api/precedent-library";
import { PRACTICE_AREAS, PRECEDENT_LEVELS } from "./practice-area";
import { AuthorityBadge } from "./halacha-meta";
-
-function formatDate(iso: string | null) {
- if (!iso) return "—";
- try {
- return new Date(iso).toLocaleDateString("he-IL");
- } catch {
- return iso;
- }
-}
+import { formatDateShort as formatDate } from "@/lib/format-date";
/** Score chip — boxed, gold-deep, tabular (mockup 07 `.score`). Sits at the
* end of the meta row via `ms-auto`. White fill on gold-wash halacha cards. */
diff --git a/web-ui/src/components/precedents/precedent-upload-sheet.tsx b/web-ui/src/components/precedents/precedent-upload-sheet.tsx
index 3329767..f003684 100644
--- a/web-ui/src/components/precedents/precedent-upload-sheet.tsx
+++ b/web-ui/src/components/precedents/precedent-upload-sheet.tsx
@@ -23,6 +23,7 @@ import {
} from "@/lib/api/precedent-library";
import { ApiError } from "@/lib/api/client";
import { useProgress } from "@/lib/api/documents";
+import { formatDateShort } from "@/lib/format-date";
import {
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES,
} from "./practice-area";
@@ -192,7 +193,7 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
{conflict.citation}
{conflict.case_name && ` — ${conflict.case_name}`}
{conflict.court && ` · ${conflict.court}`}
- {conflict.date && ` · ${new Date(conflict.date).toLocaleDateString("he-IL")}`}
+ {conflict.date && ` · ${formatDateShort(conflict.date)}`}
{conflict.halacha_extraction_status && (
diff --git a/web-ui/src/components/training/chat-panel.tsx b/web-ui/src/components/training/chat-panel.tsx
index 07c7903..b649b87 100644
--- a/web-ui/src/components/training/chat-panel.tsx
+++ b/web-ui/src/components/training/chat-panel.tsx
@@ -41,6 +41,7 @@ import {
useDeleteChat,
type ChatMessage,
} from "@/lib/api/training";
+import { formatDateShort } from "@/lib/format-date";
import { useQueryClient } from "@tanstack/react-query";
export function ChatPanel() {
@@ -200,7 +201,7 @@ function ConversationsSidebar({
{c.message_count}
- {new Date(c.last_message_at).toLocaleDateString("he-IL")}
+ {formatDateShort(c.last_message_at)}