fix(dashboard): improve home-cards.js snapshot caching and alert handling

Cache the full WebSocket snapshot so incremental updates always have
complete state for banner and card rendering. Add fall/security alert
detection in the status banner with --alert level. Add armed security
state styling in home.css.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-24 13:05:48 -04:00
parent 814e1b7721
commit beb6bd2af3
2 changed files with 155 additions and 81 deletions

View file

@ -30,17 +30,17 @@
.home-status__banner--ok {
background: var(--ok-bg);
color: var(--ok);
border: 1px solid rgba(70, 167, 88, 0.25);
border: 1px solid var(--ok-border);
}
.home-status__banner--warn {
background: var(--warn-bg);
color: var(--warn);
border: 1px solid rgba(229, 160, 13, 0.25);
border: 1px solid var(--warn-border);
}
.home-status__banner--alert {
background: var(--alert-bg);
color: var(--alert);
border: 1px solid rgba(229, 72, 77, 0.25);
border: 1px solid var(--alert-border);
}
/* ── Card grid (Row 2) ── */
@ -146,6 +146,11 @@
.home-extras__item--visible {
display: block;
}
.home-extras__item--armed {
border-color: var(--alert-border);
background: var(--alert-bg);
color: var(--alert);
}
/* ── Mobile bottom nav (uses shared .app-mobile-nav from layout.css) ── */

View file

@ -3,34 +3,35 @@
*
* Connects to the dashboard WebSocket, reads the initial snapshot to fill all
* three cards, then applies incremental updates to refresh counts only.
* Caches the full snapshot so incremental merges always have complete state.
*/
(function () {
'use strict';
// ── DOM refs ──
const $banner = document.getElementById('status-banner');
const $dot = document.getElementById('ws-dot');
const $peopleCnt = document.getElementById('people-count');
const $peopleDtl = document.getElementById('people-detail');
const $devicesCnt = document.getElementById('devices-count');
const $devicesDtl = document.getElementById('devices-detail');
const $devicesMeta= document.getElementById('devices-meta');
const $eventsDtl = document.getElementById('events-detail');
const $briefing = document.getElementById('briefing-card');
const $anomaly = document.getElementById('anomaly-banner');
const $security = document.getElementById('security-toggle');
var $banner = document.getElementById('status-banner');
var $peopleCnt = document.getElementById('people-count');
var $peopleDtl = document.getElementById('people-detail');
var $devicesCnt = document.getElementById('devices-count');
var $devicesDtl = document.getElementById('devices-detail');
var $devicesMeta = document.getElementById('devices-meta');
var $eventsDtl = document.getElementById('events-detail');
var $briefing = document.getElementById('briefing-card');
var $anomaly = document.getElementById('anomaly-banner');
var $security = document.getElementById('security-toggle');
// ── State ──
let ws = null;
let reconnectDelay = 1000;
let recentEvents = [];
var ws = null;
var reconnectDelay = 1000;
var recentEvents = [];
// Cached snapshot — always holds the latest full state
var cached = { blobs: [], nodes: [], zones: [], events: [], triggers: [] };
// ── Helpers ──
function $(id) { return document.getElementById(id); }
function relativeTime(isoOrMs) {
const ts = typeof isoOrMs === 'number' ? isoOrMs : new Date(isoOrMs).getTime();
const diff = Date.now() - ts;
var ts = typeof isoOrMs === 'number' ? isoOrMs : new Date(isoOrMs).getTime();
var diff = Date.now() - ts;
if (diff < 60000) return 'just now';
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
@ -42,23 +43,56 @@
$banner.innerHTML = '<span class="home-status__dot home-status__dot--connected" id="ws-dot"></span>' + html;
}
// Merge an incremental message's partial arrays into the cached snapshot.
// Incremental messages only contain items that changed — replace by id/key.
function mergeIncremental(msg) {
if (msg.blobs) {
if (msg.type !== 'snapshot') {
// Replace existing blobs with same id, add new ones
var ids = {};
for (var i = 0; i < msg.blobs.length; i++) ids[msg.blobs[i].id] = msg.blobs[i];
cached.blobs = cached.blobs.filter(function (b) { return !(b.id in ids); }).concat(msg.blobs);
} else {
cached.blobs = msg.blobs;
}
}
if (msg.nodes) {
if (msg.type !== 'snapshot') {
var macs = {};
for (var i = 0; i < msg.nodes.length; i++) macs[msg.nodes[i].mac] = msg.nodes[i];
cached.nodes = cached.nodes.filter(function (n) { return !(n.mac in macs); }).concat(msg.nodes);
} else {
cached.nodes = msg.nodes;
}
}
if (msg.zones) {
if (msg.type !== 'snapshot') {
var zids = {};
for (var i = 0; i < msg.zones.length; i++) zids[msg.zones[i].id] = msg.zones[i];
cached.zones = cached.zones.filter(function (z) { return !(z.id in zids); }).concat(msg.zones);
} else {
cached.zones = msg.zones;
}
}
if (msg.triggers) cached.triggers = msg.triggers;
if (typeof msg.confidence === 'number') cached.confidence = msg.confidence;
if (msg.security_mode !== undefined) cached.security_mode = msg.security_mode;
}
// ── Card updaters ──
function updatePeopleCard(snapshot) {
const blobs = snapshot.blobs || [];
const zones = snapshot.zones || [];
const peopleNames = blobs
.map(function (b) { return b.person; })
.filter(function (p) { return p && p !== 'Unknown'; });
function updatePeopleCard() {
var blobs = cached.blobs || [];
var zones = cached.zones || [];
var peopleNames = [];
for (var i = 0; i < blobs.length; i++) {
var p = blobs[i].person;
if (p && p !== 'Unknown' && peopleNames.indexOf(p) === -1) peopleNames.push(p);
}
const uniquePeople = [];
peopleNames.forEach(function (n) {
if (uniquePeople.indexOf(n) === -1) uniquePeople.push(n);
});
$peopleCnt.textContent = uniquePeople.length + ' people';
if (uniquePeople.length > 0) {
$peopleDtl.textContent = uniquePeople.join(', ');
$peopleCnt.textContent = peopleNames.length + (peopleNames.length === 1 ? ' person' : ' people');
if (peopleNames.length > 0) {
$peopleDtl.textContent = peopleNames.join(', ');
} else {
var occupied = zones.filter(function (z) { return z.count > 0; });
$peopleDtl.textContent = occupied.length > 0
@ -67,11 +101,11 @@
}
}
function updateDevicesCard(snapshot) {
var nodes = snapshot.nodes || [];
function updateDevicesCard() {
var nodes = cached.nodes || [];
var online = nodes.filter(function (n) { return n.status === 'online'; });
var stale = nodes.filter(function (n) { return n.status === 'stale'; });
var offline= nodes.filter(function (n) { return n.status === 'offline'; });
var offline = nodes.filter(function (n) { return n.status === 'offline'; });
$devicesCnt.textContent = online.length + '/' + nodes.length + ' online';
$devicesMeta.innerHTML = '';
@ -88,7 +122,7 @@
: 'All devices healthy';
}
var quality = snapshot.confidence;
var quality = cached.confidence;
if (typeof quality === 'number') {
var qLevel = quality >= 80 ? 'ok' : quality >= 60 ? 'warn' : 'alert';
addTag($devicesMeta, qLevel, quality + '% quality');
@ -102,15 +136,7 @@
container.appendChild(span);
}
function updateEventsCard(snapshot) {
var evts = snapshot.events || [];
if (evts.length > 0) {
recentEvents = evts.slice(0, 5);
}
renderEvents();
}
function renderEvents() {
function updateEventsCard() {
if (recentEvents.length === 0) {
$eventsDtl.textContent = 'No recent events';
return;
@ -123,65 +149,108 @@
$eventsDtl.innerHTML = lines.join('<br>');
}
function updateExtras(snapshot) {
function updateExtras() {
// Morning briefing
if (snapshot.briefing) {
$briefing.textContent = snapshot.briefing;
if (cached.briefing) {
$briefing.textContent = cached.briefing;
$briefing.classList.add('home-extras__item--visible');
}
// Security mode
if (snapshot.security_mode) {
$security.textContent = 'Security mode: ARMED';
$security.classList.add('home-extras__item--visible');
if (cached.security_mode) {
$security.innerHTML = '<strong>Security mode: ARMED</strong>';
$security.classList.add('home-extras__item--visible', 'home-extras__item--armed');
} else {
$security.classList.remove('home-extras__item--visible', 'home-extras__item--armed');
}
// Anomaly
if (snapshot.anomaly_active) {
if (cached.anomaly_active) {
$anomaly.textContent = 'Anomaly detected';
$anomaly.classList.add('home-extras__item--visible');
}
}
function updateBanner(snapshot) {
var nodes = snapshot.nodes || [];
var blobs = snapshot.blobs || [];
var offline = nodes.filter(function (n) { return n.status === 'offline'; });
var people = blobs.filter(function (b) { return b.person; });
function updateBanner() {
var nodes = cached.nodes || [];
var blobs = cached.blobs || [];
var offline = nodes.filter(function (n) { return n.status === 'offline'; });
var online = nodes.filter(function (n) { return n.status === 'online'; });
if (offline.length > 0) {
// Check for active alerts (fall, security) in recent events
var now = Date.now();
var recentAlert = null;
for (var i = 0; i < recentEvents.length; i++) {
var e = recentEvents[i];
var age = now - (e.timestamp_ms || 0);
if (age > 300000) break; // only consider last 5 min
if (e.type === 'fall_alert') {
recentAlert = 'Fall alert' + (e.zone ? ' in ' + e.zone : '') +
(e.person ? ': ' + e.person : '');
break;
}
if (e.type === 'security_alert') {
recentAlert = 'Security alert' + (e.zone ? ' in ' + e.zone : '');
break;
}
}
if (recentAlert) {
setBanner('alert', recentAlert);
} else if (offline.length > 0) {
setBanner('warn', offline.length + ' device' + (offline.length > 1 ? 's' : '') + ' offline');
} else if (people.length > 0) {
var names = [];
people.forEach(function (b) {
if (b.person && names.indexOf(b.person) === -1) names.push(b.person);
});
var online = nodes.filter(function (n) { return n.status === 'online'; });
setBanner('ok', 'All clear — ' + names.length + ' people home, ' +
online.length + ' devices online');
} else {
var onlineN = nodes.filter(function (n) { return n.status === 'online'; });
setBanner('ok', 'All clear — No one detected, ' + onlineN.length + ' devices online');
var names = [];
for (var i = 0; i < blobs.length; i++) {
var p = blobs[i].person;
if (p && names.indexOf(p) === -1) names.push(p);
}
if (names.length > 0) {
setBanner('ok', 'All clear — ' + names.length + ' people home, ' +
online.length + ' devices online');
} else {
setBanner('ok', 'All clear — No one detected, ' + online.length + ' devices online');
}
}
}
// ── Snapshot processing ──
function handleSnapshot(snapshot) {
updateBanner(snapshot);
updatePeopleCard(snapshot);
updateDevicesCard(snapshot);
updateEventsCard(snapshot);
updateExtras(snapshot);
cached = {
blobs: snapshot.blobs || [],
nodes: snapshot.nodes || [],
zones: snapshot.zones || [],
triggers: snapshot.triggers || [],
events: snapshot.events || [],
confidence: snapshot.confidence,
security_mode: snapshot.security_mode,
briefing: snapshot.briefing,
anomaly_active: snapshot.anomaly_active,
};
if (snapshot.events && snapshot.events.length > 0) {
recentEvents = snapshot.events.slice(0, 5);
}
updateBanner();
updatePeopleCard();
updateDevicesCard();
updateEventsCard();
updateExtras();
}
function handleIncremental(msg) {
// Lightweight refresh on incremental updates
if (msg.blobs) updatePeopleCard(msg);
if (msg.nodes) { updateDevicesCard(msg); updateBanner(msg); }
mergeIncremental(msg);
if (msg.events && msg.events.length > 0) {
// Prepend new events, keep last 5
recentEvents = msg.events.concat(recentEvents).slice(0, 5);
renderEvents();
}
// Always refresh all cards from cached state
updateBanner();
updatePeopleCard();
updateDevicesCard();
updateEventsCard();
updateExtras();
}
// ── WebSocket ──
@ -227,5 +296,5 @@
}
// Refresh relative timestamps every 30s
setInterval(renderEvents, 30000);
setInterval(updateEventsCard, 30000);
})();