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); })();