diff --git a/drb-frontend/components/LiveView.tsx b/drb-frontend/components/LiveView.tsx index 3007867..1d2a03f 100644 --- a/drb-frontend/components/LiveView.tsx +++ b/drb-frontend/components/LiveView.tsx @@ -24,13 +24,18 @@ * and merges them in, so a long incident's path and spine are complete. An * `#inc=` that is not active is fetched by id (resolved → banner; missing or * another org → "not found"). The scrubber hatches time the window didn't load. + * + * Attention cues (server-26#238): lib/attention.ts diffs successive snapshots + * (Major opened, severity up, pursuit path stop, priority keyword) after a + * silent baseline. Cues, the NEW lane and pin pulses only ever cover the + * FILTERED incidents; tone and notifications are opt-in (AttentionControls). */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import dynamic from "next/dynamic"; import { useNodes } from "@/lib/useNodes"; import { useSystems } from "@/lib/useSystems"; import { useActiveCalls, useCalls, useCallsByIncident } from "@/lib/useCalls"; -import { useActiveIncidents, useIncident } from "@/lib/useIncidents"; +import { useActiveIncidentsState, useIncident } from "@/lib/useIncidents"; import { useAuth } from "@/components/AuthProvider"; import { useAircraft } from "@/lib/useAircraft"; import { useVessels } from "@/lib/useVessels"; @@ -54,6 +59,9 @@ import { CameraDrill } from "@/components/live/CameraDrill"; import { IncidentPanel, RegionPanel } from "@/components/live/WallPanels"; import { TruthStrip } from "@/components/live/TruthStrip"; import { usePipelineHealth } from "@/lib/usePipelineHealth"; +import { DEFAULT_ATTENTION_PREFS, loadAttentionPrefs, saveAttentionPrefs, useAttention, type AttentionPrefs } from "@/lib/attention"; +import { NewLane } from "@/components/live/NewLane"; +import { AttentionControls, useAttentionCues } from "@/components/live/AttentionControls"; const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); @@ -140,7 +148,7 @@ function Kpi({ value, label }: { value: string | number; label: string }) { export function LiveView() { const { nodes, loading: nodesLoading } = useNodes(); const activeCalls = useActiveCalls(); - const activeIncidents = useActiveIncidents(); + const { incidents: activeIncidents, loaded: incidentsLoaded } = useActiveIncidentsState(); // Wide-ish recent window: feeds incident paths, the call spine, the ticker // and the scrubber's density bars. Real recent calls, not a fixture. const { calls: recentCalls, loading: callsLoading, error: callsError, hasMore: windowFull } = useCalls(RECENT_CALLS); @@ -399,6 +407,32 @@ export function LiveView() { // ── Navigation ──────────────────────────────────────────────────────────── const selectIncident = useCallback((id: string) => patchLink({ inc: id, cam: null, tour: false }), [patchLink]); + + // ── Attention cues (server-26#238) ──────────────────────────────────────── + // Diffed over everything loaded (so widening the filter is never "new"); + // emitted, laned and pulsed only for what the filter shows. + const [attnPrefs, setAttnPrefs] = useState(DEFAULT_ATTENTION_PREFS); + useEffect(() => { setAttnPrefs(loadAttentionPrefs()); }, []); + const changeAttnPrefs = useCallback((p: AttentionPrefs) => { setAttnPrefs(p); saveAttentionPrefs(p); }, []); + const cues = useAttentionCues(attnPrefs, selectIncident); + const attentionCalls = useMemo(() => { + if (!incidentCalls.length) return recentCalls; + const have = new Set(recentCalls.map((c) => c.call_id)); + const extra = incidentCalls.filter((c) => !have.has(c.call_id)); + return extra.length ? [...recentCalls, ...extra] : recentCalls; + }, [recentCalls, incidentCalls]); + const visibleCallIds = useMemo(() => new Set(filtered.calls.map((c) => c.call_id)), [filtered.calls]); + const attention = useAttention({ + ready: incidentsLoaded && !!orgId, + resetKey: orgId ?? null, + incidents: activeIncidents, + calls: attentionCalls, + keywords: attnPrefs.keywords, + visible: filtered.incidents, + visibleIds: filtered.ids, + visibleCallIds, + onEvents: cues.fire, + }); const openCamera = useCallback((c: Camera) => patchLink({ cam: c.id, tour: false }), [patchLink]); const stepUp = useCallback(() => { if (link.cam) patchLink({ cam: null, tour: false }); @@ -502,6 +536,7 @@ export function LiveView() { className="px-2 py-0.5 rounded border border-line text-ink-2 hover:text-ink whitespace-nowrap" aria-live="polite"> {copied === "ok" ? "Link copied" : copied === "fail" ? "Copy failed — use the address bar" : "Copy view link"} + {clock} @@ -539,6 +574,7 @@ export function LiveView() { onOpenCamera={openCamera} onAir={onAir} camCountByIncident={camCountByIncident} + pulseIncidentIds={attention.pulseIds} key={mapGen} initialLayers={mapState.layers} initialBase={mapState.base} @@ -548,6 +584,10 @@ export function LiveView() { )} + {!nodesLoading && !callsLoading && mapState && ( + + )} + {/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's Activation screen handles the zero-node case). */} {configuredButQuiet && ( diff --git a/drb-frontend/components/live/AttentionControls.tsx b/drb-frontend/components/live/AttentionControls.tsx new file mode 100644 index 0000000..2835c8b --- /dev/null +++ b/drb-frontend/components/live/AttentionControls.tsx @@ -0,0 +1,239 @@ +"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 = { + 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("default"); + const [kwDraft, setKwDraft] = useState(prefs.keywords.join(", ")); + const boxRef = useRef(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 ( +
+ + {open && ( +
+

+ 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. +

+ + +
+ + 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" /> +
+ Matched against new call transcripts. + +
+
+
+ )} +
+ ); +} diff --git a/drb-frontend/components/live/NewLane.tsx b/drb-frontend/components/live/NewLane.tsx new file mode 100644 index 0000000..3b8f958 --- /dev/null +++ b/drb-frontend/components/live/NewLane.tsx @@ -0,0 +1,82 @@ +"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 scrolls). Mobile: a compact chip that 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 = ( + <> +