ui(precedents): live status pill with shimmer + auto-queue + auto-refresh
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m44s

The chair pointed out three UX gaps after uploading a new precedent:

1. The status said "מחלץ הלכות" but nothing was actually running — the
   field only meant "halacha_extraction_status != completed", which
   includes the post-upload "pending" state where the local MCP worker
   hasn't been told to drain anything yet. Misleading.

2. The page didn't refresh on its own. The chair had to F5 to see new
   counts after extraction completed.

3. Clicking the trash icon mid-extraction would cascade-delete the row
   while the extractor was still using it (FK errors, partial writes).

Fixes:

- ingest_precedent now auto-queues both metadata and halacha extraction
  on upload by stamping the request timestamps. The chair (or me) drains
  the queue with one `precedent_process_pending` call from chat —
  no need to click any button before that.

- StatusPill is now five-state with proper labels:
    "נכשל" (extraction_status=failed) — red
    "מעבד טקסט" — shimmer (extraction_status=processing)
    "בתור" — neutral (chunks queued, not yet running)
    "מחלץ הלכות" — shimmer (halacha_extraction_status=processing)
    "ממתין לחילוץ" — neutral (queued for local MCP worker)
    "לא חולץ" — neutral (pending without queue stamp — shouldn't happen)
    "X/Y מאושרות" — gold (done, with halachot count)
  The shimmer is a CSS-only sliding-stripe animation defined in globals.

- usePrecedents has a conditional refetchInterval — polls every 5s while
  any row is mid-extraction or queued, then stops once everything settles
  to completed/failed. New helper isPrecedentActive() centralises the
  "is this row mid-something" check so the UI and the destructive-action
  guard agree.

- Trash button is disabled (opacity 30%, tooltip explains) while the row
  is active. Pencil/edit stays enabled — editing metadata fields during
  extraction is safe (last write wins, low-stakes race).

Schema: list_external_case_law now exposes the two *_requested_at
timestamps so the UI can distinguish "queued" from "never asked".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 12:47:31 +00:00
parent 4a9a6b7970
commit 1f17419ee9
5 changed files with 131 additions and 13 deletions

View File

@@ -1833,6 +1833,8 @@ async def list_external_case_law(
appeal_subtype, source_type, precedent_level, is_binding,
summary, headnote, subject_tags, source_kind,
extraction_status, halacha_extraction_status,
metadata_extraction_requested_at,
halacha_extraction_requested_at,
created_at,
(SELECT COUNT(*) FROM halachot h WHERE h.case_law_id = case_law.id) AS halachot_count,
(SELECT COUNT(*) FROM halachot h WHERE h.case_law_id = case_law.id
@@ -1843,7 +1845,15 @@ async def list_external_case_law(
LIMIT ${idx} OFFSET ${idx + 1}
"""
rows = await pool.fetch(sql, *params)
return [_row_to_case_law(r) for r in rows]
out = []
for r in rows:
d = _row_to_case_law(r)
# Render timestamps as ISO strings so the JSON layer stays simple
for k in ("metadata_extraction_requested_at", "halacha_extraction_requested_at"):
if d.get(k) is not None:
d[k] = d[k].isoformat()
out.append(d)
return out
async def delete_case_law(case_law_id: UUID) -> bool:

View File

@@ -190,19 +190,23 @@ async def ingest_precedent(
# Pipeline split: the container does the non-LLM half (extract +
# chunk + embed + store). LLM-driven extraction (metadata, halachot)
# runs separately via the MCP tools `precedent_extract_metadata` /
# `precedent_extract_halachot` from local Claude Code, where
# `claude` CLI is available. Mark statuses so the chair can see
# what's pending in the UI.
# runs separately via the MCP tool `precedent_process_pending` from
# local Claude Code, where `claude` CLI is available.
#
# We auto-queue both extractions so the chair doesn't need to click
# any button — the moment they (or me) run `precedent_process_pending`
# in chat, both kinds get processed.
await db.set_case_law_extraction_status(case_law_id, "completed")
await db.set_case_law_halacha_status(case_law_id, "pending")
await db.request_metadata_extraction(case_law_id)
await db.request_halacha_extraction(case_law_id)
await progress(
"completed",
100,
f"הוכנס לספרייה: {stored_chunks} chunks. "
f"חילוץ הלכות ומטא-דאטה — להפעיל מ-Claude Code "
f"(precedent_extract_halachot / precedent_extract_metadata).",
f"חילוץ הלכות ומטא-דאטה ממתינים בתור — "
f"להפעיל מ-Claude Code: precedent_process_pending.",
)
return {

View File

@@ -246,3 +246,24 @@
color: var(--color-navy);
}
}
/* ── Status pill shimmer ──────────────────────────────────────────
* Indeterminate "in progress" indicator used by precedent-library
* StatusPill while extraction is running. A diagonal stripe slides
* left-to-right across the badge background. */
@keyframes ezer-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.shimmer-active {
background-image: linear-gradient(
90deg,
transparent 0%,
rgba(168, 124, 58, 0.18) 50%,
transparent 100%
);
background-size: 200% 100%;
background-repeat: no-repeat;
animation: ezer-shimmer 1.6s linear infinite;
}

View File

@@ -16,6 +16,7 @@ import {
import {
usePrecedents,
useDeletePrecedent,
isPrecedentActive,
type Precedent,
type PracticeArea,
} from "@/lib/api/precedent-library";
@@ -41,16 +42,68 @@ function cleanCitation(s: string | null | undefined): string {
return s.replace(/[--]/g, "").trim();
}
/* Shimmering pill — used while extraction is actively running.
* Visually distinct from the static "queued" / "completed" pills. */
function ActivePill({ label }: { label: string }) {
return (
<Badge
variant="outline"
className="bg-gold-wash text-gold-deep border-gold/40 shimmer-active"
>
{label}
</Badge>
);
}
/**
* Five distinct states. The "queued" state is what the user actually
* sees most of the time (after upload, both extractions are auto-queued
* but the local MCP worker hasn't drained them yet); "מחלץ" / "מעבד"
* shimmers and only appears while the extractor is actively running.
*/
function StatusPill({ p }: { p: Precedent }) {
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>
);
}
if (p.extraction_status === "processing") {
return <ActivePill label="מעבד טקסט" />;
}
if (p.extraction_status !== "completed") {
return <Badge variant="outline" className="bg-rule-soft text-ink-muted">בעיבוד</Badge>;
return (
<Badge variant="outline" className="bg-rule-soft text-ink-muted">
בתור
</Badge>
);
}
if (p.halacha_extraction_status !== "completed") {
return <Badge variant="outline" className="bg-gold-wash text-gold-deep">מחלץ הלכות</Badge>;
if (p.halacha_extraction_status === "processing") {
return <ActivePill label="מחלץ הלכות" />;
}
if (p.halacha_extraction_status === "failed") {
return (
<Badge variant="outline" className="bg-danger-bg text-danger border-danger/40">
חילוץ נכשל
</Badge>
);
}
if (p.halacha_extraction_status === "pending") {
if (p.halacha_extraction_requested_at) {
return (
<Badge variant="outline" className="bg-rule-soft text-ink-muted">
ממתין לחילוץ
</Badge>
);
}
return (
<Badge variant="outline" className="bg-rule-soft text-ink-muted">
לא חולץ
</Badge>
);
}
// halacha_extraction_status === "completed"
if (p.halachot_count === 0) {
return <Badge variant="outline">ללא הלכות</Badge>;
}
@@ -71,8 +124,15 @@ function PrecedentRow({
onEdit: (id: string) => void;
}) {
const del = useDeletePrecedent();
const active = isPrecedentActive(p);
const onDelete = async () => {
if (active) {
toast.error(
"מתבצע עיבוד — לא ניתן למחוק עכשיו. המתיני לסיום או רעני את הדף.",
);
return;
}
if (!window.confirm(`למחוק את ${p.case_number}? cascade ימחק את ה-chunks וההלכות.`)) return;
try {
await del.mutateAsync(p.id);
@@ -136,9 +196,10 @@ function PrecedentRow({
</Button>
<Button
variant="ghost" size="sm" onClick={onDelete}
disabled={del.isPending}
disabled={del.isPending || active}
aria-label={`מחק את ${p.case_number}`}
className="text-danger hover:text-danger hover:bg-danger-bg"
title={active ? "מתבצע עיבוד — לא ניתן למחוק" : "מחק"}
className="text-danger hover:text-danger hover:bg-danger-bg disabled:opacity-30"
>
<Trash2 className="w-4 h-4" />
</Button>

View File

@@ -48,6 +48,8 @@ export type Precedent = {
source_kind: string;
extraction_status: string;
halacha_extraction_status: string;
metadata_extraction_requested_at: string | null;
halacha_extraction_requested_at: string | null;
created_at: string;
halachot_count: number;
approved_count: number;
@@ -172,9 +174,29 @@ export function usePrecedents(filters: ListFilters = {}) {
);
},
staleTime: 30_000,
/* Poll while any row is mid-processing or queued for the local MCP
* worker. Once everything settles to completed/failed the polling
* stops on its own — no fixed background timer. */
refetchInterval: (query) => {
const data = query.state.data;
if (!data) return false;
const active = data.items.some((p) => isPrecedentActive(p));
return active ? 5000 : false;
},
});
}
/** A precedent is "active" while text/halacha extraction is in flight or
* queued for the local MCP worker. Used by the auto-refresh poller and
* by the row UI to disable destructive actions. */
export function isPrecedentActive(p: Precedent): boolean {
if (p.extraction_status === "processing") return true;
if (p.halacha_extraction_status === "processing") return true;
if (p.halacha_extraction_requested_at !== null) return true;
if (p.metadata_extraction_requested_at !== null) return true;
return false;
}
export function usePrecedent(id: string | null) {
return useQuery({
queryKey: libraryKeys.detail(id ?? ""),