ui(precedents): collapsible groups by precedent + Hebrew labels + RTL fixes
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 33s

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 12:05:40 +00:00
parent 2cfdf35191
commit fc3b6b6cae
2 changed files with 240 additions and 98 deletions

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; 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 { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -17,11 +17,11 @@ import {
* NO halacha is auto-published; every row sits in pending_review until * NO halacha is auto-published; every row sits in pending_review until
* approved. * approved.
* *
* UX is optimised for high-throughput approval: * UX: items are grouped by precedent (case_law_id). Groups start
* - Keyboard: J/K cycle rows, A approve, R reject, E edit, Esc cancel * collapsed so the chair picks one ruling at a time. Within an open
* - Side-by-side: rule_statement vs supporting_quote * group, J/K navigates, A approves, R rejects, E edits. Items inside
* - Quote-verified pill: green check or red triangle * each group are sorted by confidence ascending so the doubtful ones
* - Confidence sort: low-confidence rows shown first to be skeptical of * surface first.
*/ */
function formatDate(iso: string | null | undefined) { 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<string, string> = {
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 }; type EditState = { rule_statement: string; reasoning_summary: string };
function HalachaCard({ function HalachaCard({
@@ -50,10 +71,7 @@ function HalachaCard({
reasoning_summary: h.reasoning_summary, reasoning_summary: h.reasoning_summary,
}); });
// Reset the editable draft whenever the underlying halacha row changes // Reset draft when underlying row changes (focus moves to a new card).
// (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.
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setDraft({ setDraft({
@@ -75,13 +93,11 @@ function HalachaCard({
${focused ? "border-gold ring-2 ring-gold/40 shadow-md" : "border-rule"} ${focused ? "border-gold ring-2 ring-gold/40 shadow-md" : "border-rule"}
`} `}
> >
{/* Header */} {/* Header — status pills only (citation is in the group header) */}
<div className="flex items-start gap-2 text-[0.78rem] text-ink-muted flex-wrap"> <div className="flex items-start gap-2 text-[0.78rem] text-ink-muted flex-wrap">
<span className="font-mono text-navy" dir="ltr">{h.case_number ?? "—"}</span> {h.page_reference && (
{h.court && <span>· {h.court}</span>} <span className="text-[0.7rem]">{h.page_reference}</span>
{h.decision_date && <span>· {formatDate(h.decision_date)}</span>} )}
{h.precedent_level && <span>· {h.precedent_level}</span>}
<span className="ms-auto flex items-center gap-2"> <span className="ms-auto flex items-center gap-2">
{h.quote_verified ? ( {h.quote_verified ? (
<Badge variant="outline" className="text-[0.65rem] bg-gold-wash text-gold-deep border-gold/40"> <Badge variant="outline" className="text-[0.65rem] bg-gold-wash text-gold-deep border-gold/40">
@@ -93,10 +109,10 @@ function HalachaCard({
</Badge> </Badge>
)} )}
<Badge variant="outline" className="text-[0.65rem] tabular-nums"> <Badge variant="outline" className="text-[0.65rem] tabular-nums">
confidence {h.confidence.toFixed(2)} ביטחון {h.confidence.toFixed(2)}
</Badge> </Badge>
<Badge variant="outline" className="text-[0.65rem]"> <Badge variant="outline" className="text-[0.65rem]">
{h.rule_type} {ruleTypeLabel(h.rule_type)}
</Badge> </Badge>
</span> </span>
</div> </div>
@@ -118,19 +134,14 @@ function HalachaCard({
)} )}
</div> </div>
<div> <div>
<div className="text-[0.7rem] text-ink-muted mb-1"> <div className="text-[0.7rem] text-ink-muted mb-1">ציטוט תומך</div>
ציטוט תומך
{h.page_reference && (
<span className="ms-2 text-[0.65rem]">({h.page_reference})</span>
)}
</div>
<blockquote className="text-ink-soft text-sm leading-relaxed border-r-2 border-gold pr-3" dir="rtl"> <blockquote className="text-ink-soft text-sm leading-relaxed border-r-2 border-gold pr-3" dir="rtl">
&ldquo;{h.supporting_quote}&rdquo; &ldquo;{h.supporting_quote}&rdquo;
</blockquote> </blockquote>
</div> </div>
</div> </div>
{/* Reasoning + tags (collapsible) */} {/* Reasoning */}
{(editing || h.reasoning_summary) && ( {(editing || h.reasoning_summary) && (
<div> <div>
<div className="text-[0.7rem] text-ink-muted mb-1">תמצית ההיגיון</div> <div className="text-[0.7rem] text-ink-muted mb-1">תמצית ההיגיון</div>
@@ -148,6 +159,7 @@ function HalachaCard({
</div> </div>
)} )}
{/* Tags */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{h.practice_areas?.map((p) => ( {h.practice_areas?.map((p) => (
<Badge key={p} variant="outline" className="text-[0.65rem] bg-navy-soft/30 text-navy"> <Badge key={p} variant="outline" className="text-[0.65rem] bg-navy-soft/30 text-navy">
@@ -161,6 +173,7 @@ function HalachaCard({
))} ))}
</div> </div>
{/* Actions */}
<div className="flex items-center gap-2 justify-end pt-1 border-t border-rule-soft"> <div className="flex items-center gap-2 justify-end pt-1 border-t border-rule-soft">
{editing ? ( {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() { export function HalachaReviewPanel() {
const { data, isPending, error } = useHalachotPending(500); const { data, isPending, error } = useHalachotPending(500);
const update = useUpdateHalacha(); const update = useUpdateHalacha();
const [focusIdx, setFocusIdx] = useState(0); const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [focusedId, setFocusedId] = useState<string | null>(null);
// Sort: low confidence first → forces the chair to scrutinize doubtful rows // Group by precedent. Within each group, items sorted by confidence ascending
const items = useMemo(() => { // so the chair sees doubtful entries first. Groups themselves sort by item
const list = [...(data?.items ?? [])]; // count descending — biggest piles surface first.
list.sort((a, b) => a.confidence - b.confidence); const groups = useMemo<Group[]>(() => {
return list; const map = new Map<string, Group>();
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]); }, [data]);
// Keep focus index in range as items change (e.g. after approve/reject const totalCount = data?.items.length ?? 0;
// shrinks the queue). Cascade-render is intentional and bounded.
useEffect(() => { // Items the keyboard handler can navigate. Only items inside expanded
if (focusIdx >= items.length && items.length > 0) { // groups are "visible" — collapsed groups hide their items from J/K.
// eslint-disable-next-line react-hooks/set-state-in-effect const visibleItems = useMemo<Halacha[]>(() => {
setFocusIdx(items.length - 1); 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 // Scroll the focused row into view
useEffect(() => { useEffect(() => {
if (!items.length) return; if (!focusedId) return;
const target = items[focusIdx]; const el = document.querySelector(`[data-halacha-id="${focusedId}"]`);
if (!target) return;
const el = document.querySelector(`[data-halacha-id="${target.id}"]`);
el?.scrollIntoView({ block: "nearest", behavior: "smooth" }); 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 ( const review = async (
h: Halacha, h: Halacha,
@@ -236,10 +302,7 @@ export function HalachaReviewPanel() {
try { try {
await update.mutateAsync({ await update.mutateAsync({
id: h.id, id: h.id,
patch: { patch: { review_status: status, ...extra },
review_status: status,
...extra,
},
}); });
toast.success(status === "approved" ? "אושר" : "נדחה"); toast.success(status === "approved" ? "אושר" : "נדחה");
} catch (e) { } 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(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName?.toLowerCase(); const tag = (e.target as HTMLElement)?.tagName?.toLowerCase();
if (tag === "input" || tag === "textarea") return; if (tag === "input" || tag === "textarea") return;
if (!focused) return;
if (e.key === "j") { if (e.key === "j") {
e.preventDefault(); e.preventDefault();
setFocusIdx((i) => Math.min(items.length - 1, i + 1)); moveFocus(1);
} else if (e.key === "k") { } else if (e.key === "k") {
e.preventDefault(); e.preventDefault();
setFocusIdx((i) => Math.max(0, i - 1)); moveFocus(-1);
} else if (e.key === "a" || e.key === "A") { } else if ((e.key === "a" || e.key === "A") && focused) {
e.preventDefault(); e.preventDefault();
review(focused, "approved"); review(focused, "approved");
} else if (e.key === "r" || e.key === "R") { } else if ((e.key === "r" || e.key === "R") && focused) {
e.preventDefault(); e.preventDefault();
if (window.confirm("לדחות הלכה זו?")) { if (window.confirm("לדחות הלכה זו?")) review(focused, "rejected");
review(focused, "rejected");
}
} }
}; };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [focused, items.length]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [focused, visibleItems]);
if (error) { if (error) {
return ( return (
@@ -284,12 +363,12 @@ export function HalachaReviewPanel() {
if (isPending) { if (isPending) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-40 w-full" />)} {[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div> </div>
); );
} }
if (!items.length) { if (!groups.length) {
return ( return (
<div className="text-center text-ink-muted py-16"> <div className="text-center text-ink-muted py-16">
<p className="text-lg">אין הלכות הממתינות לאישור.</p> <p className="text-lg">אין הלכות הממתינות לאישור.</p>
@@ -300,9 +379,10 @@ export function HalachaReviewPanel() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-3 text-sm text-ink-muted"> <div className="flex items-center gap-3 text-sm text-ink-muted flex-wrap">
<span> <span>
<span className="text-navy font-semibold">{items.length}</span> ממתינות לאישור <span className="text-navy font-semibold">{totalCount}</span> ממתינות
ב-<span className="text-navy font-semibold">{groups.length}</span> פסיקות
</span> </span>
<span className="me-auto text-[0.72rem]"> <span className="me-auto text-[0.72rem]">
ניווט: <kbd className="bg-rule-soft px-1.5 rounded">J</kbd>/<kbd className="bg-rule-soft px-1.5 rounded">K</kbd> ניווט: <kbd className="bg-rule-soft px-1.5 rounded">J</kbd>/<kbd className="bg-rule-soft px-1.5 rounded">K</kbd>
@@ -310,38 +390,83 @@ export function HalachaReviewPanel() {
{" "}· דחייה: <kbd className="bg-rule-soft px-1.5 rounded">R</kbd> {" "}· דחייה: <kbd className="bg-rule-soft px-1.5 rounded">R</kbd>
{" "}· עריכה: <kbd className="bg-rule-soft px-1.5 rounded">E</kbd> {" "}· עריכה: <kbd className="bg-rule-soft px-1.5 rounded">E</kbd>
</span> </span>
<Button {expandedIds.size > 0 && (
size="sm" variant="ghost" <Button
onClick={() => setFocusIdx((i) => Math.max(0, i - 1))} size="sm" variant="ghost"
disabled={focusIdx === 0}> onClick={() => setExpandedIds(new Set())}
<ChevronUp className="w-4 h-4" /> >
</Button> סגור הכל
<Button </Button>
size="sm" variant="ghost" )}
onClick={() => setFocusIdx((i) => Math.min(items.length - 1, i + 1))}
disabled={focusIdx >= items.length - 1}>
<ChevronDown className="w-4 h-4" />
</Button>
</div> </div>
<div className="space-y-4"> <div className="space-y-3">
{items.map((h, i) => ( {groups.map((g) => {
<HalachaCard const isOpen = expandedIds.has(g.caseLawId);
key={h.id} h={h} focused={i === focusIdx} return (
onApprove={() => review(h, "approved")} <div
onReject={() => { key={g.caseLawId}
if (window.confirm("לדחות הלכה זו?")) review(h, "rejected"); className="rounded-lg border border-rule bg-surface overflow-hidden"
}} >
onSave={async (patch) => { <button
try { type="button"
await update.mutateAsync({ id: h.id, patch }); onClick={() => toggleGroup(g.caseLawId)}
toast.success("נשמר"); className={`
} catch (e) { w-full flex items-center gap-3 px-4 py-3 text-right
toast.error(e instanceof Error ? e.message : "שגיאה"); hover:bg-gold-wash/30 transition-colors
} ${isOpen ? "bg-gold-wash/40 border-b border-rule" : ""}
}} `}
/> aria-expanded={isOpen}
))} >
{isOpen ? (
<ChevronDown className="w-4 h-4 text-ink-muted shrink-0" />
) : (
<ChevronLeft className="w-4 h-4 text-ink-muted shrink-0" />
)}
<div className="flex-1 min-w-0 text-right">
<div className="font-semibold text-navy truncate">
{cleanCitation(g.caseNumber)}
</div>
<div className="text-[0.72rem] text-ink-muted flex items-center gap-2 flex-wrap mt-0.5">
{g.court && <span>{g.court}</span>}
{g.decisionDate && <span>· {formatDate(g.decisionDate)}</span>}
{g.precedentLevel && <span>· {g.precedentLevel}</span>}
</div>
</div>
<Badge
variant="outline"
className="bg-gold-wash text-gold-deep border-gold/40 tabular-nums"
>
{g.items.length} ממתינות
</Badge>
</button>
{isOpen && (
<div className="p-4 space-y-3 bg-rule-soft/20">
{g.items.map((h) => (
<HalachaCard
key={h.id}
h={h}
focused={h.id === focusedId}
onApprove={() => 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 : "שגיאה");
}
}}
/>
))}
</div>
)}
</div>
);
})}
</div> </div>
</div> </div>
); );

View File

@@ -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 }) { function StatusPill({ p }: { p: Precedent }) {
if (p.extraction_status === "failed") { if (p.extraction_status === "failed") {
return <Badge variant="outline" className="bg-danger-bg text-danger border-danger/40">נכשל</Badge>; return <Badge variant="outline" className="bg-danger-bg text-danger border-danger/40">נכשל</Badge>;
@@ -75,14 +84,18 @@ function PrecedentRow({
return ( return (
<TableRow className="border-rule hover:bg-gold-wash/30"> <TableRow className="border-rule hover:bg-gold-wash/30">
<TableCell className="font-semibold text-navy" dir="ltr"> <TableCell className="font-semibold text-navy text-right" dir="rtl">
{p.case_number} <span dir="auto">{cleanCitation(p.case_number)}</span>
</TableCell> </TableCell>
<TableCell className="text-ink"> <TableCell className="text-ink">
<div className="font-medium">{p.case_name || "—"}</div> <div className="font-medium">{cleanCitation(p.case_name)}</div>
<div className="text-[0.72rem] text-ink-muted">{p.court || "—"}</div> {p.court ? (
<div className="text-[0.72rem] text-ink-muted">{p.court}</div>
) : null}
</TableCell>
<TableCell className="text-ink-muted">
{p.date ? formatDate(p.date) : <span className="text-ink-light"></span>}
</TableCell> </TableCell>
<TableCell className="text-ink-muted">{formatDate(p.date)}</TableCell>
<TableCell> <TableCell>
{p.practice_area ? ( {p.practice_area ? (
<Badge variant="outline" className="bg-navy-soft/40 text-navy border-navy/30"> <Badge variant="outline" className="bg-navy-soft/40 text-navy border-navy/30">
@@ -93,7 +106,11 @@ function PrecedentRow({
)} )}
</TableCell> </TableCell>
<TableCell className="text-ink-muted text-[0.78rem]"> <TableCell className="text-ink-muted text-[0.78rem]">
{p.precedent_level || "—"} {p.precedent_level ? (
p.precedent_level
) : (
<span className="text-ink-light"></span>
)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<StatusPill p={p} /> <StatusPill p={p} />