- {/* 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 && (
-
+ {active && (
+
+ {resolving ? "Resolving…" : "Mark resolved"}
+
)}
)}
- {/* 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) => (
- setTab(t)}
- className={`flex-1 px-4 py-2.5 text-xs font-mono capitalize transition-colors ${
- tab === t
- ? "text-white border-b-2 border-indigo-500 bg-gray-800/40"
- : "text-gray-500 hover:text-gray-300"
- }`}
- >
- {t}
-
- ))}
-
-
- {/* Tab content */}
-
- {tab === "summary" && (
- incident.summary ? (
-
{incident.summary}
- ) : (
-
- No summary yet.{" "}
- {isAdmin && (
-
- Generate now
-
- )}
-
- )
- )}
-
- {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 && (
+
+ Generate now
+
)}
-
-
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.
- ) : (
-
-
-
-
- | Time |
- Talkgroup |
- System |
- Node |
- Duration |
- Audio |
- |
-
-
-
- {calls.map((c) => (
-
+ {/* On scene / Cleared */}
+
+
+
On scene
+ {unitsActive.length > 0 ? (
+
+ {unitsActive.map((u) => (
+ {u}
))}
-
-
+
+ ) : (
+
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 && (
+ setEarlierShown((n) => n + EARLIER_PAGE_SIZE)}
+ className="text-xs text-accent hover:underline mt-2"
+ >
+ {remaining} earlier call{remaining !== 1 ? "s" : ""}
+
+ )}
+
+ )}
+
);
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()}>
+
+ {loading ? "…" : playing ? "❚❚" : "▶"}
+
+
{
+ 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}
+ )}
+
+
+ );
+}