From 4b5cf1971ed1feea24fa4d65dee2d6f869f16b61 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Wed, 19 Aug 2026 23:07:28 -0400 Subject: [PATCH] Frontend redesign chunk 7: incident detail rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite app/incidents/[id]/page.tsx to UI_REDESIGN.md §5.2. Header is now type glyph + SeverityMark + active/resolved chip + a 27px title, with elapsed time, path length (haversine sum over geocoded calls) and call count as a single subline. Summary is promoted out of the old tab into a first-class prose block (16.5px/1.58) — it's the artifact the product sells, so it gets the best position instead of competing with Units/ Details behind a click. Units/Details tabs are gone; On scene / Cleared render directly from units_active/units_cleared (chunk 3), Vehicles below. New components/CallSpineEntry.tsx replaces CallRow for this page (CallRow stays for the Archive table until chunk 12): time-ordered entries with a numbered stop marker that matches the map's path stops via the same sort-by-started_at-over-geocoded-calls index MapView's IncidentPathLayer uses — the "shared index" from §2.4. Includes an inline play/scrub audio player (lazy-fetches the signed URL on first play, same pattern CallRow already used), transcript in sans prose instead of a font-mono
, unit/
cleared-unit chips, and a paginating "N earlier calls" control. Thin/
status-only calls collapse to one line.

The incident map keeps the location_coords guard and now passes `calls`
through to MapView so its path polyline (chunk 5) renders here too.

Per UI_REDESIGN.md chunk 7.
---
 drb-frontend/app/incidents/[id]/page.tsx   | 371 ++++++++++-----------
 drb-frontend/components/CallSpineEntry.tsx | 203 +++++++++++
 2 files changed, 378 insertions(+), 196 deletions(-)
 create mode 100644 drb-frontend/components/CallSpineEntry.tsx

diff --git a/drb-frontend/app/incidents/[id]/page.tsx b/drb-frontend/app/incidents/[id]/page.tsx
index 011541a..8ec2a5c 100644
--- a/drb-frontend/app/incidents/[id]/page.tsx
+++ b/drb-frontend/app/incidents/[id]/page.tsx
@@ -1,276 +1,255 @@
 "use client";
 
 import dynamic from "next/dynamic";
+import { useMemo, useState } from "react";
 import { useParams, useRouter } from "next/navigation";
-import { useState } from "react";
 import { useIncident } from "@/lib/useIncidents";
 import { useCallsByIncident } from "@/lib/useCalls";
-import { useSystems } from "@/lib/useSystems";
 import { useAuth } from "@/components/AuthProvider";
-import { CallRow } from "@/components/CallRow";
+import { CallSpineEntry } from "@/components/CallSpineEntry";
 import { c2api } from "@/lib/c2api";
-import type { IncidentRecord } from "@/lib/types";
-import { TypeBadge } from "@/components/IncidentBadges";
-import { severityBadge } from "@/lib/severity";
+import { TypeGlyph } from "@/components/marks/TypeGlyph";
+import { SeverityMark } from "@/components/marks/SeverityMark";
+import { isKnownSeverity } from "@/lib/severity";
+import { Button } from "@/components/ui/Button";
+import type { CallRecord } from "@/lib/types";
 
 const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
 
-function StatusBadge({ status }: { status: IncidentRecord["status"] }) {
-  return (
-    
-      {status}
-    
-  );
+const EARLIER_PAGE_SIZE = 8;
+
+function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
+  const R = 6371;
+  const dLat = ((b.lat - a.lat) * Math.PI) / 180;
+  const dLng = ((b.lng - a.lng) * Math.PI) / 180;
+  const s =
+    Math.sin(dLat / 2) ** 2 +
+    Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) * Math.sin(dLng / 2) ** 2;
+  return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
 }
 
-type Tab = "summary" | "units" | "details";
+function elapsedLabel(startedAt: string, active: boolean, updatedAt: string): string {
+  const start = new Date(startedAt).getTime();
+  const end = active ? Date.now() : new Date(updatedAt).getTime();
+  const mins = Math.max(0, Math.round((end - start) / 60000));
+  if (mins < 60) return `${mins}m`;
+  const hrs = Math.floor(mins / 60);
+  return `${hrs}h ${mins % 60}m`;
+}
 
 export default function IncidentDetailPage() {
   const params = useParams();
-  const id     = params.id as string;
+  const id = params.id as string;
   const router = useRouter();
 
-  const { incident, loading }            = useIncident(id);
+  const { incident, loading } = useIncident(id);
   const { calls, loading: callsLoading } = useCallsByIncident(id);
-  const { systems }                      = useSystems();
-  const { isAdmin }                      = useAuth();
+  const { isAdmin } = useAuth();
 
-  const [tab, setTab]                 = useState("summary");
   const [summarizing, setSummarizing] = useState(false);
-  const [resolving, setResolving]     = useState(false);
+  const [resolving, setResolving] = useState(false);
+  const [earlierShown, setEarlierShown] = useState(EARLIER_PAGE_SIZE);
 
-  const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
+  // Same ordering/filtering MapView's IncidentPathLayer uses, so the stop
+  // number shown on a spine entry matches the number on its map marker.
+  const geocodedCalls = useMemo(
+    () =>
+      calls
+        .filter((c): c is CallRecord & { location_coords: { lat: number; lng: number } } => !!c.location_coords)
+        .slice()
+        .sort((a, b) => a.started_at.localeCompare(b.started_at)),
+    [calls]
+  );
+  const stopNumberByCallId = useMemo(() => {
+    const m = new Map();
+    geocodedCalls.forEach((c, i) => m.set(c.call_id, i + 1));
+    return m;
+  }, [geocodedCalls]);
+
+  const pathLengthKm = useMemo(() => {
+    let total = 0;
+    for (let i = 1; i < geocodedCalls.length; i++) {
+      total += haversineKm(geocodedCalls[i - 1].location_coords!, geocodedCalls[i].location_coords!);
+    }
+    return total;
+  }, [geocodedCalls]);
+
+  const newestFirst = useMemo(
+    () => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)),
+    [calls]
+  );
 
   async function handleResolve() {
     setResolving(true);
     try { await c2api.updateIncident(id, { status: "resolved" }); }
     catch (e) { console.error(e); }
-    finally   { setResolving(false); }
+    finally { setResolving(false); }
   }
 
   async function handleSummarize() {
     setSummarizing(true);
     try { await c2api.summarizeIncident(id); }
     catch (e) { console.error(e); }
-    finally   { setSummarizing(false); }
+    finally { setSummarizing(false); }
   }
 
-  if (loading)   return 

Loading…

; - if (!incident) return

Incident not found.

; + if (loading) return

Loading…

; + if (!incident) return

Incident not found.

; const displayTags = incident.tags.filter((t) => t !== "auto-generated"); + const unitsActive = incident.units_active ?? incident.units ?? []; + const unitsCleared = incident.units_cleared ?? []; + const active = incident.status === "active"; + + const visible = newestFirst.slice(0, earlierShown); + const remaining = newestFirst.length - visible.length; return (
- {/* Back */} - {/* Header */} + {/* Header — glyph, severity chip, status, title at 27px, elapsed/path/count */}
-
+
- - - {severityBadge(incident.severity)} + + {isKnownSeverity(incident.severity) && } + + {active ? "Active" : "Resolved"} +
-

+

{incident.title ?? "Incident"}

+

+ {elapsedLabel(incident.started_at, active, incident.updated_at)} elapsed + {pathLengthKm > 0 && <> · {pathLengthKm.toFixed(1)} km path} + {" · "}{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""} +

{isAdmin && (
- - {incident.status === "active" && ( - + + {active && ( + )}
)}
- {/* Tags */} {displayTags.length > 0 && (
{displayTags.map((t) => ( - - {t} - + {t} ))}
)} - {/* Map */} - {incident.location_coords && ( -
- -
- )} + {/* Two columns: 828 / 612 per UI_REDESIGN.md §5.2 */} +
+ {/* Left */} +
+ {incident.location_coords && ( +
+ +
+ )} - {/* Two-panel body */} -
- - {/* Left: tabs — Summary / Units / Details */} -
- {/* Tab bar */} -
- {(["summary", "units", "details"] as Tab[]).map((t) => ( - - ))} -
- - {/* Tab content */} -
- {tab === "summary" && ( - incident.summary ? ( -

{incident.summary}

- ) : ( -

- No summary yet.{" "} - {isAdmin && ( - - )} -

- ) - )} - - {tab === "units" && ( -
-
-

Units

- {incident.units?.length > 0 ? ( -
- {incident.units.map((u) => ( - {u} - ))} -
- ) : ( -

None extracted.

- )} -
-
-

Vehicles

- {incident.vehicles?.length > 0 ? ( -
- {incident.vehicles.map((v) => ( - {v} - ))} -
- ) : ( -

None extracted.

- )} -
-
- )} - - {tab === "details" && ( -
- {incident.location && ( -
-

Location

-

{incident.location}

-
+ {/* Summary — first, in prose. Not a tab. */} +
+ {incident.summary ? ( +

{incident.summary}

+ ) : ( +

+ No summary yet.{" "} + {isAdmin && ( + )} -

-

Started

-

{new Date(incident.started_at).toLocaleString()}

-
-
-

Last activity

-

{new Date(incident.updated_at).toLocaleString()}

-
- {incident.talkgroup_ids?.length > 0 && ( -
-

Talkgroups

-

{incident.talkgroup_ids.join(", ")}

-
- )} - {incident.severity && ( -
-

Severity

-

{incident.severity}

-
- )} -
-

Total calls

-

{incident.call_ids.length}

-
-
+

)}
-
- {/* Right: calls */} -
-

- Calls ({calls.length}) -

- {callsLoading ? ( -

Loading…

- ) : calls.length === 0 ? ( -

No calls linked yet.

- ) : ( -
- - - - - - - - - - - - - - {calls.map((c) => ( - + {/* On scene / Cleared */} +
+
+

On scene

+ {unitsActive.length > 0 ? ( +
+ {unitsActive.map((u) => ( + {u} ))} -
-
TimeTalkgroupSystemNodeDurationAudio
+
+ ) : ( +

None extracted.

+ )} +
+
+

Cleared

+ {unitsCleared.length > 0 ? ( +
+ {unitsCleared.map((u) => ( + {u} + ))} +
+ ) : ( +

None yet.

+ )} +
+
+ + {incident.vehicles?.length > 0 && ( +
+

Vehicles

+
+ {incident.vehicles.map((v) => ( + {v} + ))} +
)}
+ {/* Right — the call spine */} +
+

+ Calls ({calls.length}) +

+ {callsLoading ? ( +

Loading…

+ ) : calls.length === 0 ? ( +

No calls linked yet.

+ ) : ( +
+ {visible.map((c) => ( + + ))} + {remaining > 0 && ( + + )} +
+ )} +
); diff --git a/drb-frontend/components/CallSpineEntry.tsx b/drb-frontend/components/CallSpineEntry.tsx new file mode 100644 index 0000000..27ad0c6 --- /dev/null +++ b/drb-frontend/components/CallSpineEntry.tsx @@ -0,0 +1,203 @@ +"use client"; + +/** + * One entry in an incident's call spine — UI_REDESIGN.md §5.2. Replaces + * CallRow for this context (CallRow stays in use for the Archive table + * until chunk 12). `stopNumber` is the same index the map's path stops use + * (see IncidentPathLayer in MapView.tsx) — that shared index is the one + * memorable thing per §2.4: "where was he when he said that" is answered + * by looking, not cross-referencing timestamps. + */ +import { useEffect, useRef, useState } from "react"; +import type { CallRecord } from "@/lib/types"; +import { c2api } from "@/lib/c2api"; +import { isKnownSeverity, SEVERITY_COLORS } from "@/lib/severity"; + +function fmtTime(iso: string): string { + return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} +function fmtClock(s: number): string { + if (!Number.isFinite(s)) return "0:00"; + const m = Math.floor(s / 60); + const r = Math.floor(s % 60); + return `${m}:${r.toString().padStart(2, "0")}`; +} + +function InlinePlayer({ callId, hasAudio }: { callId: string; hasAudio: boolean }) { + const [url, setUrl] = useState(null); + const [error, setError] = useState(false); + const [loading, setLoading] = useState(false); + const [playing, setPlaying] = useState(false); + const [current, setCurrent] = useState(0); + const [duration, setDuration] = useState(0); + const audioRef = useRef(null); + + if (!hasAudio) return null; + + async function ensureUrl() { + if (url || loading) return; + setLoading(true); + try { + const full = await c2api.getCall(callId); + setUrl(full.audio_url ?? null); + if (!full.audio_url) setError(true); + } catch { + setError(true); + } finally { + setLoading(false); + } + } + + async function toggle() { + if (!url) { + await ensureUrl(); + return; + } + const el = audioRef.current; + if (!el) return; + if (playing) el.pause(); + else el.play(); + } + + // Once the URL lands, autoplay (the click that fetched it was the play intent). + useEffect(() => { + if (url && audioRef.current) audioRef.current.play().catch(() => {}); + }, [url]); + + if (error) { + return Audio unavailable; + } + + return ( +
e.stopPropagation()}> + + { + const t = Number(e.target.value); + if (audioRef.current) audioRef.current.currentTime = t; + setCurrent(t); + }} + className="flex-1 h-1 accent-accent" + disabled={!url} + /> + + {fmtClock(current)} + + {url && ( +
+ ); +} + +function StopMarker({ stopNumber, color }: { stopNumber?: number; color: string }) { + if (!stopNumber) { + return ; + } + return ( + + {stopNumber} + + ); +} + +export function CallSpineEntry({ + call, + stopNumber, + isAdmin, +}: { + call: CallRecord; + stopNumber?: number; + isAdmin?: boolean; +}) { + const [expanded, setExpanded] = useState(false); + const hasAudio = !!(call.audio_gcs_uri || call.audio_url); + const transcript = call.transcript_corrected || call.transcript; + const color = isKnownSeverity(call.severity) ? SEVERITY_COLORS[call.severity] : "var(--ink-muted)"; + const substantive = !!transcript || (call.units && call.units.length > 0) || !!call.location_coords; + + const units = call.units ?? []; + const clearedInCall = call.cleared_units ?? []; + + if (!substantive) { + // Thin/routine calls collapse to a single line. + return ( +
+ + {fmtTime(call.started_at)} + TG {call.talkgroup_name ?? call.talkgroup_id ?? "—"} · status only +
+ ); + } + + return ( +
+
+ + +
+
+
+ {fmtTime(call.started_at)} + · + TG {call.talkgroup_name ?? call.talkgroup_id ?? "—"} + {call.node_id} +
+ + {hasAudio && ( +
+ +
+ )} + + {transcript && ( +

setExpanded((v) => !v)} + > + {transcript} +

+ )} + +
+ {call.location && ( + {call.location} + )} + {units.map((u) => ( + {u} + ))} + {clearedInCall.map((u) => ( + {u} + ))} +
+ + {isAdmin && call.corr_path && ( +

corr: {call.corr_path}

+ )} +
+
+ ); +}