Files
legal-ai/web-ui/src/components/precedents/library-search-panel.tsx
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

218 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
useLibrarySearch, type PracticeArea, type SearchHit,
} from "@/lib/api/precedent-library";
import { PRACTICE_AREAS, PRECEDENT_LEVELS } from "./practice-area";
import { AuthorityBadge } from "./halacha-meta";
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. */
function ScoreChip({ score, onWash }: { score: number; onWash?: boolean }) {
return (
<span
className={`ms-auto rounded-md border border-rule px-2.5 py-0.5 text-xs font-semibold text-gold-deep tabular-nums ${
onWash ? "bg-white" : "bg-surface"
}`}
>
דירוג {score.toFixed(2)}
</span>
);
}
function HalachaCard({ hit }: { hit: Extract<SearchHit, { type: "halacha" }> }) {
return (
<div className="rounded-lg border border-rule bg-gold-wash p-4 shadow-sm space-y-2.5">
<div className="flex items-center gap-2 text-[0.78rem] text-ink-muted flex-wrap">
<Badge className="rounded bg-gold text-white border-0 text-[0.68rem] font-bold tracking-wide">
הלכה
</Badge>
<span className="text-navy font-semibold" dir="ltr">{hit.case_number}</span>
{(hit.court || hit.decision_date || hit.precedent_level) && (
<span className="text-ink-muted">
{hit.court ? `· ${hit.court}` : ""}
{hit.decision_date ? ` · ${formatDate(hit.decision_date)}` : ""}
{hit.precedent_level ? ` · ${hit.precedent_level}` : ""}
</span>
)}
{/* PRE-3/PRE-5 (INV-IA5): derived authority (binding/persuasive)
rides on the wire — render the pill as in the review tab. */}
<AuthorityBadge authority={hit.authority} />
<ScoreChip score={hit.score} onWash />
</div>
<p className="text-ink font-medium text-[0.95rem] leading-7" dir="rtl">
{hit.rule_statement}
</p>
<blockquote
className="rounded-e border-s-[3px] border-gold bg-white/50 px-3 py-2 text-sm text-ink-soft leading-7"
dir="rtl"
>
&ldquo;{hit.supporting_quote}&rdquo;
{hit.page_reference && (
<span className="text-ink-muted text-[0.72rem] ms-2">({hit.page_reference})</span>
)}
</blockquote>
{hit.subject_tags?.length > 0 && (
<div className="flex flex-wrap gap-1">
{hit.subject_tags.map((t) => (
<Badge key={t} variant="outline" className="text-[0.65rem] bg-white">
{t}
</Badge>
))}
</div>
)}
</div>
);
}
function PassageCard({ hit }: { hit: Extract<SearchHit, { type: "passage" }> }) {
return (
<div className="rounded-lg border border-rule bg-surface p-4 shadow-sm space-y-2.5">
<div className="flex items-center gap-2 text-[0.78rem] text-ink-muted flex-wrap">
<Badge variant="outline" className="rounded bg-info-bg text-info border-transparent text-[0.68rem] font-bold tracking-wide">
קטע
</Badge>
<span className="text-navy font-semibold" dir="ltr">{hit.case_number}</span>
{(hit.court || hit.decision_date) && (
<span className="text-ink-muted">
{hit.court ? `· ${hit.court}` : ""}
{hit.decision_date ? ` · ${formatDate(hit.decision_date)}` : ""}
</span>
)}
<span className="rounded bg-rule-soft text-ink-muted text-[0.68rem] px-2 py-0.5 font-medium">
{hit.section_type}
</span>
<ScoreChip score={hit.score} />
</div>
<p className="text-ink-soft text-sm leading-7" dir="rtl">
{hit.content.slice(0, 600)}
{hit.content.length > 600 && <span></span>}
</p>
</div>
);
}
export function LibrarySearchPanel() {
const [draft, setDraft] = useState("");
const [query, setQuery] = useState("");
const [practiceArea, setPracticeArea] = useState<PracticeArea>("");
const [precedentLevel, setPrecedentLevel] = useState("");
const [includeHalachot, setIncludeHalachot] = useState(true);
const { data, isFetching, error } = useLibrarySearch(query, {
practiceArea: practiceArea || undefined,
precedentLevel: precedentLevel || undefined,
includeHalachot,
limit: 20,
});
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
setQuery(draft.trim());
};
return (
<div className="space-y-6">
{/* search panel — boxed surface with query row + two-up filter row +
controls strip (checkbox start, gold CTA end) per mockup 07. */}
<form
onSubmit={onSubmit}
className="rounded-lg border border-rule bg-surface shadow-sm p-5 space-y-3.5"
>
<div>
<label className="block text-[0.78rem] text-ink-muted font-medium mb-1.5">
שאילתת חיפוש
</label>
<Input value={draft} onChange={(e) => setDraft(e.target.value)}
placeholder="השבחה אובייקטיבית" dir="rtl" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3.5">
<div>
<label className="block text-[0.78rem] text-ink-muted font-medium mb-1.5">תחום</label>
<Select value={practiceArea || "_all"}
onValueChange={(v) => setPracticeArea(v === "_all" ? "" : v as PracticeArea)}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="_all">הכל</SelectItem>
{PRACTICE_AREAS.map((a) => (
<SelectItem key={a.value} value={a.value}>{a.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="block text-[0.78rem] text-ink-muted font-medium mb-1.5">רמת תקדים</label>
<Select value={precedentLevel || "_all"}
onValueChange={(v) => setPrecedentLevel(v === "_all" ? "" : v)}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="_all">הכל</SelectItem>
{PRECEDENT_LEVELS.map((l) => (
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-4 flex-wrap pt-1">
<label className="flex items-center gap-2 cursor-pointer text-[0.85rem] text-ink-soft">
<input type="checkbox" className="w-[15px] h-[15px] accent-gold" checked={includeHalachot}
onChange={(e) => setIncludeHalachot(e.target.checked)} />
כלול הלכות
</label>
<Button type="submit" className="ms-auto bg-gold text-white hover:bg-gold-deep border-transparent">
<Search className="w-4 h-4 me-1" />
חפש
</Button>
</div>
</form>
{!query.trim() ? (
<div className="text-center text-ink-muted py-12">
הקלד שאילתא כדי לחפש בקורפוס. החיפוש סמנטי לא טקסטואלי.
</div>
) : error ? (
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
{error.message}
</div>
) : isFetching ? (
<div className="space-y-3">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div>
) : !data?.items.length ? (
<div className="text-center text-ink-muted py-12">
לא נמצאו תוצאות. נסה ניסוח אחר או הסר פילטרים.
</div>
) : (
<div className="space-y-3.5">
<p className="text-[0.82rem] text-ink-muted flex items-center gap-2">
<span>תוצאות</span>
<span className="inline-flex items-center rounded-full bg-success-bg text-success text-[0.72rem] font-semibold px-2.5 py-0.5">
הלכות מאושרות בלבד
</span>
<span aria-hidden>·</span>
<span className="tabular-nums">{data.count} תוצאות</span>
</p>
{data.items.map((hit, i) =>
hit.type === "halacha" ? (
<HalachaCard key={`h-${hit.halacha_id ?? i}`} hit={hit} />
) : (
<PassageCard key={`p-${hit.chunk_id ?? i}`} hit={hit} />
),
)}
</div>
)}
</div>
);
}