Merge pull request 'Live wall attention cues: NEW lane, pin pulse, opt-in tone and notifications (#238)' (#276) from feat/238-attention-cues into main
This commit was merged in pull request #276.
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
"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(); }
|
||||
}
|
||||
if (p === "granted") {
|
||||
// Confirm with one notification (as the tone confirms with a beep). It
|
||||
// doubles as the support check: mobile Chrome grants permission but
|
||||
// throws on `new Notification()` without a service worker, which would
|
||||
// otherwise leave a switched-on control that never delivers anything.
|
||||
try {
|
||||
new Notification("DRB · Notifications on", {
|
||||
body: `Cues will appear here while the live wall tab is hidden.\n${MACHINE_OUTPUT_NOTICE_TEXT}`,
|
||||
tag: "drb-attn:on",
|
||||
});
|
||||
} catch {
|
||||
p = "unsupported";
|
||||
}
|
||||
}
|
||||
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 show notifications from a web page (mobile browsers often only allow them from installed apps).</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* "NEW · last 15 min" (server-26#238). The rail is worst-first, so a fresh
|
||||
* minor that is escalating sits under stale Majors; this lane is the
|
||||
* recency view of the same FILTERED incidents, newest first, with why each
|
||||
* one is here ("New", "↑ Major", "+1 stop", a priority keyword).
|
||||
*
|
||||
* Desktop: overlay at the map's top-left, right of the zoom stack and above
|
||||
* MapView's rail (the rail starts at top-[9.5rem], so the lane is capped
|
||||
* just short of it and its rows scroll). The Gate A notice is pinned below
|
||||
* the rows, never inside the scroll area — same rule as the rail (#46).
|
||||
* Mobile: a compact chip under MapView's centred "Live" chip (same top-3
|
||||
* centre slot would cover it), right of the fit-all button; it expands.
|
||||
* Hidden when there is nothing new.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import type { LaneEntry } from "@/lib/attention";
|
||||
import { SEVERITY_COLORS, isKnownSeverity } from "@/lib/severity";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
|
||||
function ago(at: number, now: number): string {
|
||||
const m = Math.max(0, Math.floor((now - at) / 60_000));
|
||||
return m < 1 ? "now" : `${m}m`;
|
||||
}
|
||||
|
||||
function Row({ e, now, onSelect }: { e: LaneEntry; now: number; onSelect: (id: string) => void }) {
|
||||
const color = isKnownSeverity(e.severity) ? SEVERITY_COLORS[e.severity] : "var(--ink-muted)";
|
||||
const body = (
|
||||
<>
|
||||
<span className="w-1 self-stretch rounded-full shrink-0" style={{ background: color }} aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-ink">{e.title}</span>
|
||||
<span className="shrink-0 px-1 rounded bg-accent/15 text-accent font-semibold">{e.reason}</span>
|
||||
<span className="shrink-0 tabular-nums text-ink-muted">{ago(e.at, now)}</span>
|
||||
</>
|
||||
);
|
||||
const cls = "w-full flex items-center gap-1.5 px-1.5 py-1 rounded text-xs text-left";
|
||||
return (
|
||||
<li>
|
||||
{e.incidentId ? (
|
||||
<button type="button" className={`${cls} hover:bg-raised`} onClick={() => onSelect(e.incidentId!)}
|
||||
title={`${e.title} — ${e.reason}`}>
|
||||
{body}
|
||||
</button>
|
||||
) : (
|
||||
<div className={cls} title={`${e.title} — ${e.reason} (call not linked to an incident yet)`}>{body}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function NewLane({ entries, now, onSelect }: { entries: LaneEntry[]; now: number; onSelect: (id: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!entries.length) return null;
|
||||
const notice = <MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start px-1.5 pt-1" />;
|
||||
return (
|
||||
<>
|
||||
<section aria-label="New in the last 15 minutes"
|
||||
className="absolute top-3 left-14 z-[1002] hidden md:flex flex-col w-56 max-h-[8.5rem] bg-surface/90 backdrop-blur-sm border border-accent/60 rounded-lg shadow pointer-events-auto">
|
||||
<h2 className="shrink-0 px-2 pt-1.5 text-[10px] font-semibold uppercase tracking-wide text-accent">
|
||||
New · last 15 min <span className="text-ink-muted font-normal">({entries.length})</span>
|
||||
</h2>
|
||||
<ul className="min-h-0 overflow-y-auto px-0.5">
|
||||
{entries.map((e) => <Row key={e.key} e={e} now={now} onSelect={onSelect} />)}
|
||||
</ul>
|
||||
<div className="shrink-0 border-t border-line pb-1">{notice}</div>
|
||||
</section>
|
||||
|
||||
<div className="absolute top-[5.25rem] left-14 z-[1002] md:hidden flex flex-col items-start">
|
||||
<button type="button" aria-expanded={open} onClick={() => setOpen((v) => !v)}
|
||||
className="bg-surface/95 border border-accent/60 rounded-full px-3 py-1 text-xs text-accent font-semibold whitespace-nowrap shadow">
|
||||
● {entries.length} new · 15 min {open ? "▴" : "▾"}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mt-1 w-64 max-w-[calc(100vw-4.5rem)] bg-surface/95 border border-line rounded-lg shadow p-0.5 flex flex-col">
|
||||
<ul className="max-h-40 overflow-y-auto">
|
||||
{entries.map((e) => (
|
||||
<Row key={e.key} e={e} now={now} onSelect={(id) => { setOpen(false); onSelect(id); }} />
|
||||
))}
|
||||
</ul>
|
||||
<div className="shrink-0 border-t border-line pb-1">{notice}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user