Files
Logan CusanoandClaude Opus 5.5 292c4fffd9 Attention cues review fixes (#238)
- 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>
2026-10-05 03:52:02 -04:00

420 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };
}