From fc3b6b6cae35bd2d84b6046836b18dfb6f5c2ca6 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sun, 3 May 2026 12:05:40 +0000 Subject: [PATCH] ui(precedents): collapsible groups by precedent + Hebrew labels + RTL fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After running the dual-mode halacha extractor on a real appeals committee decision (403-17), the pending-review tab surfaced 351 halachot in a single flat list — the chair correctly pointed out that this is unusable without grouping. Three fixes: 1. Group pending halachot by precedent (case_law_id). Each group shows the citation, court, date, level and item count; default state is collapsed so the chair picks one ruling at a time. Within a group, items still sort by confidence ascending so the doubtful ones surface first. J/K/A/R/E now scope to currently-expanded groups; toggling open auto-focuses the first item. 2. Translate the badges that were leaking English: rule_type values (`persuasive`, `interpretive`, `binding`, `application`, `procedural`, `obiter`) now render as Hebrew labels, and `confidence X.XX` becomes `ביטחון X.XX`. The card header no longer repeats the citation since it's already in the group header. 3. Strip Unicode bidi marks (U+200E/F/202A-E/2066-9) from displayed citations. Nevo PDFs and the upload form embed these in the case_number; they render as zero-width but visually push the text away from the right edge of the table cell. Also: hide the empty court line under the case name in the list (was rendering as a stray em-dash), and use a muted em-dash for empty date/level rather than blank/dash inconsistency across columns. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../precedents/halacha-review-panel.tsx | 309 ++++++++++++------ .../precedents/library-list-panel.tsx | 29 +- 2 files changed, 240 insertions(+), 98 deletions(-) diff --git a/web-ui/src/components/precedents/halacha-review-panel.tsx b/web-ui/src/components/precedents/halacha-review-panel.tsx index 5209e9b..66a001a 100644 --- a/web-ui/src/components/precedents/halacha-review-panel.tsx +++ b/web-ui/src/components/precedents/halacha-review-panel.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { Check, X, Edit2, ChevronDown, ChevronUp, AlertTriangle } from "lucide-react"; +import { Check, X, Edit2, ChevronDown, ChevronLeft, AlertTriangle } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -17,11 +17,11 @@ import { * NO halacha is auto-published; every row sits in pending_review until * approved. * - * UX is optimised for high-throughput approval: - * - Keyboard: J/K cycle rows, A approve, R reject, E edit, Esc cancel - * - Side-by-side: rule_statement vs supporting_quote - * - Quote-verified pill: green check or red triangle - * - Confidence sort: low-confidence rows shown first to be skeptical of + * UX: items are grouped by precedent (case_law_id). Groups start + * collapsed so the chair picks one ruling at a time. Within an open + * group, J/K navigates, A approves, R rejects, E edits. Items inside + * each group are sorted by confidence ascending so the doubtful ones + * surface first. */ function formatDate(iso: string | null | undefined) { @@ -33,6 +33,27 @@ function formatDate(iso: string | null | undefined) { } } +/* The upload form (and Nevo PDFs) embed Unicode bidi marks (RTL/LTR/embedding/ + * isolate) inside the citation. They render as zero-width but visually push + * the text away from where it should sit. Strip for display only. */ +function cleanCitation(s: string | null | undefined): string { + if (!s) return "—"; + return s.replace(/[‎‏‪-‮⁦-⁩]/g, "").trim(); +} + +const RULE_TYPE_LABELS: Record = { + binding: "הלכה מחייבת", + interpretive: "פרשני", + procedural: "פרוצדורלי", + obiter: "אמרת אגב", + application: "יישום הלכה", + persuasive: "משכנע", +}; + +function ruleTypeLabel(t: string): string { + return RULE_TYPE_LABELS[t] ?? t; +} + type EditState = { rule_statement: string; reasoning_summary: string }; function HalachaCard({ @@ -50,10 +71,7 @@ function HalachaCard({ reasoning_summary: h.reasoning_summary, }); - // Reset the editable draft whenever the underlying halacha row changes - // (id or any of its synced fields). Cascade-render is intentional here — - // the form is keyed off the row so a re-mount would also work but loses - // editing affordances on rapid navigation. + // Reset draft when underlying row changes (focus moves to a new card). useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect setDraft({ @@ -75,13 +93,11 @@ function HalachaCard({ ${focused ? "border-gold ring-2 ring-gold/40 shadow-md" : "border-rule"} `} > - {/* Header */} + {/* Header — status pills only (citation is in the group header) */}
- {h.case_number ?? "—"} - {h.court && · {h.court}} - {h.decision_date && · {formatDate(h.decision_date)}} - {h.precedent_level && · {h.precedent_level}} - + {h.page_reference && ( + {h.page_reference} + )} {h.quote_verified ? ( @@ -93,10 +109,10 @@ function HalachaCard({ )} - confidence {h.confidence.toFixed(2)} + ביטחון {h.confidence.toFixed(2)} - {h.rule_type} + {ruleTypeLabel(h.rule_type)}
@@ -118,19 +134,14 @@ function HalachaCard({ )}
-
- ציטוט תומך - {h.page_reference && ( - ({h.page_reference}) - )} -
+
ציטוט תומך
“{h.supporting_quote}”
- {/* Reasoning + tags (collapsible) */} + {/* Reasoning */} {(editing || h.reasoning_summary) && (
תמצית ההיגיון
@@ -148,6 +159,7 @@ function HalachaCard({
)} + {/* Tags */}
{h.practice_areas?.map((p) => ( @@ -161,6 +173,7 @@ function HalachaCard({ ))}
+ {/* Actions */}
{editing ? ( <> @@ -196,37 +209,90 @@ function HalachaCard({ ); } +type Group = { + caseLawId: string; + caseNumber: string; + court: string; + decisionDate: string | null; + precedentLevel: string; + items: Halacha[]; +}; + export function HalachaReviewPanel() { const { data, isPending, error } = useHalachotPending(500); const update = useUpdateHalacha(); - const [focusIdx, setFocusIdx] = useState(0); + const [expandedIds, setExpandedIds] = useState>(new Set()); + const [focusedId, setFocusedId] = useState(null); - // Sort: low confidence first → forces the chair to scrutinize doubtful rows - const items = useMemo(() => { - const list = [...(data?.items ?? [])]; - list.sort((a, b) => a.confidence - b.confidence); - return list; + // Group by precedent. Within each group, items sorted by confidence ascending + // so the chair sees doubtful entries first. Groups themselves sort by item + // count descending — biggest piles surface first. + const groups = useMemo(() => { + const map = new Map(); + for (const h of data?.items ?? []) { + const k = h.case_law_id; + let g = map.get(k); + if (!g) { + g = { + caseLawId: k, + caseNumber: h.case_number ?? "", + court: h.court ?? "", + decisionDate: h.decision_date ?? null, + precedentLevel: h.precedent_level ?? "", + items: [], + }; + map.set(k, g); + } + g.items.push(h); + } + for (const g of map.values()) { + g.items.sort((a, b) => a.confidence - b.confidence); + } + return Array.from(map.values()).sort((a, b) => b.items.length - a.items.length); }, [data]); - // Keep focus index in range as items change (e.g. after approve/reject - // shrinks the queue). Cascade-render is intentional and bounded. - useEffect(() => { - if (focusIdx >= items.length && items.length > 0) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setFocusIdx(items.length - 1); + const totalCount = data?.items.length ?? 0; + + // Items the keyboard handler can navigate. Only items inside expanded + // groups are "visible" — collapsed groups hide their items from J/K. + const visibleItems = useMemo(() => { + const out: Halacha[] = []; + for (const g of groups) { + if (expandedIds.has(g.caseLawId)) out.push(...g.items); } - }, [items.length, focusIdx]); + return out; + }, [groups, expandedIds]); + + // If the focused item disappears (approved/rejected/group-collapsed), pick + // a sensible neighbour so the highlight doesn't vanish silently. + useEffect(() => { + if (focusedId === null) return; + if (visibleItems.some((h) => h.id === focusedId)) return; + // eslint-disable-next-line react-hooks/set-state-in-effect + setFocusedId(visibleItems[0]?.id ?? null); + }, [focusedId, visibleItems]); // Scroll the focused row into view useEffect(() => { - if (!items.length) return; - const target = items[focusIdx]; - if (!target) return; - const el = document.querySelector(`[data-halacha-id="${target.id}"]`); + if (!focusedId) return; + const el = document.querySelector(`[data-halacha-id="${focusedId}"]`); el?.scrollIntoView({ block: "nearest", behavior: "smooth" }); - }, [focusIdx, items]); + }, [focusedId]); - const focused = items[focusIdx]; + const focused = focusedId + ? visibleItems.find((h) => h.id === focusedId) ?? null + : null; + + const moveFocus = (delta: 1 | -1) => { + if (visibleItems.length === 0) return; + const idx = focusedId + ? visibleItems.findIndex((h) => h.id === focusedId) + : -1; + const next = idx < 0 + ? (delta > 0 ? 0 : visibleItems.length - 1) + : Math.max(0, Math.min(visibleItems.length - 1, idx + delta)); + setFocusedId(visibleItems[next].id); + }; const review = async ( h: Halacha, @@ -236,10 +302,7 @@ export function HalachaReviewPanel() { try { await update.mutateAsync({ id: h.id, - patch: { - review_status: status, - ...extra, - }, + patch: { review_status: status, ...extra }, }); toast.success(status === "approved" ? "אושר" : "נדחה"); } catch (e) { @@ -247,31 +310,47 @@ export function HalachaReviewPanel() { } }; - // Keyboard navigation + const toggleGroup = (caseLawId: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(caseLawId)) { + next.delete(caseLawId); + } else { + next.add(caseLawId); + // Auto-focus first item of the just-opened group + const g = groups.find((x) => x.caseLawId === caseLawId); + if (g && g.items.length) { + setTimeout(() => setFocusedId(g.items[0].id), 0); + } + } + return next; + }); + }; + + // Keyboard navigation — only acts when something is focused (i.e. at + // least one group is open). useEffect(() => { const onKey = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement)?.tagName?.toLowerCase(); if (tag === "input" || tag === "textarea") return; - if (!focused) return; if (e.key === "j") { e.preventDefault(); - setFocusIdx((i) => Math.min(items.length - 1, i + 1)); + moveFocus(1); } else if (e.key === "k") { e.preventDefault(); - setFocusIdx((i) => Math.max(0, i - 1)); - } else if (e.key === "a" || e.key === "A") { + moveFocus(-1); + } else if ((e.key === "a" || e.key === "A") && focused) { e.preventDefault(); review(focused, "approved"); - } else if (e.key === "r" || e.key === "R") { + } else if ((e.key === "r" || e.key === "R") && focused) { e.preventDefault(); - if (window.confirm("לדחות הלכה זו?")) { - review(focused, "rejected"); - } + if (window.confirm("לדחות הלכה זו?")) review(focused, "rejected"); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [focused, items.length]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focused, visibleItems]); if (error) { return ( @@ -284,12 +363,12 @@ export function HalachaReviewPanel() { if (isPending) { return (
- {[...Array(3)].map((_, i) => )} + {[...Array(3)].map((_, i) => )}
); } - if (!items.length) { + if (!groups.length) { return (

אין הלכות הממתינות לאישור.

@@ -300,9 +379,10 @@ export function HalachaReviewPanel() { return (
-
+
- {items.length} ממתינות לאישור + {totalCount} ממתינות + ב-{groups.length} פסיקות ניווט: J/K @@ -310,38 +390,83 @@ export function HalachaReviewPanel() { {" "}· דחייה: R {" "}· עריכה: E - - + {expandedIds.size > 0 && ( + + )}
-
- {items.map((h, i) => ( - review(h, "approved")} - onReject={() => { - if (window.confirm("לדחות הלכה זו?")) review(h, "rejected"); - }} - onSave={async (patch) => { - try { - await update.mutateAsync({ id: h.id, patch }); - toast.success("נשמר"); - } catch (e) { - toast.error(e instanceof Error ? e.message : "שגיאה"); - } - }} - /> - ))} +
+ {groups.map((g) => { + const isOpen = expandedIds.has(g.caseLawId); + return ( +
+ + + {isOpen && ( +
+ {g.items.map((h) => ( + review(h, "approved")} + onReject={() => { + if (window.confirm("לדחות הלכה זו?")) review(h, "rejected"); + }} + onSave={async (patch) => { + try { + await update.mutateAsync({ id: h.id, patch }); + toast.success("נשמר"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "שגיאה"); + } + }} + /> + ))} +
+ )} +
+ ); + })}
); diff --git a/web-ui/src/components/precedents/library-list-panel.tsx b/web-ui/src/components/precedents/library-list-panel.tsx index cfe6251..5bef6fe 100644 --- a/web-ui/src/components/precedents/library-list-panel.tsx +++ b/web-ui/src/components/precedents/library-list-panel.tsx @@ -32,6 +32,15 @@ function formatDate(iso: string | null) { } } +/* The upload form (and Nevo PDFs) embed Unicode bidi marks (RTL/LTR/embedding/ + * isolate) inside the citation. They render as zero-width but visually push + * the text away from the cell edge. Strip them for display only — DB still + * has the original. */ +function cleanCitation(s: string | null | undefined): string { + if (!s) return "—"; + return s.replace(/[‎‏‪-‮⁦-⁩]/g, "").trim(); +} + function StatusPill({ p }: { p: Precedent }) { if (p.extraction_status === "failed") { return נכשל; @@ -75,14 +84,18 @@ function PrecedentRow({ return ( - - {p.case_number} + + {cleanCitation(p.case_number)} -
{p.case_name || "—"}
-
{p.court || "—"}
+
{cleanCitation(p.case_name)}
+ {p.court ? ( +
{p.court}
+ ) : null} +
+ + {p.date ? formatDate(p.date) : } - {formatDate(p.date)} {p.practice_area ? ( @@ -93,7 +106,11 @@ function PrecedentRow({ )} - {p.precedent_level || "—"} + {p.precedent_level ? ( + p.precedent_level + ) : ( + + )}