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)}