Files
Logan CusanoandClaude Sonnet 5 d67b2057e6 frontend: safe fixes from the #109 punch-list
- CallSpineEntry.tsx: drop the dead `hasAudio` prop + the early `return null`
  that sat between hooks in InlinePlayer (React #310 risk). Parent already
  gates the mount on audio presence.
- NodeCard.tsx + nodes/page.tsx: pending-node card no longer double-fires.
  NodeCard gains `linkToDetail` (default true); the pending branch passes
  false so the wrapping onClick (open config modal) isn't swallowed by the
  inner <Link> navigation. List view unchanged.
- trips/page.tsx: TripCard badge now buckets on end_date >= today, matching
  the list's own upcoming/past split — an in-progress trip no longer shows a
  "Past" badge under "Upcoming".
- trips/page.tsx, NodeConfigModal.tsx, nodes/[id]/page.tsx: tall modals get
  `p-4` on the overlay + `max-h-[90vh] overflow-y-auto` on the panel so they
  don't clip on short viewports (incidents' CreateModal pattern).
- lib/types.ts: IncidentRecord.units / vehicles are optional now, matching
  Firestore (older docs omit them); incidents/[id] gains a `?? []` guard.

Untypechecked (no node/npm locally). next build in deploy.yml gates it.
Full list of remaining items in server-26 #109.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 00:07:54 -04:00

202 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 }: { callId: string }) {
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);
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} />
</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>
);
}