- NEW lane: Gate A notice pinned below the rows (it scrolled off with >3 entries, same rule as the rail, #46); rows at text-xs for the wall. - Mobile chip moved off MapView's centred "Live" chip, which it covered. - Keyword cues re-resolve to the incident once correlation links the call, so the lane row is clickable and the pin pulses. - Enabling notifications sends one confirmation notification; a browser that grants permission but throws on construct (mobile Chrome) now shows "unsupported" instead of a switched-on control that never delivers. - useActiveIncidentsState: includeMetadataChanges so `loaded` flips on a metadata-only server sync (quiet org / offline start) instead of waiting for the first incident change and swallowing it into the baseline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
420 lines
17 KiB
TypeScript
420 lines
17 KiB
TypeScript
/**
|
||
* 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<string>;
|
||
newestStopAt: number | null;
|
||
}
|
||
|
||
export interface AttentionState {
|
||
baselineAt: number;
|
||
callsBaselined: boolean;
|
||
incidents: Map<string, IncState>;
|
||
/** call_id → started_at ms, for calls whose transcript has been keyword-checked. */
|
||
checkedCalls: Map<string, number>;
|
||
}
|
||
|
||
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<string, IncState>();
|
||
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<string, number>();
|
||
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<string, IncState>();
|
||
const present = new Set<string>();
|
||
|
||
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<string, number>();
|
||
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<string>, visibleCallIds: ReadonlySet<string>): 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<string, number>,
|
||
log: AttentionEvent[],
|
||
now: number,
|
||
visibleCallIds: ReadonlySet<string>,
|
||
): LaneEntry[] {
|
||
const since = now - LANE_WINDOW_MS;
|
||
const latest = new Map<string, AttentionEvent>();
|
||
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<string>, seenAt: ReadonlyMap<string, number>, log: AttentionEvent[], now: number): Set<string> {
|
||
const since = now - PULSE_MS;
|
||
const out = new Set<string>();
|
||
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<AttentionPrefs>;
|
||
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<string>();
|
||
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<string>;
|
||
visibleCallIds: ReadonlySet<string>;
|
||
/** 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<AttentionState | null>(null);
|
||
const keyRef = useRef<string | null>(resetKey);
|
||
const [log, setLog] = useState<AttentionEvent[]>([]);
|
||
const [seenAt, setSeenAt] = useState<Map<string, number>>(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<string, number>();
|
||
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<string, string[]>();
|
||
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 };
|
||
}
|