diff --git a/dashboard/css/home.css b/dashboard/css/home.css
index 717f264..c679044 100644
--- a/dashboard/css/home.css
+++ b/dashboard/css/home.css
@@ -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) ── */
diff --git a/dashboard/js/home-cards.js b/dashboard/js/home-cards.js
index 08b0895..0a9fe10 100644
--- a/dashboard/js/home-cards.js
+++ b/dashboard/js/home-cards.js
@@ -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 = '' + 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('
');
}
- 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 = 'Security mode: ARMED';
+ $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);
})();