From 457e6d7e0f0c2f85e3f298a0e7631f4a1841153e Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 23 Aug 2026 12:36:38 -0400 Subject: [PATCH] Make Archive a real page instead of a redirect /calls was a ten-line stub that redirected to /incidents, so there was nowhere in the app to look at a call. The nav's "Archive" link led to the incident list, and a call that never correlated was invisible entirely -- which is backwards when correlation quality is the thing under development, because the orphans are the evidence. Its stated blocker (Gitea #17/#18) closed weeks ago. The page browses the org's calls newest-first over the new /calls/search route, filtered by link state (all / orphans / linked), transcript presence, and system, with a transcript substring search and cursor paging. A row expands to the full transcript, a playback link minted on demand, and the correlation path that decided it. The counts line -- how many of the loaded calls are orphaned, how many have no transcript at all -- is the number worth watching during an AI window. Attribution is the point of it: attach an orphan to the incident it belongs to, or detach one the correlator got wrong. Both go through the routes fixed in the previous commit, so a manual attachment now actually shows up on the incident. Admin-only. It exposes every call in the org regardless of node ownership and carries controls that rewrite incident membership. Co-Authored-By: Claude Opus 5 --- drb-frontend/app/calls/page.tsx | 391 +++++++++++++++++++++++++++++++- drb-frontend/lib/c2api.ts | 33 ++- 2 files changed, 415 insertions(+), 9 deletions(-) diff --git a/drb-frontend/app/calls/page.tsx b/drb-frontend/app/calls/page.tsx index acac09a..96de10a 100644 --- a/drb-frontend/app/calls/page.tsx +++ b/drb-frontend/app/calls/page.tsx @@ -1,10 +1,385 @@ -import { redirect } from "next/navigation"; +"use client"; -// Destination for /calls is Archive (/search) per UI_REDESIGN.md §3/§5.4, but -// /search needs paged server-side search (order_by/limit/cursor on -// internal/firestore.py, a GET /calls/search route) that doesn't exist yet — -// tracked as UI_REDESIGN.md chunk 12, blocked on Gitea #17/#18. Until then -// this redirects to Incidents, same as the chunk 4 spec's interim. -export default function CallsPageRedirect() { - redirect("/incidents"); +// Archive — the call-level view. Until now /calls was a ten-line stub that +// redirected to /incidents, so there was no way to look at a call anywhere in +// the app: the nav's "Archive" link led to the incident list, and a call that +// never correlated was invisible. That is the wrong way round when correlation +// quality is the thing under development — the orphans are the evidence. +// +// Admin-only, because it exposes every call in the org regardless of node +// ownership and carries the manual attribution controls. + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/components/AuthProvider"; +import { useSystems } from "@/lib/useSystems"; +import { useIncidents } from "@/lib/useIncidents"; +import { c2api } from "@/lib/c2api"; +import type { CallRecord, IncidentRecord } from "@/lib/types"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { Card } from "@/components/ui/Card"; +import { Badge } from "@/components/ui/Badge"; +import { Button } from "@/components/ui/Button"; +import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState"; +import { SkeletonCard } from "@/components/ui/Skeleton"; + +type LinkFilter = "any" | "orphan" | "linked"; +type TranscriptFilter = "any" | "yes" | "no"; + +const LINK_FILTERS: { key: LinkFilter; label: string }[] = [ + { key: "any", label: "All" }, + { key: "orphan", label: "Orphans" }, + { key: "linked", label: "Linked" }, +]; + +const TRANSCRIPT_FILTERS: { key: TranscriptFilter; label: string }[] = [ + { key: "any", label: "Any" }, + { key: "yes", label: "Transcribed" }, + { key: "no", label: "No transcript" }, +]; + +const PAGE_SIZE = 50; + +function fmtWhen(iso?: string | null): string { + if (!iso) return "—"; + try { + const d = new Date(iso); + return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}`; + } catch { + return String(iso); + } +} + +function fmtDuration(call: CallRecord): string { + if (!call.ended_at) return "active"; + const ms = new Date(call.ended_at).getTime() - new Date(call.started_at).getTime(); + const s = Math.max(0, Math.round(ms / 1000)); + return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}`; +} + +function callIncidentIds(call: CallRecord): string[] { + if (call.incident_ids?.length) return call.incident_ids; + return call.incident_id ? [call.incident_id] : []; +} + +/** One archive row: metadata, transcript, audio, and the attribution control. */ +function ArchiveRow({ + call, + systemName, + incidents, + onChanged, +}: { + call: CallRecord; + systemName?: string; + incidents: IncidentRecord[]; + onChanged: () => void; +}) { + const [open, setOpen] = useState(false); + const [audioUrl, setAudioUrl] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [attachTo, setAttachTo] = useState(""); + + const linkedIds = callIncidentIds(call); + const text = call.transcript_corrected || call.transcript || ""; + + // The stored document holds only the private gs:// object location; a + // playable link is minted per read by the API, so fetch it on expand. + useEffect(() => { + if (!open || audioUrl) return; + let cancelled = false; + c2api + .getCall(call.call_id) + .then((full) => { if (!cancelled) setAudioUrl(full.audio_url ?? null); }) + .catch(() => { /* audio is optional — the row is still useful without it */ }); + return () => { cancelled = true; }; + }, [open, audioUrl, call.call_id]); + + async function attach() { + if (!attachTo) return; + setBusy(true); setError(null); + try { + await c2api.linkCallToIncident(attachTo, call.call_id); + setAttachTo(""); + onChanged(); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + } + + async function detach(incidentId: string) { + setBusy(true); setError(null); + try { + await c2api.unlinkCallFromIncident(incidentId, call.call_id); + onChanged(); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + } + + return ( + + + + {open && ( +
+ {text ? ( +

{text}

+ ) : ( +

+ No transcript. Either STT was off when this call landed, or Whisper rejected it as + silence or degenerate output. +

+ )} + + {audioUrl && ( + /* eslint-disable-next-line jsx-a11y/media-has-caption */ +
+ )} +
+ ); +} + +export default function ArchivePage() { + const { isAdmin, loading: authLoading } = useAuth(); + const router = useRouter(); + const { systems } = useSystems(); + const { incidents } = useIncidents(200); + + const [calls, setCalls] = useState([]); + const [cursor, setCursor] = useState(null); + const [moreAvailable, setMoreAvailable] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [link, setLink] = useState("any"); + const [transcript, setTranscript] = useState("any"); + const [systemId, setSystemId] = useState(""); + const [q, setQ] = useState(""); + const [submittedQ, setSubmittedQ] = useState(""); + + useEffect(() => { + if (!authLoading && !isAdmin) router.replace("/"); + }, [authLoading, isAdmin, router]); + + const load = useCallback( + async (nextCursor: string | null, append: boolean) => { + setLoading(true); + setError(null); + try { + const res = await c2api.searchCalls({ + limit: PAGE_SIZE, + cursor: nextCursor, + link, + transcript, + system_id: systemId || undefined, + q: submittedQ || undefined, + }); + setCalls((prev) => (append ? [...prev, ...res.calls] : res.calls)); + setCursor(res.next_cursor); + setMoreAvailable(Boolean(res.next_cursor)); + } catch (e) { + setError(String(e)); + } finally { + setLoading(false); + } + }, + [link, transcript, systemId, submittedQ], + ); + + // Reload from the top whenever a filter changes. + useEffect(() => { + if (authLoading || !isAdmin) return; + load(null, false); + }, [authLoading, isAdmin, load]); + + const systemName = useMemo(() => { + const m = new Map(systems.map((s) => [s.system_id, s.name])); + return (id?: string | null) => (id ? m.get(id) : undefined); + }, [systems]); + + // Every hook runs before this guard — see the note in app/nodes/page.tsx. + if (authLoading || !isAdmin) return null; + + const orphanCount = calls.filter((c) => callIncidentIds(c).length === 0).length; + const noTranscript = calls.filter((c) => !(c.transcript_corrected || c.transcript)).length; + + return ( +
+ + +
+
+ {LINK_FILTERS.map(({ key, label }) => ( + + ))} +
+ +
+ {TRANSCRIPT_FILTERS.map(({ key, label }) => ( + + ))} +
+ + + +
{ e.preventDefault(); setSubmittedQ(q.trim()); }} + className="flex items-center gap-2 ml-auto" + > + setQ(e.target.value)} + placeholder="Search transcripts…" + className="bg-surface border border-line rounded-lg text-sm text-ink px-3 py-2 w-56" + /> + +
+
+ + {calls.length > 0 && ( +

+ {calls.length} calls · {orphanCount} orphaned · {noTranscript} without a transcript +

+ )} + + {error && } + + {loading && calls.length === 0 ? ( +
+ +
+ ) : calls.length === 0 && !error ? ( + + ) : ( +
+ {calls.map((call) => ( + load(null, false)} + /> + ))} +
+ )} + + {moreAvailable && ( +
+ +
+ )} +
+ ); } diff --git a/drb-frontend/lib/c2api.ts b/drb-frontend/lib/c2api.ts index fe76407..5259c33 100644 --- a/drb-frontend/lib/c2api.ts +++ b/drb-frontend/lib/c2api.ts @@ -67,6 +67,33 @@ export const c2api = { const qs = params ? "?" + new URLSearchParams(params).toString() : ""; return request(`/calls${qs}`); }, + /** + * Paged, filterable call archive — backs the /calls page. Distinct from + * getCalls(), which returns every call unordered and cannot page. + * `next_cursor` is null when the scan reached the end of the collection. + */ + searchCalls: (params: { + limit?: number; + cursor?: string | null; + system_id?: string; + node_id?: string; + talkgroup_id?: number; + link?: "any" | "orphan" | "linked"; + transcript?: "any" | "yes" | "no"; + q?: string; + }) => { + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== null && v !== "") qs.set(k, String(v)); + } + return request<{ + calls: import("@/lib/types").CallRecord[]; + next_cursor: string | null; + scanned: number; + matched: number; + window_exhausted: boolean; + }>(`/calls/search?${qs.toString()}`); + }, patchTranscript: (callId: string, transcript: string) => request(`/calls/${callId}/transcript`, { method: "PATCH", body: JSON.stringify({ transcript }) }), closeStallCalls: (olderThanMinutes: number, dryRun: boolean) => @@ -85,7 +112,11 @@ export const c2api = { deleteIncident: (id: string) => request(`/incidents/${id}`, { method: "DELETE" }), linkCallToIncident: (incidentId: string, callId: string) => - request(`/incidents/${incidentId}/calls/${callId}`, { method: "POST" }), + request<{ ok: boolean; incident_ids: string[] }>( + `/incidents/${incidentId}/calls/${callId}`, { method: "POST" }), + unlinkCallFromIncident: (incidentId: string, callId: string) => + request<{ ok: boolean; incident_emptied: boolean }>( + `/incidents/${incidentId}/calls/${callId}`, { method: "DELETE" }), summarizeIncident: (id: string) => request(`/incidents/${id}/summarize`, { method: "POST" }),