Files
legal-ai/web-ui/src/components/training/learning-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

198 lines
8.9 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 { Loader2, ChevronLeft, Check } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
useReconciliationLedger, useStyleDistance, usePairDetail, usePromoteLearning,
type DraftFinalPair,
} from "@/lib/api/learning";
import { formatDateShort as fmtDate } from "@/lib/format-date";
/**
* /training "למידה" tab (T6/T13) — the reconciliation ledger (INV-LRN4): every
* decision and its draft↔final comparison status, plus per-case style-distance
* (T7). The list is the chair's "have we closed each decision against Dafna's
* final?" surface.
*/
const STATUS_LABEL: Record<string, string> = {
final_received: "התקבל סופי — ממתין לניתוח",
analyzed: "נותח (הצעות ממתינות)",
lessons_folded: "לקחים הוטמעו",
};
const STATUS_CLASS: Record<string, string> = {
final_received: "bg-gold-wash text-gold-deep border-gold/40",
analyzed: "bg-navy-soft/20 text-navy",
lessons_folded: "bg-emerald-50 text-emerald-800 border-emerald-300/60",
};
function StyleDistanceDetail({ caseNumber }: { caseNumber: string }) {
const { data, isPending, error } = useStyleDistance(caseNumber);
if (isPending) return <Loader2 className="w-4 h-4 animate-spin text-ink-muted" />;
if (error || data?.error) return <p className="text-danger text-[0.75rem]">{data?.error || "שגיאה"}</p>;
if (!data) return null;
const s = data.summary;
return (
<div className="rounded-md bg-rule-soft/30 border border-rule p-3 space-y-2 text-[0.8rem]">
<div className="flex gap-4 flex-wrap">
<span>שינוי draftfinal: <b className="tabular-nums">{s.change_percent ?? "—"}%</b></span>
<span>סטיית יחסי-זהב מקס׳: <b className="tabular-nums">{s.ratio_max_deviation_pp ?? "—"} נק׳</b></span>
<span>אנטי-דפוסים: <b className="tabular-nums">{s.anti_pattern_total}</b></span>
</div>
{Object.keys(data.golden_ratio_adherence.sections).length > 0 && (
<div className="flex gap-3 flex-wrap text-[0.72rem] text-ink-muted">
{Object.entries(data.golden_ratio_adherence.sections).map(([sec, v]) => (
<span key={sec} className={v.deviation_pp > 0 ? "text-danger" : ""}>
{sec}: {v.actual_pct}% (יעד {v.target[0]}-{v.target[1]})
</span>
))}
</div>
)}
{data.anti_pattern_hits.total > 0 && (
<div className="text-[0.72rem] text-danger">
{Object.entries(data.anti_pattern_hits.by_pattern).map(([n, v]) => (
<span key={n} className="me-3">{n}: {v.count}</span>
))}
</div>
)}
</div>
);
}
function SelectableItem({ text, selected, onToggle }: {
text: string; selected: boolean; onToggle: () => void;
}) {
return (
<button type="button" onClick={onToggle}
className={`w-full flex items-start gap-2 rounded-md border p-2 text-right text-[0.78rem] transition-colors ${
selected ? "border-navy bg-navy-soft/15" : "border-rule bg-surface hover:bg-rule-soft/30"
}`}>
<span className={`mt-0.5 w-4 h-4 shrink-0 rounded border flex items-center justify-center ${
selected ? "bg-navy border-navy text-parchment" : "border-rule"
}`}>
{selected && <Check className="w-3 h-3" />}
</span>
<span className="flex-1">{text}</span>
</button>
);
}
/** T14 — chair approval gate (INV-G10/LRN1): review the curator's style_method
* proposal and fold approved lessons/phrases into writer-consumed channels. */
function ProposalReview({ pairId }: { pairId: string }) {
const { data, isPending, error } = usePairDetail(pairId);
const promote = usePromoteLearning(pairId);
const [lessons, setLessons] = useState<Set<string>>(new Set());
const [phrases, setPhrases] = useState<Set<string>>(new Set());
if (isPending) return <Loader2 className="w-4 h-4 animate-spin text-ink-muted" />;
if (error || !data) return <p className="text-danger text-[0.75rem]">שגיאה בטעינת ההצעה.</p>;
const lessonTexts = data.changes.map((c) => c.lesson).filter((l): l is string => Boolean(l));
const toggle = (set: Set<string>, setFn: (s: Set<string>) => void, v: string) => {
const next = new Set(set);
next.has(v) ? next.delete(v) : next.add(v);
setFn(next);
};
const total = lessons.size + phrases.size;
return (
<div className="rounded-md bg-gold-wash/30 border border-gold/40 p-3 space-y-3 text-[0.8rem]">
<p className="text-ink-muted text-[0.75rem]">
הצעת הדיסטילציה (סגנון/שיטה בלבד INV-LRN5). בחר/י מה לאמץ; ייכתב לערוצים שהכותב צורך (שער-יו&quot;ר).
</p>
{data.overall_assessment && (
<p className="italic text-ink-muted">{data.overall_assessment}</p>
)}
{lessonTexts.length > 0 && (
<div className="space-y-1.5">
<div className="font-semibold text-navy text-[0.75rem]">לקחי-סגנון כללי-דיון</div>
{lessonTexts.map((l, i) => (
<SelectableItem key={`l${i}`} text={l} selected={lessons.has(l)}
onToggle={() => toggle(lessons, setLessons, l)} />
))}
</div>
)}
{data.new_expressions.length > 0 && (
<div className="space-y-1.5">
<div className="font-semibold text-navy text-[0.75rem]">ביטויי-מעבר חדשים</div>
{data.new_expressions.map((p, i) => (
<SelectableItem key={`p${i}`} text={p} selected={phrases.has(p)}
onToggle={() => toggle(phrases, setPhrases, p)} />
))}
</div>
)}
{lessonTexts.length === 0 && data.new_expressions.length === 0 && (
<p className="text-ink-muted">אין הצעות style_method.</p>
)}
<div className="flex justify-end">
<Button size="sm" disabled={total === 0 || promote.isPending}
className="bg-navy text-parchment hover:bg-navy-soft"
onClick={() => promote.mutate(
{ lessons: [...lessons], phrases: [...phrases] },
{
onSuccess: (r) => toast.success(`הוטמעו ${r.folded_lessons} לקחים + ${r.folded_phrases} ביטויים`),
onError: (e) => toast.error(e instanceof Error ? e.message : "שגיאה"),
},
)}>
{promote.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin me-1" />
: <Check className="w-3.5 h-3.5 me-1" />}
אשר והטמע {total > 0 ? `(${total})` : ""}
</Button>
</div>
</div>
);
}
function Row({ pair }: { pair: DraftFinalPair }) {
const [open, setOpen] = useState(false);
return (
<div className="rounded-lg border border-rule bg-surface overflow-hidden">
<button type="button" onClick={() => setOpen((o) => !o)}
className="w-full flex items-center gap-3 px-4 py-3 text-right hover:bg-gold-wash/30 transition-colors">
<ChevronLeft className={`w-4 h-4 text-ink-muted shrink-0 transition-transform ${open ? "-rotate-90" : ""}`} />
<div className="flex-1 min-w-0 text-right">
<div className="font-semibold text-navy truncate">{pair.case_number || "—"}{pair.title ? `${pair.title}` : ""}</div>
<div className="text-[0.72rem] text-ink-muted">עודכן {fmtDate(pair.updated_at)}</div>
</div>
{pair.change_percent != null && (
<Badge variant="outline" className="tabular-nums text-[0.65rem]">Δ {pair.change_percent}%</Badge>
)}
<Badge variant="outline" className={`text-[0.65rem] ${STATUS_CLASS[pair.status] ?? ""}`}>
{STATUS_LABEL[pair.status] ?? pair.status}
</Badge>
</button>
{open && (
<div className="px-4 pb-3 space-y-3">
{pair.status === "analyzed" && <ProposalReview pairId={pair.id} />}
{pair.case_number && <StyleDistanceDetail caseNumber={pair.case_number} />}
</div>
)}
</div>
);
}
export function LearningPanel() {
const { data, isPending, error } = useReconciliationLedger();
if (error) return <p className="text-danger text-sm">שגיאה בטעינת הפנקס.</p>;
if (isPending) return <Loader2 className="w-5 h-5 animate-spin text-ink-muted" />;
const items = data?.items ?? [];
return (
<div className="space-y-3">
<p className="text-[0.8rem] text-ink-muted">
פנקס-ההתאמה (INV-LRN4): כל החלטה נסגרת מול הסופי של דפנה; כל סופי מנותח מול הטיוטה.
לחיצה על תיק מדד מרחק-הסגנון שלו.
</p>
{items.length === 0 ? (
<p className="text-ink-muted text-sm py-8 text-center">אין עדיין השוואות. הן נוצרות כשמסמנים החלטה כסופית.</p>
) : (
items.map((p) => <Row key={p.id} pair={p} />)
)}
</div>
);
}