diff --git a/docs/plans/2026-05-21-pager-sensor-display-revamp.md b/docs/plans/2026-05-21-pager-sensor-display-revamp.md
new file mode 100644
index 0000000..ef4922d
--- /dev/null
+++ b/docs/plans/2026-05-21-pager-sensor-display-revamp.md
@@ -0,0 +1,1104 @@
+# Pager & 433 Sensor Display Revamp — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace the plain card feed for Pager and 433 Sensor modes with a source-directory split-view and a per-device station dashboard, each with a toggle back to the classic feed.
+
+**Architecture:** `#pagerDirectoryView` (left panel) + `#output` (right feed) share a flex wrapper for the pager split; `#sensorDashboardView` replaces `#output` in sensor dashboard mode while `#output` continues receiving cards silently. Two new IIFE components (`PagerDirectory`, `SensorDashboard`) are notified via one-line hooks in the existing `addMessage()` and `addSensorReading()` functions. No backend changes.
+
+**Tech Stack:** Vanilla JS (IIFE pattern), CSS custom properties from `variables.css`, SVG sparklines, Flask/Jinja2 templates, pytest for template structure tests.
+
+---
+
+## File Map
+
+| Action | Path | Responsibility |
+|--------|------|----------------|
+| Create | `static/css/components/pager-directory.css` | Flex wrap layout, directory panel, entry rows, highlight, toggle buttons |
+| Create | `static/css/components/sensor-dashboard.css` | Station card grid, flash animations, sparkline, state-only devices |
+| Create | `static/js/components/pager-directory.js` | `PagerDirectory` IIFE — address tracking, highlight, show/hide, reset |
+| Create | `static/js/components/sensor-dashboard.js` | `SensorDashboard` IIFE — station cards, sparklines, flash, show/hide, reset |
+| Modify | `templates/index.html` | Wrap `#output`, add view containers, toggle buttons, ``/`
+```
+Add immediately after:
+```html
+
+
+```
+
+- [ ] **Step 8: Run tests to confirm they pass**
+
+```
+pytest tests/test_app.py::test_pager_directory_elements_present tests/test_app.py::test_sensor_dashboard_elements_present -v
+```
+
+Expected: 2 PASSED.
+
+- [ ] **Step 9: Smoke-test the page loads without JS errors**
+
+```
+python intercept.py
+```
+
+Open `http://localhost:5000` in a browser. Check the DevTools console — there should be no JS errors (the new script tags reference files that don't exist yet, but that causes a network 404, not a console error that breaks the page).
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add templates/index.html tests/test_app.py
+git commit -m "feat: add HTML scaffolding for pager directory and sensor dashboard views"
+```
+
+---
+
+## Task 2: Pager directory CSS
+
+**Files:**
+- Create: `static/css/components/pager-directory.css`
+
+- [ ] **Step 1: Create the CSS file**
+
+Create `static/css/components/pager-directory.css` with this exact content:
+
+```css
+/* ============================================================
+ Signal View Wrap — flex container for split-panel layouts
+ ============================================================ */
+#signalViewWrap {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+/* Feed column — wraps feed header + #output, fills remaining space */
+.pdir-feed-col {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+/* Feed header strip — shown in directory mode above the message list */
+.pdir-feed-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 4px 10px;
+ background: var(--bg-card);
+ border-bottom: 1px solid var(--border-color);
+ font-family: var(--font-mono);
+ font-size: var(--text-xs);
+ color: var(--text-secondary);
+ flex-shrink: 0;
+}
+
+.pdir-clear-btn {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+ font-size: var(--text-xs);
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: var(--radius-sm);
+ transition: color var(--transition-fast);
+}
+.pdir-clear-btn:hover { color: var(--text-dim); }
+
+/* ---- Directory panel (left side of split) ---- */
+.pdir-panel {
+ width: 200px;
+ flex-shrink: 0;
+ border-right: 1px solid var(--border-color);
+ flex-direction: column;
+ overflow: hidden;
+ background: var(--bg-secondary);
+ font-family: var(--font-mono);
+}
+
+.pdir-header {
+ padding: 6px 10px;
+ font-size: var(--text-xs);
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ border-bottom: 1px solid var(--border-color);
+ background: var(--bg-card);
+ flex-shrink: 0;
+}
+
+.pdir-entries {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+/* ---- Individual address entry ---- */
+.pdir-entry {
+ padding: 7px 10px;
+ border-bottom: 1px solid rgba(var(--accent-cyan-rgb), 0.04);
+ cursor: pointer;
+ position: relative;
+ transition: background var(--transition-fast);
+}
+.pdir-entry:hover { background: var(--bg-tertiary); }
+.pdir-entry--active {
+ background: rgba(var(--accent-cyan-rgb), 0.06);
+ border-left: 2px solid var(--accent-cyan);
+ padding-left: 8px;
+}
+
+.pdir-entry-top {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ margin-bottom: 3px;
+}
+
+.pdir-proto {
+ font-size: 8px;
+ padding: 1px 4px;
+ border-radius: var(--radius-sm);
+ font-weight: var(--font-bold);
+ flex-shrink: 0;
+}
+.pdir-proto--p { background: rgba(var(--accent-cyan-rgb), 0.15); color: var(--accent-cyan); }
+.pdir-proto--f { background: rgba(143, 123, 214, 0.15); color: var(--accent-purple); }
+
+.pdir-addr {
+ font-size: var(--text-xs);
+ color: var(--text-secondary);
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.pdir-new-dot {
+ display: inline-block;
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: var(--accent-green);
+ flex-shrink: 0;
+ opacity: 0;
+}
+.pdir-new-dot--active {
+ animation: pdir-dot-fade 3s ease-out forwards;
+}
+@keyframes pdir-dot-fade {
+ 0% { opacity: 1; }
+ 85% { opacity: 1; }
+ 100% { opacity: 0; }
+}
+
+.pdir-count { font-size: 9px; color: var(--text-muted); flex-shrink: 0; }
+
+.pdir-bar-wrap { height: 2px; background: var(--bg-tertiary); border-radius: 1px; margin-bottom: 2px; }
+.pdir-bar { height: 2px; background: var(--accent-cyan); border-radius: 1px; transition: width var(--transition-slow); }
+.pdir-bar--flex { background: var(--accent-purple); }
+
+.pdir-age { font-size: 8px; color: var(--text-muted); }
+
+/* ---- Highlight applied to signal-cards in #output ---- */
+.signal-card.pdir-hl {
+ border-left: 2px solid var(--accent-cyan) !important;
+ background: rgba(var(--accent-cyan-rgb), 0.04) !important;
+}
+
+/* ---- View toggle button group (inside .stats) ---- */
+.stats .view-toggle-group { display: none; }
+.stats.active .view-toggle-group { display: flex; }
+
+.view-toggle-group {
+ gap: 2px;
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ padding: 2px;
+ margin-left: 6px;
+}
+
+.view-toggle-btn {
+ padding: 2px 8px;
+ font-size: 9px;
+ font-family: var(--font-mono);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ border: none;
+ border-radius: var(--radius-sm);
+ background: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: background var(--transition-fast), color var(--transition-fast);
+}
+.view-toggle-btn:hover { color: var(--text-dim); }
+.view-toggle-btn--active {
+ background: var(--accent-cyan-dim);
+ color: var(--accent-cyan);
+}
+```
+
+- [ ] **Step 2: Verify styles load without errors**
+
+With `python intercept.py` running, open `http://localhost:5000`, DevTools → Network tab. Confirm `pager-directory.css` returns HTTP 200.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add static/css/components/pager-directory.css
+git commit -m "feat: add pager directory view CSS"
+```
+
+---
+
+## Task 3: PagerDirectory JS component
+
+**Files:**
+- Create: `static/js/components/pager-directory.js`
+
+- [ ] **Step 1: Create the component file**
+
+Create `static/js/components/pager-directory.js` with this exact content:
+
+```javascript
+const PagerDirectory = (function () {
+ 'use strict';
+
+ const STORAGE_KEY = 'pagerView';
+
+ // Map
+ const addresses = new Map();
+ let highlighted = null;
+
+ // ---- Helpers ----
+
+ function esc(str) {
+ return String(str)
+ .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+ }
+
+ function formatAge(ts) {
+ const s = Math.floor((Date.now() - ts) / 1000);
+ if (s < 10) return 'just now';
+ if (s < 60) return `${s}s ago`;
+ return `${Math.floor(s / 60)}m ago`;
+ }
+
+ // ---- Directory rendering ----
+
+ function renderDirectory() {
+ const entriesEl = document.getElementById('pagerDirEntries');
+ const countEl = document.getElementById('pagerDirCount');
+ if (!entriesEl) return;
+
+ const sorted = [...addresses.entries()].sort((a, b) => b[1].count - a[1].count);
+ const maxCount = sorted.length > 0 ? sorted[0][1].count : 1;
+
+ if (countEl) countEl.textContent = sorted.length;
+
+ sorted.forEach(([addr, data]) => {
+ let el = entriesEl.querySelector(`[data-pdir-addr="${CSS.escape(addr)}"]`);
+ const isActive = addr === highlighted;
+ const pct = Math.round((data.count / maxCount) * 100);
+ const isPocsag = data.protocol !== 'flex';
+ const protoClass = isPocsag ? 'pdir-proto--p' : 'pdir-proto--f';
+ const barClass = isPocsag ? '' : 'pdir-bar--flex';
+ const html = `
+
+ ${isPocsag ? 'P' : 'F'}
+ ${esc(addr)}
+
+ ×${data.count}
+
+
+ ${formatAge(data.lastSeen)}
`;
+
+ if (!el) {
+ el = document.createElement('div');
+ el.className = 'pdir-entry';
+ el.dataset.pdirAddr = addr;
+ el.addEventListener('click', () => toggleHighlight(addr));
+ entriesEl.appendChild(el);
+ }
+ el.classList.toggle('pdir-entry--active', isActive);
+ el.innerHTML = html;
+ });
+
+ // Re-order DOM to match sort
+ sorted.forEach(([addr]) => {
+ const el = entriesEl.querySelector(`[data-pdir-addr="${CSS.escape(addr)}"]`);
+ if (el) entriesEl.appendChild(el);
+ });
+ }
+
+ function flashNewDot(addr) {
+ // Find the dot inside this entry after the current render frame
+ setTimeout(() => {
+ const entriesEl = document.getElementById('pagerDirEntries');
+ const entry = entriesEl?.querySelector(`[data-pdir-addr="${CSS.escape(addr)}"]`);
+ const dot = entry?.querySelector('.pdir-new-dot');
+ if (!dot) return;
+ dot.classList.remove('pdir-new-dot--active');
+ void dot.offsetWidth; // force reflow to restart animation
+ dot.classList.add('pdir-new-dot--active');
+ }, 0);
+ }
+
+ // ---- Highlight ----
+
+ function toggleHighlight(addr) {
+ if (highlighted === addr) clearHighlight();
+ else highlight(addr);
+ }
+
+ function highlight(addr) {
+ highlighted = addr;
+ renderDirectory();
+
+ const feedLabel = document.getElementById('pagerFeedLabel');
+ const clearBtn = document.getElementById('pagerClearHighlight');
+ if (feedLabel) feedLabel.textContent = `${addr} highlighted`;
+ if (clearBtn) clearBtn.style.display = 'inline';
+
+ const output = document.getElementById('output');
+ if (!output) return;
+
+ output.querySelectorAll('.signal-card').forEach(card => {
+ card.classList.toggle('pdir-hl', card.dataset.address === addr);
+ });
+
+ const first = output.querySelector(`.signal-card[data-address="${CSS.escape(addr)}"]`);
+ if (first) first.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ }
+
+ function clearHighlight() {
+ highlighted = null;
+ renderDirectory();
+
+ const feedLabel = document.getElementById('pagerFeedLabel');
+ const clearBtn = document.getElementById('pagerClearHighlight');
+ if (feedLabel) feedLabel.textContent = 'All messages';
+ if (clearBtn) clearBtn.style.display = 'none';
+
+ document.getElementById('output')
+ ?.querySelectorAll('.pdir-hl')
+ .forEach(c => c.classList.remove('pdir-hl'));
+ }
+
+ // ---- Public: message hook ----
+
+ function addMessage(msg) {
+ const addr = msg.address;
+ if (!addr) return;
+ const proto = (msg.protocol || '').includes('FLEX') ? 'flex' : 'pocsag';
+ const entry = addresses.get(addr);
+ if (entry) {
+ entry.count++;
+ entry.lastSeen = Date.now();
+ entry.protocol = proto;
+ } else {
+ addresses.set(addr, { count: 1, protocol: proto, lastSeen: Date.now() });
+ }
+ renderDirectory();
+ flashNewDot(addr);
+ // Re-apply highlight class to the newly inserted card (caller inserts it after this hook)
+ if (highlighted === addr) {
+ setTimeout(() => {
+ const output = document.getElementById('output');
+ output?.querySelectorAll(`.signal-card[data-address="${CSS.escape(addr)}"]`)
+ .forEach(c => c.classList.add('pdir-hl'));
+ }, 0);
+ }
+ }
+
+ // ---- Show / hide / reset ----
+
+ function applyViewState(mode) {
+ const dirPanel = document.getElementById('pagerDirectoryView');
+ const feedHeader = document.getElementById('pagerFeedHeader');
+
+ if (mode === 'pager') {
+ const saved = localStorage.getItem(STORAGE_KEY) || 'directory';
+ const isDir = saved === 'directory';
+ if (dirPanel) dirPanel.style.display = isDir ? 'flex' : 'none';
+ if (feedHeader) feedHeader.style.display = isDir ? 'flex' : 'none';
+ _updateToggle(isDir);
+ } else {
+ if (dirPanel) dirPanel.style.display = 'none';
+ if (feedHeader) feedHeader.style.display = 'none';
+ clearHighlight();
+ }
+ }
+
+ function show() {
+ localStorage.setItem(STORAGE_KEY, 'directory');
+ applyViewState('pager');
+ }
+
+ function hide() {
+ localStorage.setItem(STORAGE_KEY, 'feed');
+ applyViewState('pager');
+ }
+
+ function _updateToggle(isDir) {
+ document.getElementById('pagerToggleDir')?.classList.toggle('view-toggle-btn--active', isDir);
+ document.getElementById('pagerToggleFeed')?.classList.toggle('view-toggle-btn--active', !isDir);
+ }
+
+ function reset() {
+ addresses.clear();
+ highlighted = null;
+ const entriesEl = document.getElementById('pagerDirEntries');
+ const countEl = document.getElementById('pagerDirCount');
+ if (entriesEl) entriesEl.innerHTML = '';
+ if (countEl) countEl.textContent = '0';
+ clearHighlight();
+ }
+
+ return { addMessage, highlight, clearHighlight, show, hide, reset, applyViewState };
+})();
+```
+
+- [ ] **Step 2: Verify the script loads without errors**
+
+With the dev server running, open `http://localhost:5000`, switch to pager mode in the UI. Check DevTools console — no errors. Open DevTools console and run `PagerDirectory` — should return the object (not `undefined`).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add static/js/components/pager-directory.js
+git commit -m "feat: add PagerDirectory JS component"
+```
+
+---
+
+## Task 4: Sensor dashboard CSS
+
+**Files:**
+- Create: `static/css/components/sensor-dashboard.css`
+
+- [ ] **Step 1: Create the CSS file**
+
+Create `static/css/components/sensor-dashboard.css` with this exact content:
+
+```css
+/* ============================================================
+ Sensor Dashboard View
+ ============================================================ */
+.sdb-view {
+ flex: 1;
+ overflow-y: auto;
+ min-height: 0;
+ background: var(--bg-primary);
+}
+
+.sdb-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 8px;
+ padding: 10px;
+}
+
+/* ---- Station card ---- */
+.sdb-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ padding: 10px;
+ font-family: var(--font-mono);
+ overflow: hidden;
+}
+
+.sdb-card--new {
+ border-color: rgba(56, 193, 128, 0.3);
+ animation: sdb-slide-in 0.4s ease-out;
+}
+@keyframes sdb-slide-in {
+ from { opacity: 0; transform: translateY(-6px); }
+ to { opacity: 1; transform: none; }
+}
+
+.sdb-card--flash-blue {
+ animation: sdb-flash-blue 0.8s ease-out;
+}
+@keyframes sdb-flash-blue {
+ 0% { background: rgba(var(--accent-cyan-rgb), 0.10); border-color: rgba(var(--accent-cyan-rgb), 0.30); }
+ 100% { background: var(--bg-card); border-color: var(--border-color); }
+}
+
+.sdb-card--flash-purple {
+ animation: sdb-flash-purple 0.8s ease-out;
+}
+@keyframes sdb-flash-purple {
+ 0% { background: rgba(143, 123, 214, 0.10); border-color: rgba(143, 123, 214, 0.30); }
+ 100% { background: var(--bg-card); border-color: var(--border-color); }
+}
+
+/* ---- Card header ---- */
+.sdb-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 8px;
+}
+.sdb-name {
+ font-size: var(--text-xs);
+ color: var(--accent-cyan);
+ font-weight: var(--font-semibold);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 120px;
+}
+.sdb-id { font-size: 8px; color: var(--text-muted); margin-top: 1px; }
+.sdb-age { font-size: 8px; color: var(--text-muted); white-space: nowrap; }
+.sdb-age--fresh { color: var(--accent-green); }
+
+/* ---- Readings grid ---- */
+.sdb-readings {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin-bottom: 8px;
+ min-height: 36px;
+ align-items: flex-end;
+}
+.sdb-reading { text-align: center; min-width: 34px; }
+.sdb-reading-val { font-size: 15px; font-weight: var(--font-bold); line-height: 1; }
+.sdb-reading-unit { font-size: 8px; color: var(--text-muted); }
+.sdb-reading-label { font-size: 8px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 1px; }
+.sdb-no-readings { font-size: 9px; color: var(--text-muted); align-self: center; }
+
+/* ---- State-only device ---- */
+.sdb-state { display: flex; align-items: center; gap: 6px; min-height: 36px; }
+.sdb-state-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
+.sdb-state-dot--on { background: var(--accent-green); box-shadow: 0 0 5px var(--accent-green); }
+.sdb-state-dot--off { background: var(--text-muted); }
+.sdb-state-label { font-size: 9px; color: var(--text-secondary); }
+
+/* ---- Sparkline ---- */
+.sdb-spark { margin-bottom: 6px; }
+.sdb-spark svg { width: 100%; height: 22px; display: block; }
+.sdb-spark-placeholder {
+ height: 22px;
+ background: var(--bg-secondary);
+ border-radius: var(--radius-sm);
+ display: flex;
+ align-items: center;
+ padding: 0 6px;
+ font-size: 8px;
+ color: var(--text-muted);
+}
+
+/* ---- Card footer ---- */
+.sdb-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 8px;
+}
+.sdb-bat--ok { color: var(--accent-green); }
+.sdb-bat--low { color: var(--accent-red); }
+.sdb-snr { color: var(--text-muted); }
+.sdb-freq {
+ padding: 1px 4px;
+ border-radius: var(--radius-sm);
+ background: var(--bg-secondary);
+ color: var(--text-muted);
+}
+```
+
+- [ ] **Step 2: Verify styles load without errors**
+
+With dev server running, DevTools → Network — confirm `sensor-dashboard.css` returns HTTP 200.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add static/css/components/sensor-dashboard.css
+git commit -m "feat: add sensor dashboard view CSS"
+```
+
+---
+
+## Task 5: SensorDashboard JS component
+
+**Files:**
+- Create: `static/js/components/sensor-dashboard.js`
+
+- [ ] **Step 1: Create the component file**
+
+Create `static/js/components/sensor-dashboard.js` with this exact content:
+
+```javascript
+const SensorDashboard = (function () {
+ 'use strict';
+
+ const STORAGE_KEY = 'sensorView';
+ const MAX_SPARK_PTS = 30;
+
+ // Map
+ const devices = new Map();
+
+ // ---- Helpers ----
+
+ function esc(str) {
+ return String(str)
+ .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+ }
+
+ function formatAge(timestamp) {
+ if (!timestamp) return '';
+ const ts = typeof timestamp === 'string' ? new Date(timestamp).getTime() : Number(timestamp);
+ const s = Math.floor((Date.now() - ts) / 1000);
+ if (s < 10) return 'just now';
+ if (s < 60) return `${s}s ago`;
+ return `${Math.floor(s / 60)}m ago`;
+ }
+
+ function isRecent(timestamp) {
+ if (!timestamp) return false;
+ const ts = typeof timestamp === 'string' ? new Date(timestamp).getTime() : Number(timestamp);
+ return (Date.now() - ts) < 10000;
+ }
+
+ // ---- Primary value for sparkline ----
+
+ function getPrimary(msg) {
+ if (msg.temperature !== undefined)
+ return { value: msg.temperature, color: '#f59e0b' };
+ if (msg.pressure !== undefined)
+ return { value: msg.pressure, color: '#a78bfa' };
+ if (msg.wind_speed !== undefined)
+ return { value: msg.wind_speed, color: '#4aa3ff' };
+ return null;
+ }
+
+ function getFlashClass(msg) {
+ return msg.temperature !== undefined ? 'sdb-card--flash-blue' : 'sdb-card--flash-purple';
+ }
+
+ // ---- HTML builders ----
+
+ function buildReadingsHTML(msg) {
+ // State-only device (no continuous numeric field)
+ if (msg.state !== undefined && msg.temperature === undefined
+ && msg.pressure === undefined && msg.wind_speed === undefined) {
+ const raw = String(msg.state);
+ const isOn = raw === '1' || raw === 'true' || raw === 'on' || raw === 'active';
+ return `
+
+ ${esc(raw.toUpperCase())}
+
`;
+ }
+
+ const parts = [];
+ if (msg.temperature !== undefined)
+ parts.push({ val: msg.temperature, unit: `°${msg.temperature_unit || 'C'}`, label: 'Temp', color: '#f59e0b' });
+ if (msg.humidity !== undefined)
+ parts.push({ val: msg.humidity, unit: '%', label: 'Humid', color: '#38bdf8' });
+ if (msg.pressure !== undefined)
+ parts.push({ val: msg.pressure, unit: msg.pressure_unit || 'hPa', label: 'Press', color: '#a78bfa' });
+ if (msg.wind_speed !== undefined)
+ parts.push({ val: msg.wind_speed, unit: msg.wind_unit || 'km/h', label: 'Wind', color: '#4aa3ff' });
+ if (msg.rain !== undefined)
+ parts.push({ val: msg.rain, unit: msg.rain_unit || 'mm', label: 'Rain', color: '#38bdf8' });
+
+ if (parts.length === 0)
+ return `No numeric data
`;
+
+ return parts.map(p => `
+
+
${p.val}
+
${esc(p.unit)}
+
${p.label}
+
`).join('');
+ }
+
+ function buildSparklineHTML(history, color) {
+ if (history.length < 2)
+ return `Collecting data…
`;
+
+ const W = 120, H = 22, PAD = 2;
+ const min = Math.min(...history);
+ const max = Math.max(...history);
+ const range = max - min || 1;
+ const pts = history.map((v, i) => {
+ const x = (i / (history.length - 1)) * (W - PAD * 2) + PAD;
+ const y = H - PAD - ((v - min) / range) * (H - PAD * 2);
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ }).join(' ');
+ const last = pts.split(' ').pop().split(',');
+ return ``;
+ }
+
+ function buildCardHTML(msg, history, primaryColor) {
+ const age = formatAge(msg.timestamp);
+ const fresh = isRecent(msg.timestamp);
+ const batOk = msg.battery === 'OK';
+ const batLow = msg.battery === 'LOW';
+ const sparkHTML = history.length > 0
+ ? buildSparklineHTML(history, primaryColor || '#4aa3ff')
+ : `Waiting for data…
`;
+
+ return `
+
+ ${buildReadingsHTML(msg)}
+ ${sparkHTML}
+ `;
+ }
+
+ // ---- Public: reading hook ----
+
+ function addReading(msg) {
+ const key = `${msg.model || 'Unknown'}_${msg.id || msg.channel || '0'}`;
+ const primary = getPrimary(msg);
+
+ if (devices.has(key)) {
+ const dev = devices.get(key);
+ if (primary) {
+ dev.history.push(primary.value);
+ if (dev.history.length > MAX_SPARK_PTS) dev.history.shift();
+ dev.primaryColor = primary.color;
+ }
+ dev.card.innerHTML = buildCardHTML(msg, dev.history, dev.primaryColor);
+ const cls = getFlashClass(msg);
+ dev.card.classList.add(cls);
+ setTimeout(() => dev.card.classList.remove(cls), 820);
+ } else {
+ const history = primary ? [primary.value] : [];
+ const grid = document.getElementById('sensorDashboardGrid');
+ if (!grid) return;
+ const card = document.createElement('div');
+ card.className = 'sdb-card sdb-card--new';
+ card.innerHTML = buildCardHTML(msg, history, primary ? primary.color : '#4aa3ff');
+ grid.insertBefore(card, grid.firstChild);
+ setTimeout(() => card.classList.remove('sdb-card--new'), 2000);
+ devices.set(key, { card, history, primaryColor: primary ? primary.color : '#4aa3ff' });
+ }
+ }
+
+ // ---- Show / hide / reset ----
+
+ function applyViewState(mode) {
+ const view = document.getElementById('sensorDashboardView');
+ const output = document.getElementById('output');
+
+ if (mode === 'sensor') {
+ const saved = localStorage.getItem(STORAGE_KEY) || 'dashboard';
+ const isDash = saved === 'dashboard';
+ if (view) view.style.display = isDash ? 'block' : 'none';
+ if (output) output.style.display = isDash ? 'none' : '';
+ _updateToggle(isDash);
+ } else {
+ if (view) view.style.display = 'none';
+ if (output) output.style.display = '';
+ }
+ }
+
+ function show() {
+ localStorage.setItem(STORAGE_KEY, 'dashboard');
+ applyViewState('sensor');
+ }
+
+ function hide() {
+ localStorage.setItem(STORAGE_KEY, 'feed');
+ applyViewState('sensor');
+ }
+
+ function _updateToggle(isDash) {
+ document.getElementById('sensorToggleDash')?.classList.toggle('view-toggle-btn--active', isDash);
+ document.getElementById('sensorToggleFeed')?.classList.toggle('view-toggle-btn--active', !isDash);
+ }
+
+ function reset() {
+ devices.clear();
+ const grid = document.getElementById('sensorDashboardGrid');
+ if (grid) grid.innerHTML = '';
+ }
+
+ return { addReading, show, hide, reset, applyViewState };
+})();
+```
+
+- [ ] **Step 2: Verify the script loads**
+
+Open `http://localhost:5000` in a browser, switch to 433 mode, open DevTools console, run `SensorDashboard` — should return the object.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add static/js/components/sensor-dashboard.js
+git commit -m "feat: add SensorDashboard JS component"
+```
+
+---
+
+## Task 6: Wire up hooks, mode integration, and reset
+
+**Files:**
+- Modify: `templates/index.html`
+
+- [ ] **Step 1: Add hook in `addMessage()`**
+
+In `templates/index.html`, find this line inside `addMessage()` (around line 7247):
+```javascript
+ const msgEl = SignalCards.createPagerCard(msg);
+
+ output.insertBefore(msgEl, output.firstChild);
+```
+Add the hook call immediately before `output.insertBefore`:
+```javascript
+ const msgEl = SignalCards.createPagerCard(msg);
+
+ if (typeof PagerDirectory !== 'undefined') PagerDirectory.addMessage(msg);
+ output.insertBefore(msgEl, output.firstChild);
+```
+
+- [ ] **Step 2: Add hook in `addSensorReading()`**
+
+Find this line inside `addSensorReading()` (around line 5771):
+```javascript
+ const card = SignalCards.createSensorCard(msg);
+ output.insertBefore(card, output.firstChild);
+```
+Replace with (the two `if` lines copy `snr`/`rssi` from raw `data` into `msg` so the dashboard footer can display them):
+```javascript
+ if (data.snr !== undefined) msg.snr = data.snr;
+ if (data.rssi !== undefined) msg.rssi = data.rssi;
+ const card = SignalCards.createSensorCard(msg);
+ if (typeof SensorDashboard !== 'undefined') SensorDashboard.addReading(msg);
+ output.insertBefore(card, output.firstChild);
+```
+
+- [ ] **Step 3: Add `applyViewState` calls in `switchMode()`**
+
+Find these two lines in `switchMode()` (around line 4787):
+```javascript
+ document.getElementById('pagerStats')?.classList.toggle('active', mode === 'pager');
+ document.getElementById('sensorStats')?.classList.toggle('active', mode === 'sensor');
+```
+Add immediately after:
+```javascript
+ if (typeof PagerDirectory !== 'undefined') PagerDirectory.applyViewState(mode);
+ if (typeof SensorDashboard !== 'undefined') SensorDashboard.applyViewState(mode);
+```
+
+- [ ] **Step 4: Add reset calls in `clearOutput()`**
+
+Find these lines in the clear output function (around line 7346):
+```javascript
+ msgCount = 0;
+ pocsagCount = 0;
+ flexCount = 0;
+ sensorCount = 0;
+```
+Add immediately before:
+```javascript
+ if (typeof PagerDirectory !== 'undefined') PagerDirectory.reset();
+ if (typeof SensorDashboard !== 'undefined') SensorDashboard.reset();
+ msgCount = 0;
+```
+
+- [ ] **Step 5: Verify pager directory end-to-end**
+
+Start `python intercept.py`, open `http://localhost:5000`, switch to Pager mode.
+
+- The output header should show **Directory | Feed** toggle buttons.
+- Click **Start Decoding** with a valid SDR connected (or wait for agent replay if available).
+- On first message: an address entry should appear in the left panel with a green dot.
+- Click that address entry: cards from that address get a blue left border; the feed header shows `" highlighted"`; clicking the same address again clears it.
+- Click **Feed**: left panel disappears, classic cards fill the full width. Click **Directory**: panel returns.
+- Refresh the page: the view preference is restored from localStorage.
+
+If no SDR is available, manually invoke from the browser console to verify DOM updates:
+```javascript
+PagerDirectory.addMessage({ address: '1234567', protocol: 'POCSAG-1200', message: 'TEST', timestamp: new Date().toISOString() });
+PagerDirectory.addMessage({ address: '9990001', protocol: 'FLEX', message: 'HELLO', timestamp: new Date().toISOString() });
+// Expect: two entries in the directory panel
+PagerDirectory.highlight('1234567');
+// Expect: first entry highlighted with blue border
+```
+
+- [ ] **Step 6: Verify sensor dashboard end-to-end**
+
+Switch to 433 mode.
+
+- The header should show **Dashboard | Feed** toggle buttons.
+- Start Listening (or use console):
+```javascript
+SensorDashboard.addReading({ model: 'Acurite-Tower', id: 42, channel: 'A', temperature: 21.4, temperature_unit: 'C', humidity: 67, battery: 'OK', snr: 14, frequency: '433.92', timestamp: new Date().toISOString() });
+SensorDashboard.addReading({ model: 'Acurite-Tower', id: 42, channel: 'A', temperature: 21.6, temperature_unit: 'C', humidity: 68, battery: 'OK', snr: 14, frequency: '433.92', timestamp: new Date().toISOString() });
+// Expect: one card, second call updates in place with flash + sparkline with two points
+SensorDashboard.addReading({ model: 'Interlogix-PIR', id: '0x44A', state: 'active', battery: 'OK', snr: 18, frequency: '433.92', timestamp: new Date().toISOString() });
+// Expect: second card with green state dot
+```
+- Click **Feed**: dashboard hides, classic cards become visible. Click **Dashboard**: returns.
+- Switching to a different mode (e.g. ADS-B) and back: dashboard is restored.
+
+- [ ] **Step 7: Run full test suite**
+
+```
+pytest
+```
+
+Expected: all existing tests pass (no regressions from template edits).
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add templates/index.html
+git commit -m "feat: wire PagerDirectory and SensorDashboard into pager and sensor modes"
+```