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 ` +
+
+
${esc(msg.model || 'Unknown')}
+
ID ${esc(String(msg.id || 'N/A'))}${msg.channel ? ` · Ch ${msg.channel}` : ''}
+
+
${age}
+
+
${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" +```