Live wall attention cues: NEW lane, pin pulse, opt-in tone and notifications (#238) #276

Merged
logan merged 2 commits from feat/238-attention-cues into main 2026-10-05 03:53:42 -04:00
Owner

Attention cues on the live wall, client-only phase of #238.

What

  • lib/attention.ts — pure diffAttention(prev, incidents, calls, keywords): Major opened, severity escalated (known rank up; unrated→Major counts), pursuit path gained a stop (new non-ghost geocoded call newer than the incident's newest known stop), priority keyword in a new transcript (default MAYDAY, 10-13, shots fired, officer down; whole-word, "10 13"/"1013" also match). First snapshot = silent baseline; calls baseline separately on their first non-empty snapshot; old calls pulled in by selection are not news.
  • Filter honesty: the diff runs over all active incidents (so widening a filter never looks like a burst of new Majors) and every cue, lane row and pulse is gated on the filtered list at emission time (isVisibleEvent). A filtered-out incident's call never cues, even if the call itself passes a loose filter.
  • components/live/NewLane.tsx — "NEW · last 15 min" overlay right of the zoom stack, capped above MapView's rail (top-[9.5rem]); mobile = compact chip that expands. Carries the Gate A inline notice.
  • Pins pulse for 2 min via MapView's existing pulseIncidentIds (no MapView edit).
  • components/live/AttentionControls.tsx — "Cues" popover in the wall bar: tone OFF by default (enabling click creates/resumes the AudioContext and beeps once; after reload the first gesture re-arms, the button says so until then), browser notifications via Notification.requestPermission() fired only while document.hidden, body = incident title + MACHINE_OUTPUT_NOTICE_TEXT; no transcript text in notifications. Prefs + keywords in localStorage behind try/catch.

Outside the planned file list (small, non-breaking)

  • lib/useIncidents.ts — new useActiveIncidentsState() returning {incidents, loaded} (loaded = first non-cache server snapshot); useActiveIncidents() is now a wrapper. Without it the baseline would be taken against the empty pre-load list and page open would cue every open Major.
  • components/ui/MachineOutputNotice.tsx — exports MACHINE_OUTPUT_NOTICE_TEXT (exact existing short wording) so the notification body reuses it rather than copying it.

Verification

  • tsc --noEmit (fresh scratch copy): exit 0
  • c2-core pytest (no backend change): 934 passed, 4 warnings
  • Ad-hoc node checks on compiled lib/attention.ts: baseline silent, new Major cues, new minor does not, escalation, keyword whole-word incl. "10 13" and not "210-135", filtered-out incident never visible, older stop not news, new stop cues, late call baseline silent, lane/pulse filtered and pulse expires at 2 min. (No JS test harness in the repo, so these are not committed.)
  • Not run in a browser: tone/notification behaviour and lane placement are unverified against a live wall.

Closes #238

🤖 Generated with Claude Code

Attention cues on the live wall, client-only phase of #238. **What** - `lib/attention.ts` — pure `diffAttention(prev, incidents, calls, keywords)`: Major opened, severity escalated (known rank up; unrated→Major counts), pursuit path gained a stop (new non-ghost geocoded call newer than the incident's newest known stop), priority keyword in a new transcript (default MAYDAY, 10-13, shots fired, officer down; whole-word, "10 13"/"1013" also match). First snapshot = silent baseline; calls baseline separately on their first non-empty snapshot; old calls pulled in by selection are not news. - **Filter honesty:** the diff runs over *all* active incidents (so widening a filter never looks like a burst of new Majors) and every cue, lane row and pulse is gated on the *filtered* list at emission time (`isVisibleEvent`). A filtered-out incident's call never cues, even if the call itself passes a loose filter. - `components/live/NewLane.tsx` — "NEW · last 15 min" overlay right of the zoom stack, capped above MapView's rail (top-[9.5rem]); mobile = compact chip that expands. Carries the Gate A inline notice. - Pins pulse for 2 min via MapView's existing `pulseIncidentIds` (no MapView edit). - `components/live/AttentionControls.tsx` — "Cues" popover in the wall bar: tone OFF by default (enabling click creates/resumes the AudioContext and beeps once; after reload the first gesture re-arms, the button says so until then), browser notifications via `Notification.requestPermission()` fired only while `document.hidden`, body = incident title + `MACHINE_OUTPUT_NOTICE_TEXT`; no transcript text in notifications. Prefs + keywords in localStorage behind try/catch. **Outside the planned file list (small, non-breaking)** - `lib/useIncidents.ts` — new `useActiveIncidentsState()` returning `{incidents, loaded}` (loaded = first non-cache server snapshot); `useActiveIncidents()` is now a wrapper. Without it the baseline would be taken against the empty pre-load list and page open would cue every open Major. - `components/ui/MachineOutputNotice.tsx` — exports `MACHINE_OUTPUT_NOTICE_TEXT` (exact existing short wording) so the notification body reuses it rather than copying it. **Verification** - tsc --noEmit (fresh scratch copy): exit 0 - c2-core pytest (no backend change): 934 passed, 4 warnings - Ad-hoc node checks on compiled `lib/attention.ts`: baseline silent, new Major cues, new minor does not, escalation, keyword whole-word incl. "10 13" and not "210-135", filtered-out incident never visible, older stop not news, new stop cues, late call baseline silent, lane/pulse filtered and pulse expires at 2 min. (No JS test harness in the repo, so these are not committed.) - Not run in a browser: tone/notification behaviour and lane placement are unverified against a live wall. Closes #238 🤖 Generated with [Claude Code](https://claude.com/claude-code)
logan added 1 commit 2026-10-05 03:45:41 -04:00
- 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>
logan added 1 commit 2026-10-05 03:52:20 -04:00
- 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>
Author
Owner

Review (not merged) — pushed 292c4ff with fixes:

  • NEW lane: Gate A notice was the last item inside the scroll list, so with >3 entries it scrolled out of view; now pinned below the rows (rail rule, #46). Rows text-xs for wall reading.
  • Mobile chip sat at MapView's top-3 centre slot and covered the "Live" chip; moved under it, right of fit-all.
  • Keyword cues fire on the transcript, usually before correlation links the call, so the lane row stayed unclickable and the pin never pulsed; events now re-resolve to the incident from current calls.
  • Notifications: mobile Chrome grants permission but throws on new Notification() (no SW) — the control showed "notify" and never delivered. Enabling now sends one confirmation notification and falls back to "unsupported" if it throws.
  • useActiveIncidentsState: includeMetadataChanges so loaded flips on a metadata-only server sync (quiet org / offline start).

Filed #279: MapView rebuilds incident icons every clock tick (1s), so the 1.8s pulse ring restarts before completing — MapView-owned, out of scope here.

Still unverified in a browser: tone, notifications, lane placement. tsc exit=0; c2-core 934 passed.

Review (not merged) — pushed 292c4ff with fixes: - NEW lane: Gate A notice was the last item inside the scroll list, so with >3 entries it scrolled out of view; now pinned below the rows (rail rule, #46). Rows text-xs for wall reading. - Mobile chip sat at MapView's top-3 centre slot and covered the "Live" chip; moved under it, right of fit-all. - Keyword cues fire on the transcript, usually before correlation links the call, so the lane row stayed unclickable and the pin never pulsed; events now re-resolve to the incident from current calls. - Notifications: mobile Chrome grants permission but throws on `new Notification()` (no SW) — the control showed "notify" and never delivered. Enabling now sends one confirmation notification and falls back to "unsupported" if it throws. - `useActiveIncidentsState`: `includeMetadataChanges` so `loaded` flips on a metadata-only server sync (quiet org / offline start). Filed #279: MapView rebuilds incident icons every clock tick (1s), so the 1.8s pulse ring restarts before completing — MapView-owned, out of scope here. Still unverified in a browser: tone, notifications, lane placement. tsc exit=0; c2-core 934 passed.
logan merged commit 3c5496acb1 into main 2026-10-05 03:53:42 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logan/server-26#276