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}

+ )} +
+
+ ); +}