From a267fdf982ebd439802e8aec171aa42085ee2815 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Mon, 5 Oct 2026 03:25:13 -0400 Subject: [PATCH] Live wall: selected incident loads its own calls; resolved deep links; scrubber marks unloaded time (#239) - LiveView: the selected incident merges useCallsByIncident (org_id + incident_ids CONTAINS, existing index, no orderBy/limit) into the calls fed to IncidentPanel and MapView, so a long incident keeps its full path and spine past the 500-call window. Stale rows from the previous selection are re-checked against the current id before merging. - #inc= not in the active set is fetched with useIncident: a resolved one is selected, drawn on the map and shown under a "Resolved HH:MM" banner; a missing or other-org id shows "Incident not found". Auto-tour stays on active incidents. - TimeScrubber: new loadedSince prop (oldest call when the window came back full). The axis before it is hatched and labelled "not loaded" instead of reading as a quiet stretch. - useCalls returns hasMore (full page, counted before the duplicate_of filter); useIncident resets loading on a new id so a lookup doesn't flash "not found". Co-Authored-By: Claude Opus 5.5 --- drb-frontend/components/LiveView.tsx | 125 ++++++++++++++++++++--- drb-frontend/components/TimeScrubber.tsx | 49 ++++++++- drb-frontend/lib/useCalls.ts | 7 +- drb-frontend/lib/useIncidents.ts | 3 + 4 files changed, 166 insertions(+), 18 deletions(-) diff --git a/drb-frontend/components/LiveView.tsx b/drb-frontend/components/LiveView.tsx index 3f828c0..95ff42d 100644 --- a/drb-frontend/components/LiveView.tsx +++ b/drb-frontend/components/LiveView.tsx @@ -18,19 +18,26 @@ * applied ONCE here and the filtered lists feed the map (pins, paths, rail), * the region panel, ticker, scrubber bands, auto-tour and KPIs. Pipeline * health stays on the unfiltered data — a filter must never hide an outage. + * + * Call window (server-26#239): the 500-call window feeds the ticker, scrubber + * and region; the SELECTED incident also loads its own calls by incident id + * and merges them in, so a long incident's path and spine are complete. An + * `#inc=` that is not active is fetched by id (resolved → banner; missing or + * another org → "not found"). The scrubber hatches time the window didn't load. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import dynamic from "next/dynamic"; import { useNodes } from "@/lib/useNodes"; import { useSystems } from "@/lib/useSystems"; -import { useActiveCalls, useCalls } from "@/lib/useCalls"; -import { useActiveIncidents } from "@/lib/useIncidents"; +import { useActiveCalls, useCalls, useCallsByIncident } from "@/lib/useCalls"; +import { useActiveIncidents, useIncident } from "@/lib/useIncidents"; +import { useAuth } from "@/components/AuthProvider"; import { useAircraft } from "@/lib/useAircraft"; import { useVessels } from "@/lib/useVessels"; import { useCamerasInBbox } from "@/lib/useCameras"; import { c2api } from "@/lib/c2api"; import { severityRank } from "@/lib/severity"; -import type { Camera, Ny511Response } from "@/lib/types"; +import type { CallRecord, Camera, IncidentRecord, Ny511Response } from "@/lib/types"; import { DEFAULT_LAYERS, NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, normaliseLayers, onAirIncidentIds, parseWallHash, sameView, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink, @@ -51,6 +58,8 @@ import { usePipelineHealth } from "@/lib/usePipelineHealth"; const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); const TOUR_MS = 20_000; +/** The wall's recent-call window (server-26#239: a selected incident loads past it). */ +const RECENT_CALLS = 500; // 10-foot type for kiosk walls (server-26#234): scales every small text class // under the wall root — including MapView's rail and WallPanels, which are not // edited for it — to 18-20 px. Literal strings so Tailwind's scanner sees them. @@ -67,6 +76,18 @@ const MAX_TAG_CHIPS = 8; const MAX_PINNED = 6; const EVENTS_POLL_MS = 2 * 60 * 1000; +/** Incident-doc fields the frontend type doesn't declare yet (backend writes both). */ +type IncidentDoc = IncidentRecord & { org_id?: string | null; resolved_at?: string | null }; + +/** "Resolved 14:32", with the date when it wasn't today. */ +function resolvedLabel(inc: IncidentDoc): string { + const d = inc.resolved_at ? new Date(inc.resolved_at) : null; + if (!d || Number.isNaN(d.getTime())) return "Resolved"; + const hm = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false }); + const today = d.toDateString() === new Date().toDateString(); + return today ? `Resolved ${hm}` : `Resolved ${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${hm}`; +} + function timeAgo(date: Date): string { const s = Math.floor((Date.now() - date.getTime()) / 1000); if (s < 60) return `${s}s ago`; @@ -122,7 +143,8 @@ export function LiveView() { const activeIncidents = useActiveIncidents(); // Wide-ish recent window: feeds incident paths, the call spine, the ticker // and the scrubber's density bars. Real recent calls, not a fixture. - const { calls: recentCalls, loading: callsLoading, error: callsError } = useCalls(500); + const { calls: recentCalls, loading: callsLoading, error: callsError, hasMore: windowFull } = useCalls(RECENT_CALLS); + const { orgId } = useAuth(); const { systems } = useSystems(); const { aircraft } = useAircraft(); const { vessels } = useVessels(); @@ -250,10 +272,47 @@ export function LiveView() { for (const inc of activeIncidents) m.set(inc.incident_id, incidentPoints(inc, callsByIncident.get(inc.incident_id))); return m; }, [activeIncidents, callsByIncident]); + // ── Selection (server-26#239) ───────────────────────────────────────────── + // Active first; an #inc= that isn't active (resolved, or the active list is + // still loading) is looked up by id. Another org's incident never selects: + // the rules should deny the read, and the org check here covers a project + // where they aren't deployed yet (DEFERRED.md, server-26#13). + const activeSelected = link.inc ? activeIncidents.find((i) => i.incident_id === link.inc) ?? null : null; + const lookupId = link.inc && !activeSelected ? link.inc : null; + const { incident: lookedUp, loading: lookupLoading } = useIncident(lookupId); + const fetchedSelected: IncidentDoc | null = + lookupId && lookedUp && lookedUp.incident_id === lookupId && !!orgId && (lookedUp as IncidentDoc).org_id === orgId + ? lookedUp + : null; + const selected: IncidentDoc | null = activeSelected ?? fetchedSelected; + const lookupPending = !!lookupId && !fetchedSelected && (lookupLoading || !orgId); + const notFound = !!lookupId && !fetchedSelected && !lookupPending; + const resolvedSelected = fetchedSelected?.status === "resolved" ? fetchedSelected : null; + + // The selected incident's own calls, by incident id — no limit, so a long + // incident keeps every stop the 500-call window has already dropped. The + // hook keeps the previous incident's rows until its new snapshot lands, so + // rows are re-checked against the current id before merging. + const { calls: incidentCalls } = useCallsByIncident(link.inc); + const selectedCalls = useMemo(() => { + if (!selected) return []; + const id = selected.incident_id; + const byId = new Map(); + for (const c of callsByIncident.get(id) ?? []) byId.set(c.call_id, c); + for (const c of incidentCalls) { + if (c.incident_ids?.includes(id) || c.incident_id === id) byId.set(c.call_id, c); + } + return Array.from(byId.values()); + }, [selected, callsByIncident, incidentCalls]); + const selectedPoints = useMemo(() => (selected ? incidentPoints(selected, selectedCalls) : []), [selected, selectedCalls]); + const regionBox = useMemo(() => { const pts = Array.from(pointsById.values()).flat(); + // A resolved incident opened by link sits outside the active set; count it + // so its nearby cameras load. + if (resolvedSelected) pts.push(...selectedPoints); return bboxAround(pts.length ? pts : nodes.map((n) => [n.lat, n.lon] as LatLon), NEAR_CAMERA_KM + 0.5); - }, [pointsById, nodes]); + }, [pointsById, nodes, resolvedSelected, selectedPoints]); const { data: cams, loaded: camsLoaded } = useCamerasInBbox(regionBox); const feeds = useMemo(() => new Map(cams.providers.map((p) => [p.id, p])), [cams.providers]); // Pins live in this browser and carry their image URLs, so a pin must not @@ -276,19 +335,20 @@ export function LiveView() { }); const onAir = useMemo(() => onAirIncidentIds(activeIncidents, activeCalls), [activeIncidents, activeCalls]); - const selected = link.inc ? activeIncidents.find((i) => i.incident_id === link.inc) ?? null : null; // A drilled-into incident stays on the map even if the filter excludes it - // (a shared #inc= link must still land); it is not added to the tour. + // or it has resolved (a shared #inc= link must still land); it is not added + // to the tour. Its full call list rides along so the path is complete. const keepSelected = !!selected && !filtered.ids.has(selected.incident_id); const mapIncidents = useMemo( () => (keepSelected && selected ? [...filtered.incidents, selected] : filtered.incidents), [keepSelected, selected, filtered.incidents], ); const mapCalls = useMemo(() => { - if (!keepSelected || !selected) return filtered.calls; + if (!selectedCalls.length) return filtered.calls; const have = new Set(filtered.calls.map((c) => c.call_id)); - return [...filtered.calls, ...(callsByIncident.get(selected.incident_id) ?? []).filter((c) => !have.has(c.call_id))]; - }, [keepSelected, selected, filtered.calls, callsByIncident]); + const extra = selectedCalls.filter((c) => !have.has(c.call_id)); + return extra.length ? [...filtered.calls, ...extra] : filtered.calls; + }, [selectedCalls, filtered.calls]); // Filter choices come from what the wall has loaded (plus system names). const filterOptions = useMemo(() => { @@ -315,8 +375,7 @@ export function LiveView() { tags: Array.from(tagCount.entries()).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, MAX_TAG_CHIPS).map(([t]) => t), }; }, [systems, recentCalls, activeIncidents]); - const selectedPoints = selected ? pointsById.get(selected.incident_id) ?? [] : []; - const near = selected ? nearById.get(selected.incident_id) ?? [] : []; + const near = useMemo(() => (selected ? nearestCameras(selectedPoints, cams.cameras) : []), [selected, selectedPoints, cams.cameras]); const drillCam: Camera | null = link.cam ? cams.cameras.find((c) => c.id === link.cam) ?? servingPinned.find((c) => c.id === link.cam) ?? null : null; @@ -370,6 +429,14 @@ export function LiveView() { return () => { clearInterval(id); clearInterval(prog); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [link.tour, tourList.join(","), tourMs]); + // Scrubber honesty (server-26#239): when the window came back full, nothing + // older than its oldest call was loaded — that span is unknown, not quiet. + const loadedSince = useMemo(() => { + if (!windowFull) return null; + let oldest: string | null = null; + for (const c of recentCalls) if (c.started_at && (!oldest || c.started_at < oldest)) oldest = c.started_at; + return oldest; + }, [windowFull, recentCalls]); const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / tourMs) * 100) : 0; // ── KPIs ────────────────────────────────────────────────────────────────── @@ -487,11 +554,35 @@ export function LiveView() {