Files
server-26/drb-frontend/components/CallSpineEntry.tsx
T
Logan Cusano 4b5cf1971e
Build & Deploy / Build & push images (push) Successful in 4m32s
Build & Deploy / Deploy to VM (push) Failing after 3m47s
Frontend redesign chunk 7: incident detail rebuild
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.
2026-08-19 23:07:28 -04:00

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>
);
}