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 <pre>, 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.
204 lines
6.7 KiB
TypeScript
204 lines
6.7 KiB
TypeScript
"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<string | null>(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<HTMLAudioElement | null>(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 <span className="text-xs text-sev-major">Audio unavailable</span>;
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 w-full max-w-xs" onClick={(e) => e.stopPropagation()}>
|
|
<button
|
|
onClick={toggle}
|
|
disabled={loading}
|
|
className="w-6 h-6 shrink-0 rounded-full bg-accent text-white flex items-center justify-center text-[10px] disabled:opacity-50"
|
|
title={playing ? "Pause" : "Play"}
|
|
>
|
|
{loading ? "…" : playing ? "❚❚" : "▶"}
|
|
</button>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={duration || 0}
|
|
value={current}
|
|
onChange={(e) => {
|
|
const t = Number(e.target.value);
|
|
if (audioRef.current) audioRef.current.currentTime = t;
|
|
setCurrent(t);
|
|
}}
|
|
className="flex-1 h-1 accent-accent"
|
|
disabled={!url}
|
|
/>
|
|
<span className="text-[10px] font-mono text-ink-muted tabular-nums shrink-0">
|
|
{fmtClock(current)}
|
|
</span>
|
|
{url && (
|
|
<audio
|
|
ref={audioRef}
|
|
src={url}
|
|
onPlay={() => setPlaying(true)}
|
|
onPause={() => setPlaying(false)}
|
|
onEnded={() => setPlaying(false)}
|
|
onTimeUpdate={(e) => setCurrent(e.currentTarget.currentTime)}
|
|
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
|
|
className="hidden"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StopMarker({ stopNumber, color }: { stopNumber?: number; color: string }) {
|
|
if (!stopNumber) {
|
|
return <span className="w-6 h-6 shrink-0 rounded-full border border-line-strong" />;
|
|
}
|
|
return (
|
|
<span
|
|
className="w-6 h-6 shrink-0 rounded-full flex items-center justify-center text-[11px] font-semibold text-page"
|
|
style={{ background: color }}
|
|
>
|
|
{stopNumber}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="flex items-center gap-3 py-1.5 text-xs text-ink-muted">
|
|
<span className="w-6 shrink-0" />
|
|
<span className="font-mono tabular-nums shrink-0">{fmtTime(call.started_at)}</span>
|
|
<span className="truncate">TG {call.talkgroup_name ?? call.talkgroup_id ?? "—"} · status only</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex gap-3 py-3">
|
|
<div className="flex flex-col items-center shrink-0">
|
|
<StopMarker stopNumber={stopNumber} color={color} />
|
|
<span className="flex-1 w-px bg-line mt-1" />
|
|
</div>
|
|
<div className="flex-1 min-w-0 pb-1">
|
|
<div className="flex items-center gap-2 flex-wrap text-xs text-ink-muted">
|
|
<span className="font-mono tabular-nums">{fmtTime(call.started_at)}</span>
|
|
<span>·</span>
|
|
<span className="font-mono">TG {call.talkgroup_name ?? call.talkgroup_id ?? "—"}</span>
|
|
<span className="font-mono">{call.node_id}</span>
|
|
</div>
|
|
|
|
{hasAudio && (
|
|
<div className="mt-1.5">
|
|
<InlinePlayer callId={call.call_id} hasAudio={hasAudio} />
|
|
</div>
|
|
)}
|
|
|
|
{transcript && (
|
|
<p
|
|
className={`text-sm text-ink leading-relaxed mt-1.5 ${!expanded ? "line-clamp-2" : ""}`}
|
|
onClick={() => setExpanded((v) => !v)}
|
|
>
|
|
{transcript}
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
|
|
{call.location && (
|
|
<span className="text-xs px-2 py-0.5 rounded-full bg-raised text-ink-2">{call.location}</span>
|
|
)}
|
|
{units.map((u) => (
|
|
<span key={u} className="text-xs px-2 py-0.5 rounded-full bg-raised text-ink-2 font-mono">{u}</span>
|
|
))}
|
|
{clearedInCall.map((u) => (
|
|
<span key={u} className="text-xs px-2 py-0.5 rounded-full bg-transparent border border-line text-ink-muted font-mono line-through">{u}</span>
|
|
))}
|
|
</div>
|
|
|
|
{isAdmin && call.corr_path && (
|
|
<p className="text-[10px] font-mono text-ink-muted mt-1">corr: {call.corr_path}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|