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>
131 lines
4.9 KiB
TypeScript
131 lines
4.9 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
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";
|
||
import { formatDateShort as formatDate } from "@/lib/format-date";
|
||
|
||
/**
|
||
* Presentational card for a single digest ("כל יום" radar entry).
|
||
*
|
||
* A digest is a SECONDARY source that POINTS at a ruling — the card makes the
|
||
* pointer-not-citation nature explicit: the underlying ruling's citation is
|
||
* shown with a link-status badge (linked → the ruling is in the precedent
|
||
* library; unlinked → an open knowledge gap, INV-DIG3).
|
||
*/
|
||
export function DigestCard({
|
||
digest,
|
||
score,
|
||
actions,
|
||
}: {
|
||
digest: Digest;
|
||
score?: number;
|
||
actions?: ReactNode;
|
||
}) {
|
||
const linked = Boolean(digest.linked_case_law_id);
|
||
const pending = digest.extraction_status !== "completed";
|
||
return (
|
||
<div className="flex flex-col rounded-lg border border-rule bg-surface shadow-sm p-4">
|
||
{/* top row — yomon-number + date at start, concept tag pushed to end */}
|
||
<div className="flex items-center gap-2.5 mb-2.5 flex-wrap">
|
||
{digest.yomon_number && (
|
||
<span className="text-navy font-bold text-[0.95rem]">
|
||
יומון {digest.yomon_number}
|
||
</span>
|
||
)}
|
||
{digest.digest_date && (
|
||
<span className="text-ink-muted text-[0.78rem]">· {formatDate(digest.digest_date)}</span>
|
||
)}
|
||
{digest.publication && digest.publication !== "כל יום" && (
|
||
<Badge variant="outline" className="bg-rule-soft text-ink-muted text-[0.65rem]">
|
||
{digest.publication}
|
||
</Badge>
|
||
)}
|
||
{digest.digest_kind === "announcement" && (
|
||
<Badge variant="outline" className="bg-sky-50 text-sky-700 border-sky-300 text-[0.65rem]">
|
||
עדכון
|
||
</Badge>
|
||
)}
|
||
{digest.digest_kind === "article" && (
|
||
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-300 text-[0.65rem]">
|
||
מאמר
|
||
</Badge>
|
||
)}
|
||
{digest.concept_tag && (
|
||
<span className="ms-auto rounded-full bg-info-bg text-info text-[0.72rem] font-semibold px-2.5 py-0.5">
|
||
{digest.concept_tag}
|
||
</span>
|
||
)}
|
||
{typeof score === "number" && (
|
||
<span className="ms-1 tabular-nums text-ink-muted text-[0.78rem]">
|
||
דירוג {score.toFixed(2)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* holding — the digest headline */}
|
||
{digest.headline_holding && (
|
||
<p className="text-ink font-medium text-[0.92rem] leading-6 mb-2.5" dir="rtl">
|
||
{digest.headline_holding}
|
||
</p>
|
||
)}
|
||
|
||
{/* source ruling — "מקור:" + citation, start-bordered (mockup `.ruling`) */}
|
||
<div className="border-s-2 border-rule ps-3 text-[0.8rem] text-ink-muted leading-6" dir="rtl">
|
||
<b className="text-ink-soft font-semibold">מקור:</b>{" "}
|
||
{digest.underlying_citation || "—"}
|
||
</div>
|
||
|
||
{digest.summary && (
|
||
<p className="text-ink-soft text-[0.82rem] leading-relaxed mt-2" dir="rtl">
|
||
{digest.summary}
|
||
</p>
|
||
)}
|
||
|
||
{digest.practice_area && (
|
||
<span className="text-ink-muted text-[0.72rem] mt-2">
|
||
{practiceAreaLabel(digest.practice_area)}
|
||
</span>
|
||
)}
|
||
|
||
{digest.subject_tags?.length > 0 && (
|
||
<div className="flex flex-wrap gap-1 mt-2">
|
||
{digest.subject_tags.map((t) => (
|
||
<Badge key={t} variant="outline" className="text-[0.65rem] bg-surface">
|
||
{t}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* foot — link-status chip + passive "ממתין לעיבוד" label (mockup `.foot`) */}
|
||
<div className="mt-3 pt-3 border-t border-rule-soft flex items-center gap-2 flex-wrap">
|
||
{linked ? (
|
||
<Link href={`/precedents/${digest.linked_case_law_id}`}>
|
||
<span className="rounded-full bg-success-bg text-success text-[0.72rem] font-semibold px-3 py-0.5 hover:opacity-90">
|
||
מקושר לפסיקה ↗
|
||
</span>
|
||
</Link>
|
||
) : (
|
||
<span className="rounded-full bg-rule-soft text-ink-muted text-[0.72rem] font-semibold px-3 py-0.5">
|
||
לא מקושר
|
||
</span>
|
||
)}
|
||
{pending && (
|
||
/* passive status label — a dot + text, deliberately NOT a button */
|
||
<span className="ms-auto inline-flex items-center gap-1.5 text-[0.72rem] font-medium text-ink-muted">
|
||
<span className="h-[7px] w-[7px] rounded-full bg-warn/60" aria-hidden />
|
||
{digest.extraction_status === "pending" ? "ממתין לעיבוד" : digest.extraction_status}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{actions && (
|
||
<div className="flex items-center gap-2 pt-3">{actions}</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|