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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6e462720a1
commit
a267fdf982
@@ -18,19 +18,26 @@
|
|||||||
* applied ONCE here and the filtered lists feed the map (pins, paths, rail),
|
* applied ONCE here and the filtered lists feed the map (pins, paths, rail),
|
||||||
* the region panel, ticker, scrubber bands, auto-tour and KPIs. Pipeline
|
* the region panel, ticker, scrubber bands, auto-tour and KPIs. Pipeline
|
||||||
* health stays on the unfiltered data — a filter must never hide an outage.
|
* 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useNodes } from "@/lib/useNodes";
|
import { useNodes } from "@/lib/useNodes";
|
||||||
import { useSystems } from "@/lib/useSystems";
|
import { useSystems } from "@/lib/useSystems";
|
||||||
import { useActiveCalls, useCalls } from "@/lib/useCalls";
|
import { useActiveCalls, useCalls, useCallsByIncident } from "@/lib/useCalls";
|
||||||
import { useActiveIncidents } from "@/lib/useIncidents";
|
import { useActiveIncidents, useIncident } from "@/lib/useIncidents";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { useAircraft } from "@/lib/useAircraft";
|
import { useAircraft } from "@/lib/useAircraft";
|
||||||
import { useVessels } from "@/lib/useVessels";
|
import { useVessels } from "@/lib/useVessels";
|
||||||
import { useCamerasInBbox } from "@/lib/useCameras";
|
import { useCamerasInBbox } from "@/lib/useCameras";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
import { severityRank } from "@/lib/severity";
|
import { severityRank } from "@/lib/severity";
|
||||||
import type { Camera, Ny511Response } from "@/lib/types";
|
import type { CallRecord, Camera, IncidentRecord, Ny511Response } from "@/lib/types";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LAYERS, NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, normaliseLayers,
|
DEFAULT_LAYERS, NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, normaliseLayers,
|
||||||
onAirIncidentIds, parseWallHash, sameView, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink,
|
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 MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
||||||
|
|
||||||
const TOUR_MS = 20_000;
|
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
|
// 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
|
// 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.
|
// 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 MAX_PINNED = 6;
|
||||||
const EVENTS_POLL_MS = 2 * 60 * 1000;
|
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 {
|
function timeAgo(date: Date): string {
|
||||||
const s = Math.floor((Date.now() - date.getTime()) / 1000);
|
const s = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||||
if (s < 60) return `${s}s ago`;
|
if (s < 60) return `${s}s ago`;
|
||||||
@@ -122,7 +143,8 @@ export function LiveView() {
|
|||||||
const activeIncidents = useActiveIncidents();
|
const activeIncidents = useActiveIncidents();
|
||||||
// Wide-ish recent window: feeds incident paths, the call spine, the ticker
|
// Wide-ish recent window: feeds incident paths, the call spine, the ticker
|
||||||
// and the scrubber's density bars. Real recent calls, not a fixture.
|
// 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 { systems } = useSystems();
|
||||||
const { aircraft } = useAircraft();
|
const { aircraft } = useAircraft();
|
||||||
const { vessels } = useVessels();
|
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)));
|
for (const inc of activeIncidents) m.set(inc.incident_id, incidentPoints(inc, callsByIncident.get(inc.incident_id)));
|
||||||
return m;
|
return m;
|
||||||
}, [activeIncidents, callsByIncident]);
|
}, [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<CallRecord[]>(() => {
|
||||||
|
if (!selected) return [];
|
||||||
|
const id = selected.incident_id;
|
||||||
|
const byId = new Map<string, CallRecord>();
|
||||||
|
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 regionBox = useMemo(() => {
|
||||||
const pts = Array.from(pointsById.values()).flat();
|
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);
|
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 { data: cams, loaded: camsLoaded } = useCamerasInBbox(regionBox);
|
||||||
const feeds = useMemo(() => new Map(cams.providers.map((p) => [p.id, p])), [cams.providers]);
|
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
|
// 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 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 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 keepSelected = !!selected && !filtered.ids.has(selected.incident_id);
|
||||||
const mapIncidents = useMemo(
|
const mapIncidents = useMemo(
|
||||||
() => (keepSelected && selected ? [...filtered.incidents, selected] : filtered.incidents),
|
() => (keepSelected && selected ? [...filtered.incidents, selected] : filtered.incidents),
|
||||||
[keepSelected, selected, filtered.incidents],
|
[keepSelected, selected, filtered.incidents],
|
||||||
);
|
);
|
||||||
const mapCalls = useMemo(() => {
|
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));
|
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))];
|
const extra = selectedCalls.filter((c) => !have.has(c.call_id));
|
||||||
}, [keepSelected, selected, filtered.calls, callsByIncident]);
|
return extra.length ? [...filtered.calls, ...extra] : filtered.calls;
|
||||||
|
}, [selectedCalls, filtered.calls]);
|
||||||
|
|
||||||
// Filter choices come from what the wall has loaded (plus system names).
|
// Filter choices come from what the wall has loaded (plus system names).
|
||||||
const filterOptions = useMemo(() => {
|
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),
|
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]);
|
}, [systems, recentCalls, activeIncidents]);
|
||||||
const selectedPoints = selected ? pointsById.get(selected.incident_id) ?? [] : [];
|
const near = useMemo(() => (selected ? nearestCameras(selectedPoints, cams.cameras) : []), [selected, selectedPoints, cams.cameras]);
|
||||||
const near = selected ? nearById.get(selected.incident_id) ?? [] : [];
|
|
||||||
const drillCam: Camera | null = link.cam
|
const drillCam: Camera | null = link.cam
|
||||||
? cams.cameras.find((c) => c.id === link.cam) ?? servingPinned.find((c) => c.id === link.cam) ?? null
|
? cams.cameras.find((c) => c.id === link.cam) ?? servingPinned.find((c) => c.id === link.cam) ?? null
|
||||||
: null;
|
: null;
|
||||||
@@ -370,6 +429,14 @@ export function LiveView() {
|
|||||||
return () => { clearInterval(id); clearInterval(prog); };
|
return () => { clearInterval(id); clearInterval(prog); };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [link.tour, tourList.join(","), tourMs]);
|
}, [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;
|
const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / tourMs) * 100) : 0;
|
||||||
|
|
||||||
// ── KPIs ──────────────────────────────────────────────────────────────────
|
// ── KPIs ──────────────────────────────────────────────────────────────────
|
||||||
@@ -487,11 +554,35 @@ export function LiveView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className={`${kiosk ? "lg:w-[480px]" : "lg:w-[360px]"} lg:shrink-0 flex-1 lg:flex-none min-h-0 overflow-y-auto border-t lg:border-t-0 lg:border-l border-line bg-surface`}
|
<aside className={`${kiosk ? "lg:w-[480px]" : "lg:w-[360px]"} lg:shrink-0 flex-1 lg:flex-none min-h-0 overflow-y-auto border-t lg:border-t-0 lg:border-l border-line bg-surface`}
|
||||||
aria-live="polite" aria-label={selected ? "Incident detail" : "Region overview"}>
|
aria-live="polite" aria-label={link.inc ? "Incident detail" : "Region overview"}>
|
||||||
{selected ? (
|
{link.inc && !selected ? (
|
||||||
|
<div className="p-4 text-sm">
|
||||||
|
{lookupPending ? (
|
||||||
|
<p className="text-ink-muted">Loading incident…</p>
|
||||||
|
) : notFound ? (
|
||||||
|
<>
|
||||||
|
<p className="font-semibold text-ink">Incident not found</p>
|
||||||
|
<p className="mt-1 text-xs text-ink-muted">
|
||||||
|
This link points to an incident that doesn't exist or isn't in your organization.
|
||||||
|
</p>
|
||||||
|
<button type="button" onClick={() => patchLink({ inc: null, cam: null, tour: false })}
|
||||||
|
className="mt-3 px-2 py-0.5 rounded border border-line text-xs text-ink-2 hover:text-ink">
|
||||||
|
Back to region
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : selected ? (
|
||||||
|
<>
|
||||||
|
{resolvedSelected && (
|
||||||
|
<div role="status" className="px-3 py-1.5 border-b border-line bg-raised text-xs text-ink-2">
|
||||||
|
<b className="text-ink font-semibold">{resolvedLabel(resolvedSelected)}</b>
|
||||||
|
{" · no longer active — opened from a link"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<IncidentPanel
|
<IncidentPanel
|
||||||
inc={selected}
|
inc={selected}
|
||||||
calls={callsByIncident.get(selected.incident_id) ?? []}
|
calls={selectedCalls}
|
||||||
points={selectedPoints}
|
points={selectedPoints}
|
||||||
near={near}
|
near={near}
|
||||||
feeds={feeds}
|
feeds={feeds}
|
||||||
@@ -503,6 +594,7 @@ export function LiveView() {
|
|||||||
eventsCredit={{ credit: events.credit, fetched_at: events.events_status?.fetched_at ?? null }}
|
eventsCredit={{ credit: events.credit, fetched_at: events.events_status?.fetched_at ?? null }}
|
||||||
onOpenCamera={openCamera}
|
onOpenCamera={openCamera}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<RegionPanel
|
<RegionPanel
|
||||||
incidents={filtered.incidents}
|
incidents={filtered.incidents}
|
||||||
@@ -524,7 +616,8 @@ export function LiveView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TranscriptTicker calls={filtered.calls} onSelectIncident={selectIncident} />
|
<TranscriptTicker calls={filtered.calls} onSelectIncident={selectIncident} />
|
||||||
<TimeScrubber calls={filtered.calls} incidents={filtered.incidents} selectedIncidentId={selected?.incident_id ?? null} onSelectIncident={selectIncident} />
|
<TimeScrubber calls={filtered.calls} incidents={filtered.incidents} selectedIncidentId={selected?.incident_id ?? null}
|
||||||
|
onSelectIncident={selectIncident} loadedSince={loadedSince} windowSize={RECENT_CALLS} />
|
||||||
|
|
||||||
{drillCam && (
|
{drillCam && (
|
||||||
<CameraDrill
|
<CameraDrill
|
||||||
|
|||||||
@@ -26,12 +26,20 @@ const BUCKETS = 48;
|
|||||||
* `incidents` (optional) adds one band per active incident from its start to
|
* `incidents` (optional) adds one band per active incident from its start to
|
||||||
* its last update, tinted by severity — click a band to drill into it (live
|
* its last update, tinted by severity — click a band to drill into it (live
|
||||||
* ops wall). The playhead still does not move; see the header.
|
* ops wall). The playhead still does not move; see the header.
|
||||||
|
*
|
||||||
|
* `loadedSince` (server-26#239): ISO time of the oldest call the caller's
|
||||||
|
* window loaded when that window came back FULL (null = everything in range
|
||||||
|
* was loaded). The axis before it is hatched and labelled "not loaded", so a
|
||||||
|
* 7d view never reads as a quiet week for time nobody fetched.
|
||||||
*/
|
*/
|
||||||
export function TimeScrubber({ calls, incidents = [], selectedIncidentId = null, onSelectIncident }: {
|
export function TimeScrubber({ calls, incidents = [], selectedIncidentId = null, onSelectIncident, loadedSince = null, windowSize }: {
|
||||||
calls: CallRecord[];
|
calls: CallRecord[];
|
||||||
incidents?: IncidentRecord[];
|
incidents?: IncidentRecord[];
|
||||||
selectedIncidentId?: string | null;
|
selectedIncidentId?: string | null;
|
||||||
onSelectIncident?: (id: string) => void;
|
onSelectIncident?: (id: string) => void;
|
||||||
|
loadedSince?: string | null;
|
||||||
|
/** Size of the caller's call window, for the "not loaded" tooltip. */
|
||||||
|
windowSize?: number;
|
||||||
}) {
|
}) {
|
||||||
const [rangeIdx, setRangeIdx] = useState(2); // default 24h
|
const [rangeIdx, setRangeIdx] = useState(2); // default 24h
|
||||||
|
|
||||||
@@ -56,6 +64,24 @@ export function TimeScrubber({ calls, incidents = [], selectedIncidentId = null,
|
|||||||
|
|
||||||
const maxCount = Math.max(1, ...buckets.map((b) => b.count));
|
const maxCount = Math.max(1, ...buckets.map((b) => b.count));
|
||||||
|
|
||||||
|
// Share of the axis (0-100) before the loaded window starts; 0 = all loaded.
|
||||||
|
const unloaded = useMemo(() => {
|
||||||
|
const t = loadedSince ? new Date(loadedSince).getTime() : NaN;
|
||||||
|
if (Number.isNaN(t)) return { pct: 0, firstLoaded: 0, since: null as Date | null };
|
||||||
|
const start = Date.now() - range.ms;
|
||||||
|
if (t <= start) return { pct: 0, firstLoaded: 0, since: null as Date | null };
|
||||||
|
const frac = Math.min(1, (t - start) / range.ms);
|
||||||
|
// Buckets wholly before the window are unknown, not empty: drawn blank.
|
||||||
|
return { pct: frac * 100, firstLoaded: Math.floor(frac * BUCKETS), since: new Date(t) };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [loadedSince, rangeIdx, calls]);
|
||||||
|
const sinceLabel = unloaded.since
|
||||||
|
? unloaded.since.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false })
|
||||||
|
: "";
|
||||||
|
const unloadedTitle = unloaded.since
|
||||||
|
? `Not loaded: only the latest ${windowSize ?? "few hundred"} calls are on this page (back to ${sinceLabel}). Earlier activity is unknown, not quiet.`
|
||||||
|
: "";
|
||||||
|
|
||||||
const bands = useMemo(() => {
|
const bands = useMemo(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const start = now - range.ms;
|
const start = now - range.ms;
|
||||||
@@ -88,7 +114,28 @@ export function TimeScrubber({ calls, incidents = [], selectedIncidentId = null,
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{unloaded.pct > 0 && (
|
||||||
|
<div
|
||||||
|
role="img"
|
||||||
|
aria-label={unloadedTitle}
|
||||||
|
title={unloadedTitle}
|
||||||
|
className="absolute left-0 top-0 bottom-0 z-[1] flex items-center justify-center overflow-hidden rounded-sm border border-dashed border-line"
|
||||||
|
style={{
|
||||||
|
width: `${unloaded.pct}%`,
|
||||||
|
background: "repeating-linear-gradient(135deg, var(--line-strong) 0 2px, transparent 2px 7px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{unloaded.pct >= 8 && (
|
||||||
|
<span className="px-1.5 rounded bg-surface/90 text-[10px] font-mono uppercase tracking-wide text-ink-muted whitespace-nowrap">
|
||||||
|
not loaded
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{buckets.map((b, i) => {
|
{buckets.map((b, i) => {
|
||||||
|
if (i < unloaded.firstLoaded) {
|
||||||
|
return <div key={i} aria-hidden="true" style={{ flex: 1, minWidth: 2 }} />;
|
||||||
|
}
|
||||||
const heightPct = b.count === 0 ? 4 : Math.max(10, (b.count / maxCount) * 100);
|
const heightPct = b.count === 0 ? 4 : Math.max(10, (b.count / maxCount) * 100);
|
||||||
const color =
|
const color =
|
||||||
b.worstRank === 3
|
b.worstRank === 3
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
|
|||||||
const [calls, setCalls] = useState<CallRecord[]>([]);
|
const [calls, setCalls] = useState<CallRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// True when the query returned a full page (snap.size, counted BEFORE the
|
||||||
|
// duplicate_of filter below) — there may be older calls past the limit.
|
||||||
|
// calls.length can't answer that: dedup drops rows from a full page.
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
const { orgId } = useAuth();
|
const { orgId } = useAuth();
|
||||||
|
|
||||||
// Stable ms values so the effect dependency doesn't fire on every render
|
// Stable ms values so the effect dependency doesn't fire on every render
|
||||||
@@ -63,6 +67,7 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
|
|||||||
// index alongside the existing org_id/started_at query.
|
// index alongside the existing org_id/started_at query.
|
||||||
.filter((c) => !c.duplicate_of);
|
.filter((c) => !c.duplicate_of);
|
||||||
setCalls(docs);
|
setCalls(docs);
|
||||||
|
setHasMore(snap.size >= limitCount);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, (err: FirestoreError) => { console.error("useCalls:", err); setError(err.message); setLoading(false); });
|
}, (err: FirestoreError) => { console.error("useCalls:", err); setError(err.message); setLoading(false); });
|
||||||
});
|
});
|
||||||
@@ -73,7 +78,7 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
|
|||||||
};
|
};
|
||||||
}, [limitCount, dateFromMs, dateToMs, orgId]);
|
}, [limitCount, dateFromMs, dateToMs, orgId]);
|
||||||
|
|
||||||
return { calls, loading, error };
|
return { calls, loading, error, hasMore };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCallsByIncident(incidentId: string | null) {
|
export function useCallsByIncident(incidentId: string | null) {
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ export function useIncident(incidentId: string | null) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!incidentId) { setLoading(false); return; }
|
if (!incidentId) { setLoading(false); return; }
|
||||||
|
// A new id is a new lookup: without this, `loading` stays false from the
|
||||||
|
// previous id and a caller reads "not found" until the snapshot lands.
|
||||||
|
setLoading(true);
|
||||||
let unsubFirestore: (() => void) | undefined;
|
let unsubFirestore: (() => void) | undefined;
|
||||||
|
|
||||||
const unsubAuth = onAuthStateChanged(auth, (user) => {
|
const unsubAuth = onAuthStateChanged(auth, (user) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user