/** * Attention cues for the live wall (server-26#238): what changed between two * snapshots that a person should look at, the "NEW · last 15 min" lane, and * which pins pulse. * * `diffAttention` is pure: (previous state, snapshot) → (next state, events). * The first snapshot is a BASELINE and emits nothing, so opening the page * never fires a cue for every incident already open. Calls baseline * separately, on the first non-empty call snapshot, because the incident and * call subscriptions land independently. * * The diff runs over ALL active incidents, not the filtered list: diffing the * filtered list would make widening a filter look like a burst of "new" * Majors. Visibility is applied when an event is emitted (`isVisibleEvent`), * against the filter in force at that moment — so a cue or notification * never fires for an incident filtered out of view. */ import { useEffect, useMemo, useRef, useState } from "react"; import type { CallRecord, IncidentRecord } from "@/lib/types"; import { SEVERITY_LABEL, isKnownSeverity, severityRank } from "@/lib/severity"; import { groupCallsByIncident, pathStops } from "@/lib/liveWall"; /** How long something counts as "new" on the lane. */ export const LANE_WINDOW_MS = 15 * 60_000; /** How long a new / changed incident's pin pulses. */ export const PULSE_MS = 2 * 60_000; export const DEFAULT_KEYWORDS: readonly string[] = ["MAYDAY", "10-13", "shots fired", "officer down"]; const MAJOR = 3; /** A stop on an incident with no known stops counts as new only if it started after (first seen − this). */ const STOP_GRACE_MS = 2 * 60_000; /** Forget incidents gone from the active list this long (state stays bounded on an all-day wall). */ const FORGET_MS = 6 * 3600_000; /** Checked-call memory: anything older than the lane window is never cued anyway. */ const CALL_MEMORY_MS = LANE_WINDOW_MS + 30 * 60_000; const MAX_LANE = 12; const MAX_LOG = 200; export type AttentionKind = "major" | "escalated" | "stop" | "keyword"; export interface AttentionEvent { /** Stable key (also the notification tag's suffix). */ id: string; kind: AttentionKind; /** Incidents this concerns; empty = a call not linked to any incident yet. */ incidentIds: string[]; callId: string | null; at: number; /** Short reason chip, e.g. "Major", "↑ Moderate", "+1 stop", "MAYDAY". */ label: string; /** Incident title, or the talkgroup for an unlinked call. */ title: string; } interface IncState { rank: number; /** When this incident first counted as new: its start for the baseline, else when the wall first saw it. */ seenAt: number; /** When the wall started tracking it (stop-grace floor). */ knownSince: number; lastSeen: number; stopIds: Set; newestStopAt: number | null; } export interface AttentionState { baselineAt: number; callsBaselined: boolean; incidents: Map; /** call_id → started_at ms, for calls whose transcript has been keyword-checked. */ checkedCalls: Map; } const ms = (iso: string | null | undefined): number => { const t = iso ? Date.parse(iso) : NaN; return Number.isNaN(t) ? 0 : t; }; function callIncidentIds(c: CallRecord): string[] { return c.incident_ids?.length ? c.incident_ids : c.incident_id ? [c.incident_id] : []; } function transcriptOf(c: CallRecord): string { return c.transcript_corrected || c.transcript || ""; } /** Non-ghost geocoded stops of a movement incident (pursuit path), oldest first. Empty otherwise. */ function movementStops(inc: IncidentRecord, calls: CallRecord[] | undefined): { id: string; at: number }[] { const path = pathStops(inc, calls); if (!path.movement) return []; return path.stops.filter((s) => !s.ghost).map((s) => ({ id: s.call.call_id, at: ms(s.call.started_at) })); } function escapeRe(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** * Configured keywords found in `text`, case-insensitive, whole-word: "10-13" * does not fire on "210-135". Spaces match any whitespace and a hyphen may be * a space or absent ("10 13", "1013"), since transcription renders codes both ways. */ export function matchKeywords(text: string, keywords: readonly string[]): string[] { if (!text) return []; const t = text.toLowerCase(); const hits: string[] = []; for (const k of keywords) { const kw = k.trim().toLowerCase(); if (!kw) continue; const body = kw.split(/\s+/).map((w) => escapeRe(w).replace(/-/g, "[-\\s]?")).join("\\s+"); if (new RegExp(`(^|[^a-z0-9])${body}($|[^a-z0-9])`).test(t)) hits.push(k.trim()); } return hits; } function sevLabel(sev: string | null | undefined): string { return isKnownSeverity(sev) ? SEVERITY_LABEL[sev] : "Unrated"; } /** * One snapshot step. `prev === null` → baseline (no events). Never mutates `prev`. * `incidents` = every active incident (unfiltered); `calls` = every call the * wall has loaded (recent window + the selected incident's own calls). */ export function diffAttention( prev: AttentionState | null, incidents: IncidentRecord[], calls: CallRecord[], keywords: readonly string[], now: number = Date.now(), ): { state: AttentionState; events: AttentionEvent[] } { const byInc = groupCallsByIncident(calls); const events: AttentionEvent[] = []; if (!prev) { const map = new Map(); for (const inc of incidents) { const stops = movementStops(inc, byInc.get(inc.incident_id)); map.set(inc.incident_id, { rank: severityRank(inc.severity), seenAt: ms(inc.started_at) || now, knownSince: now, lastSeen: now, stopIds: new Set(stops.map((s) => s.id)), newestStopAt: stops.length ? Math.max(...stops.map((s) => s.at)) : null, }); } const checked = new Map(); for (const c of calls) if (transcriptOf(c)) checked.set(c.call_id, ms(c.started_at)); return { state: { baselineAt: now, callsBaselined: calls.length > 0, incidents: map, checkedCalls: checked }, events }; } // The first non-empty call snapshot is the calls' own baseline: record what // is there, and re-seed every known incident's stops from it, silently. const baselineCalls = !prev.callsBaselined && calls.length > 0; const map = new Map(); const present = new Set(); for (const inc of incidents) { const id = inc.incident_id; present.add(id); const rank = severityRank(inc.severity); const stops = movementStops(inc, byInc.get(id)); const p = prev.incidents.get(id); const title = inc.title || "Untitled incident"; if (!p) { map.set(id, { rank, seenAt: now, knownSince: now, lastSeen: now, stopIds: new Set(stops.map((s) => s.id)), newestStopAt: stops.length ? Math.max(...stops.map((s) => s.at)) : null, }); if (rank === MAJOR) events.push({ id: `major:${id}`, kind: "major", incidentIds: [id], callId: null, at: now, label: "Major", title }); continue; } const st: IncState = { ...p, rank, lastSeen: now, stopIds: new Set(p.stopIds) }; // An unrated incident getting its first rating is classification, not // escalation — unless that first rating is Major. if (rank > p.rank && (p.rank >= 0 || rank === MAJOR)) { events.push({ id: `sev:${id}:${rank}`, kind: rank === MAJOR && p.rank < 0 ? "major" : "escalated", incidentIds: [id], callId: null, at: now, label: p.rank < 0 ? sevLabel(inc.severity) : `↑ ${sevLabel(inc.severity)}`, title, }); } if (stops.length) { const floor = st.newestStopAt ?? st.knownSince - STOP_GRACE_MS; const fresh = baselineCalls ? [] : stops.filter((s) => !p.stopIds.has(s.id) && s.at > floor); for (const s of stops) st.stopIds.add(s.id); st.newestStopAt = Math.max(st.newestStopAt ?? 0, ...stops.map((s) => s.at)); if (fresh.length) { const newest = fresh[fresh.length - 1]; events.push({ id: `stop:${id}:${newest.id}`, kind: "stop", incidentIds: [id], callId: newest.id, at: now, label: fresh.length === 1 ? "+1 stop" : `+${fresh.length} stops`, title, }); } } map.set(id, st); } // Keep incidents that dropped out for a while, so one that flickers out of // the active list and back is not "new" again. prev.incidents.forEach((st, id) => { if (!present.has(id) && now - st.lastSeen < FORGET_MS) map.set(id, st); }); const checked = new Map(); prev.checkedCalls.forEach((t, id) => { if (now - t < CALL_MEMORY_MS) checked.set(id, t); }); const incTitle = new Map(incidents.map((i) => [i.incident_id, i.title || "Untitled incident"])); for (const c of calls) { const text = transcriptOf(c); if (!text || checked.has(c.call_id)) continue; const started = ms(c.started_at); checked.set(c.call_id, started || now); if (baselineCalls || !prev.callsBaselined) continue; if (now - started > LANE_WINDOW_MS) continue; // an old call pulled in late (e.g. by selection) is not news const hits = matchKeywords(text, keywords); if (!hits.length) continue; const ids = callIncidentIds(c); events.push({ id: `kw:${c.call_id}`, kind: "keyword", incidentIds: ids, callId: c.call_id, at: now, label: hits.join(" · ").toUpperCase(), title: ids.map((i) => incTitle.get(i)).find(Boolean) ?? c.talkgroup_name ?? (c.talkgroup_id != null ? `TG ${c.talkgroup_id}` : "Unlinked call"), }); } return { state: { baselineAt: prev.baselineAt, callsBaselined: prev.callsBaselined || baselineCalls, incidents: map, checkedCalls: checked }, events, }; } /** * In view under the current filter: a linked event needs one of its incidents * in the filtered list (never the call alone — a filtered-out incident's call * must not cue); an unlinked call needs the call itself in the filtered calls. */ export function isVisibleEvent(e: AttentionEvent, visibleIds: ReadonlySet, visibleCallIds: ReadonlySet): boolean { if (e.incidentIds.length) return e.incidentIds.some((id) => visibleIds.has(id)); return !!e.callId && visibleCallIds.has(e.callId); } export interface LaneEntry { key: string; incidentId: string | null; title: string; severity: string | null; /** Most recent reason to be on the lane (ms). */ at: number; /** "New" or the latest event's label. */ reason: string; } /** The lane: visible incidents new in the window or with a cue in it, plus unlinked keyword calls; newest first. */ export function laneEntries( visible: IncidentRecord[], seenAt: ReadonlyMap, log: AttentionEvent[], now: number, visibleCallIds: ReadonlySet, ): LaneEntry[] { const since = now - LANE_WINDOW_MS; const latest = new Map(); for (const e of log) { if (e.at < since) continue; for (const id of e.incidentIds) { const cur = latest.get(id); if (!cur || e.at >= cur.at) latest.set(id, e); } } const out: LaneEntry[] = []; for (const inc of visible) { const seen = seenAt.get(inc.incident_id) ?? 0; const ev = latest.get(inc.incident_id); const isNew = seen >= since; if (!isNew && !ev) continue; const at = Math.max(isNew ? seen : 0, ev?.at ?? 0); out.push({ key: inc.incident_id, incidentId: inc.incident_id, title: inc.title || "Untitled incident", severity: inc.severity, at, reason: ev && ev.at >= (isNew ? seen : 0) ? ev.label : "New", }); } for (const e of log) { if (e.at < since || e.incidentIds.length || !e.callId || !visibleCallIds.has(e.callId)) continue; out.push({ key: e.id, incidentId: null, title: e.title, severity: null, at: e.at, reason: e.label }); } return out.sort((a, b) => b.at - a.at).slice(0, MAX_LANE); } /** Visible incidents first seen, or cued, within PULSE_MS. */ export function pulseIds(visibleIds: ReadonlySet, seenAt: ReadonlyMap, log: AttentionEvent[], now: number): Set { const since = now - PULSE_MS; const out = new Set(); seenAt.forEach((t, id) => { if (t >= since && visibleIds.has(id)) out.add(id); }); for (const e of log) if (e.at >= since) for (const id of e.incidentIds) if (visibleIds.has(id)) out.add(id); return out; } // ── Preferences (per browser; a convenience, so storage failure = defaults) ── export interface AttentionPrefs { /** Audible cue. OFF until the viewer turns it on (autoplay policy + no surprise noise). */ tone: boolean; /** Browser notifications while the tab is hidden. OFF until granted and turned on. */ notify: boolean; keywords: string[]; } const PREFS_KEY = "drb.wall.attention"; export const DEFAULT_ATTENTION_PREFS: AttentionPrefs = { tone: false, notify: false, keywords: [...DEFAULT_KEYWORDS] }; export function loadAttentionPrefs(): AttentionPrefs { try { const raw = window.localStorage.getItem(PREFS_KEY); if (!raw) return DEFAULT_ATTENTION_PREFS; const v = JSON.parse(raw) as Partial; return { tone: v.tone === true, notify: v.notify === true, keywords: Array.isArray(v.keywords) ? v.keywords.filter((k): k is string => typeof k === "string" && !!k.trim()).slice(0, 30) : [...DEFAULT_KEYWORDS], }; } catch { return DEFAULT_ATTENTION_PREFS; } } export function saveAttentionPrefs(p: AttentionPrefs): void { try { window.localStorage.setItem(PREFS_KEY, JSON.stringify(p)); } catch { /* storage blocked */ } } /** "MAYDAY, 10-13 , ,shots fired" → ["MAYDAY", "10-13", "shots fired"] (deduped, case-insensitively). */ export function parseKeywords(s: string): string[] { const seen = new Set(); const out: string[] = []; for (const part of s.split(",")) { const k = part.trim().replace(/\s+/g, " "); if (!k || seen.has(k.toLowerCase())) continue; seen.add(k.toLowerCase()); out.push(k); } return out.slice(0, 30); } // ── Hook ───────────────────────────────────────────────────────────────────── interface UseAttentionInput { /** True once the incident list's first server snapshot has landed (and the org is known). */ ready: boolean; /** Changing this (e.g. the org) starts a fresh baseline. */ resetKey: string | null; incidents: IncidentRecord[]; calls: CallRecord[]; keywords: readonly string[]; /** The FILTERED incidents, their ids, and the filtered call ids — what is in view right now. */ visible: IncidentRecord[]; visibleIds: ReadonlySet; visibleCallIds: ReadonlySet; /** Called with each snapshot's new, in-view events (tone / notification). */ onEvents?: (events: AttentionEvent[]) => void; } export function useAttention({ ready, resetKey, incidents, calls, keywords, visible, visibleIds, visibleCallIds, onEvents }: UseAttentionInput) { const stateRef = useRef(null); const keyRef = useRef(resetKey); const [log, setLog] = useState([]); const [seenAt, setSeenAt] = useState>(new Map()); const [now, setNow] = useState(() => Date.now()); const visRef = useRef({ visibleIds, visibleCallIds }); visRef.current = { visibleIds, visibleCallIds }; const onEventsRef = useRef(onEvents); onEventsRef.current = onEvents; const kwKey = keywords.join("\u0001"); useEffect(() => { const id = setInterval(() => setNow(Date.now()), 15_000); return () => clearInterval(id); }, []); useEffect(() => { if (keyRef.current !== resetKey) { keyRef.current = resetKey; stateRef.current = null; setLog([]); setSeenAt(new Map()); } if (!ready) return; const t = Date.now(); const { state, events } = diffAttention(stateRef.current, incidents, calls, keywords, t); stateRef.current = state; const seen = new Map(); state.incidents.forEach((s, id) => seen.set(id, s.seenAt)); setSeenAt(seen); setNow(t); if (!events.length) return; setLog((cur) => [...cur.filter((e) => t - e.at < LANE_WINDOW_MS), ...events].slice(-MAX_LOG)); const { visibleIds: vi, visibleCallIds: vc } = visRef.current; const shown = events.filter((e) => isVisibleEvent(e, vi, vc)); if (shown.length) onEventsRef.current?.(shown); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ready, resetKey, incidents, calls, kwKey]); // A keyword usually fires on the transcript, before correlation links the // call. Re-resolve unlinked events against the calls as they are now, so // the lane row becomes the (clickable) incident and its pin pulses. const linkedLog = useMemo(() => { if (!log.some((e) => !e.incidentIds.length && e.callId)) return log; const links = new Map(); for (const c of calls) { const ids = callIncidentIds(c); if (ids.length) links.set(c.call_id, ids); } return log.map((e) => (e.incidentIds.length || !e.callId || !links.has(e.callId) ? e : { ...e, incidentIds: links.get(e.callId)! })); }, [log, calls]); const lane = useMemo(() => laneEntries(visible, seenAt, linkedLog, now, visibleCallIds), [visible, seenAt, linkedLog, now, visibleCallIds]); const pulse = useMemo(() => pulseIds(visibleIds, seenAt, linkedLog, now), [visibleIds, seenAt, linkedLog, now]); return { lane, pulseIds: pulse, now }; }