- lib/attention.ts: pure snapshot diff (Major opened, severity escalated, pursuit path gained a stop, priority keyword in a new transcript) with a silent baseline on page open; calls baseline separately on their first non-empty snapshot. Diffed over all active incidents so widening a filter is never "new"; emitted, laned and pulsed only for the FILTERED view. - components/live/NewLane.tsx: "NEW · last 15 min" overlay above the rail (desktop) / compact chip (mobile), Gate A notice included. - components/live/AttentionControls.tsx: opt-in Web Audio tone (enabling click unlocks audio) and Web Notifications while the tab is hidden, body carries the machine-output notice; prefs + keywords in localStorage. - LiveView wires it and passes pulseIncidentIds to MapView (2 min pulse). - useIncidents: useActiveIncidentsState exposes a loaded flag (first server snapshot) so the baseline never sees an empty pre-load list. - MachineOutputNotice exports MACHINE_OUTPUT_NOTICE_TEXT (same wording). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
240 lines
11 KiB
TypeScript
240 lines
11 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Attention cues (server-26#238) — the opt-in half: an audible tone and
|
|
* browser notifications, both OFF by default.
|
|
*
|
|
* Tone: Web Audio beep, no asset. Browsers only let audio start from a user
|
|
* gesture, so the AudioContext is created by the click that turns the tone
|
|
* on (and that click plays one confirmation beep). After a reload with the
|
|
* tone already on, the first click/key anywhere on the page re-arms it; until
|
|
* then nothing plays and the button says so.
|
|
*
|
|
* Notifications: Notification.requestPermission() on click; fired only while
|
|
* the tab is hidden (a visible wall already shows the lane and pulse). The
|
|
* body ends with the Gate A machine-output notice — the title it carries is
|
|
* pipeline output. No transcript text goes into a notification.
|
|
*/
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { DEFAULT_KEYWORDS, parseKeywords, type AttentionEvent, type AttentionPrefs } from "@/lib/attention";
|
|
import { MACHINE_OUTPUT_NOTICE_TEXT } from "@/components/ui/MachineOutputNotice";
|
|
|
|
const TONE_GAP_MS = 4_000;
|
|
const MAX_NOTIFICATIONS = 3;
|
|
|
|
// ── Audio (one context per page, only ever created inside a user gesture) ────
|
|
let audioCtx: AudioContext | null = null;
|
|
|
|
function createAudio(): AudioContext | null {
|
|
try {
|
|
if (!audioCtx) {
|
|
const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
|
if (!AC) return null;
|
|
audioCtx = new AC();
|
|
}
|
|
if (audioCtx.state === "suspended") void audioCtx.resume().catch(() => undefined);
|
|
return audioCtx;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** n short beeps, alternating 880/660 Hz. Silent unless a gesture already started the context. */
|
|
function beep(n: number) {
|
|
const a = audioCtx;
|
|
if (!a || a.state !== "running") return;
|
|
try {
|
|
for (let i = 0; i < n; i++) {
|
|
const t0 = a.currentTime + i * 0.22;
|
|
const osc = a.createOscillator();
|
|
const gain = a.createGain();
|
|
osc.type = "sine";
|
|
osc.frequency.value = i % 2 ? 660 : 880;
|
|
gain.gain.setValueAtTime(0.0001, t0);
|
|
gain.gain.exponentialRampToValueAtTime(0.25, t0 + 0.02);
|
|
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.18);
|
|
osc.connect(gain).connect(a.destination);
|
|
osc.start(t0);
|
|
osc.stop(t0 + 0.2);
|
|
}
|
|
} catch { /* audio unavailable */ }
|
|
}
|
|
|
|
type NotifyState = "unsupported" | "default" | "granted" | "denied";
|
|
|
|
function notifyState(): NotifyState {
|
|
if (typeof window === "undefined" || !("Notification" in window)) return "unsupported";
|
|
return Notification.permission as NotifyState;
|
|
}
|
|
|
|
const HEADLINE: Record<AttentionEvent["kind"], string> = {
|
|
major: "Major incident",
|
|
escalated: "Severity escalated",
|
|
stop: "Pursuit path: new stop",
|
|
keyword: "Priority keyword",
|
|
};
|
|
|
|
/**
|
|
* Fires the opted-in cues for a batch of in-view events. Returns `fire` (pass
|
|
* to useAttention's onEvents) and the live audio state for the controls.
|
|
*/
|
|
export function useAttentionCues(prefs: AttentionPrefs, onOpenIncident: (id: string) => void) {
|
|
const [armed, setArmed] = useState(false);
|
|
const lastTone = useRef(0);
|
|
const prefsRef = useRef(prefs);
|
|
prefsRef.current = prefs;
|
|
const openRef = useRef(onOpenIncident);
|
|
openRef.current = onOpenIncident;
|
|
|
|
// Tone on: track the context's state. Already on from an earlier visit →
|
|
// the next gesture anywhere on the page creates/resumes the context.
|
|
useEffect(() => {
|
|
if (!prefs.tone) { setArmed(false); return; }
|
|
const sync = () => setArmed(audioCtx?.state === "running");
|
|
const arm = () => {
|
|
const a = createAudio();
|
|
if (a) { a.onstatechange = sync; a.resume().then(sync, () => undefined); }
|
|
};
|
|
if (audioCtx) { audioCtx.onstatechange = sync; sync(); }
|
|
window.addEventListener("pointerdown", arm, { once: true });
|
|
window.addEventListener("keydown", arm, { once: true });
|
|
return () => {
|
|
window.removeEventListener("pointerdown", arm);
|
|
window.removeEventListener("keydown", arm);
|
|
};
|
|
}, [prefs.tone]);
|
|
|
|
const fire = useCallback((events: AttentionEvent[]) => {
|
|
if (!events.length) return;
|
|
const p = prefsRef.current;
|
|
if (p.tone && Date.now() - lastTone.current > TONE_GAP_MS) {
|
|
lastTone.current = Date.now();
|
|
// Three beeps for anything reaching Major or a priority keyword, one otherwise.
|
|
beep(events.some((e) => e.kind === "major" || e.kind === "keyword" || e.label.endsWith("Major")) ? 3 : 1);
|
|
}
|
|
if (!p.notify || typeof document === "undefined" || !document.hidden || notifyState() !== "granted") return;
|
|
const send = (title: string, body: string, tag: string, incidentId: string | null) => {
|
|
try {
|
|
const n = new Notification(title, { body: `${body}\n${MACHINE_OUTPUT_NOTICE_TEXT}`, tag });
|
|
n.onclick = () => {
|
|
window.focus();
|
|
if (incidentId) openRef.current(incidentId);
|
|
n.close();
|
|
};
|
|
} catch { /* e.g. mobile Chrome needs a service worker — silently no notification */ }
|
|
};
|
|
for (const e of events.slice(0, MAX_NOTIFICATIONS)) {
|
|
const head = e.kind === "keyword" ? `${HEADLINE.keyword}: ${e.label}` : e.kind === "escalated" ? `${HEADLINE.escalated}: ${e.label.replace(/^↑\s*/, "")}` : HEADLINE[e.kind];
|
|
send(`DRB · ${head}`, e.title, `drb-attn:${e.incidentIds[0] ?? e.id}`, e.incidentIds[0] ?? null);
|
|
}
|
|
if (events.length > MAX_NOTIFICATIONS) {
|
|
send("DRB · More activity", `${events.length - MAX_NOTIFICATIONS} more on the live wall.`, "drb-attn:more", null);
|
|
}
|
|
}, []);
|
|
|
|
return { fire, armed };
|
|
}
|
|
|
|
interface Props {
|
|
prefs: AttentionPrefs;
|
|
onChange: (p: AttentionPrefs) => void;
|
|
armed: boolean;
|
|
}
|
|
|
|
export function AttentionControls({ prefs, onChange, armed }: Props) {
|
|
const [open, setOpen] = useState(false);
|
|
const [perm, setPerm] = useState<NotifyState>("default");
|
|
const [kwDraft, setKwDraft] = useState(prefs.keywords.join(", "));
|
|
const boxRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => { setPerm(notifyState()); }, [open]);
|
|
useEffect(() => { setKwDraft(prefs.keywords.join(", ")); }, [prefs.keywords]);
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onDown = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); };
|
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
|
|
document.addEventListener("mousedown", onDown);
|
|
document.addEventListener("keydown", onKey);
|
|
return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); };
|
|
}, [open]);
|
|
|
|
const toggleTone = () => {
|
|
if (prefs.tone) { onChange({ ...prefs, tone: false }); return; }
|
|
// This click is the gesture that unlocks audio; confirm with one beep.
|
|
const a = createAudio();
|
|
onChange({ ...prefs, tone: true });
|
|
if (a) a.resume().then(() => beep(1), () => undefined);
|
|
};
|
|
|
|
const toggleNotify = async () => {
|
|
if (prefs.notify) { onChange({ ...prefs, notify: false }); return; }
|
|
if (notifyState() === "unsupported") { setPerm("unsupported"); return; }
|
|
let p: NotifyState = notifyState();
|
|
if (p === "default") {
|
|
try { p = (await Notification.requestPermission()) as NotifyState; } catch { p = notifyState(); }
|
|
}
|
|
setPerm(p);
|
|
if (p === "granted") onChange({ ...prefs, notify: true });
|
|
};
|
|
|
|
const commitKeywords = () => {
|
|
const next = parseKeywords(kwDraft);
|
|
if (next.join("\u0001") !== prefs.keywords.join("\u0001")) onChange({ ...prefs, keywords: next });
|
|
else setKwDraft(prefs.keywords.join(", "));
|
|
};
|
|
|
|
const notifyOn = prefs.notify && perm !== "denied" && perm !== "unsupported";
|
|
const summary = [prefs.tone ? (armed ? "tone" : "tone (click to arm)") : null, notifyOn ? "notify" : null].filter(Boolean).join(" · ") || "off";
|
|
const on = prefs.tone || notifyOn;
|
|
|
|
return (
|
|
<div ref={boxRef} className="relative">
|
|
<button type="button" aria-expanded={open} aria-haspopup="dialog" onClick={() => setOpen((v) => !v)}
|
|
title="Attention cues: an audible tone and browser notifications for new Majors, escalations, pursuit stops and priority keywords"
|
|
className={`px-2 py-0.5 rounded border whitespace-nowrap ${on ? "border-accent text-accent" : "border-line text-ink-2 hover:text-ink"}`}>
|
|
Cues: {summary}
|
|
</button>
|
|
{open && (
|
|
<div role="dialog" aria-label="Attention cues"
|
|
className="absolute right-0 top-full mt-1 z-[1100] w-72 max-w-[calc(100vw-2rem)] bg-surface border border-line rounded-lg shadow-lg p-3 space-y-3 text-xs text-ink-2">
|
|
<p className="text-ink-muted leading-snug">
|
|
Cues fire on: a Major opened, severity escalated, a pursuit path gaining a stop, or a priority keyword in a new
|
|
call. Only for incidents the current filter shows.
|
|
</p>
|
|
<label className="flex items-start gap-2 cursor-pointer">
|
|
<input type="checkbox" className="mt-0.5" checked={prefs.tone} onChange={toggleTone} />
|
|
<span>
|
|
<span className="text-ink font-medium">Sound a tone</span>
|
|
{prefs.tone && !armed && (
|
|
<span className="block text-ink-muted">Click anywhere on the page to arm it — browsers block sound until you do.</span>
|
|
)}
|
|
</span>
|
|
</label>
|
|
<label className="flex items-start gap-2 cursor-pointer">
|
|
<input type="checkbox" className="mt-0.5" checked={notifyOn} disabled={perm === "unsupported"} onChange={() => { void toggleNotify(); }} />
|
|
<span>
|
|
<span className="text-ink font-medium">Browser notifications while this tab is hidden</span>
|
|
{perm === "denied" && <span className="block text-ink-muted">Blocked in this browser's site settings — allow notifications there first.</span>}
|
|
{perm === "unsupported" && <span className="block text-ink-muted">This browser doesn't support page notifications.</span>}
|
|
</span>
|
|
</label>
|
|
<div>
|
|
<label htmlFor="attn-kw" className="block text-ink font-medium mb-1">Priority keywords</label>
|
|
<input id="attn-kw" type="text" value={kwDraft} onChange={(e) => setKwDraft(e.target.value)} onBlur={commitKeywords}
|
|
onKeyDown={(e) => { if (e.key === "Enter") commitKeywords(); }}
|
|
placeholder="comma-separated"
|
|
className="w-full px-2 py-1 rounded border border-line bg-raised text-ink" />
|
|
<div className="mt-1 flex items-center justify-between text-ink-muted">
|
|
<span>Matched against new call transcripts.</span>
|
|
<button type="button" className="underline hover:text-ink"
|
|
onClick={() => onChange({ ...prefs, keywords: [...DEFAULT_KEYWORDS] })}>
|
|
Reset
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|