/** * Spaxel Simple Mode - Mobile-first card-based UI * * Progressive disclosure interface for household members. * No 3D scene, just room cards, activity feed, and quick actions. */ (function() { 'use strict'; // ============================================ // Configuration // ============================================ const CONFIG = { WS_URL: (window.location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + window.location.host + '/ws/dashboard', POLL_INTERVAL: 30000, // 30 seconds for zone updates MAX_EVENTS: 50, // Maximum events to keep in feed STORAGE_KEY_MODE: 'spaxel_ui_mode', // localStorage key for mode preference STORAGE_KEY_PIN: 'spaxel_expert_pin_set' // Whether PIN has been set }; // ============================================ // Application State // ============================================ const state = { // WebSocket connection ws: null, connected: false, reconnectTimer: null, reconnectAttempts: 0, // Cached data from WebSocket zones: [], blobs: [], nodes: [], events: [], alerts: [], system: { detection_quality: 0, security_mode: false, nodes_online: 0, nodes_total: 0 }, // UI state filters: { eventTypes: ['presence', 'zone_entry', 'zone_exit', 'alert', 'system'], person: '', zone: '' }, // Sleep data sleepData: null, // Alert dismissal state silencedUntil: null }; // ============================================ // DOM Elements // ============================================ const dom = {}; function initDOM() { dom.connectionStatus = document.getElementById('connection-status'); dom.alertBanner = document.getElementById('alert-banner'); dom.alertTitle = document.getElementById('alert-title'); dom.alertMessage = document.getElementById('alert-message'); dom.alertDismiss = document.getElementById('alert-dismiss'); dom.zonesGrid = document.getElementById('zones-grid'); dom.activityFeed = document.getElementById('activity-feed'); dom.modeToggle = document.getElementById('mode-toggle'); dom.securityBtn = document.getElementById('action-security'); dom.securityLabel = document.getElementById('security-label'); dom.rebaselineBtn = document.getElementById('action-rebaseline'); dom.silenceBtn = document.getElementById('action-silence'); dom.sleepCard = document.getElementById('sleep-card'); dom.sleepContent = document.getElementById('sleep-content'); dom.systemStatus = document.getElementById('system-status'); } // ============================================ // WebSocket Connection // ============================================ function connectWebSocket() { if (state.ws && state.ws.readyState === WebSocket.OPEN) { return; } updateConnectionStatus('connecting'); try { state.ws = new WebSocket(CONFIG.WS_URL); } catch (e) { console.error('[Simple Mode] Failed to create WebSocket:', e); scheduleReconnect(); return; } state.ws.onopen = function() { state.connected = true; state.reconnectAttempts = 0; updateConnectionStatus('connected'); console.log('[Simple Mode] WebSocket connected'); }; state.ws.onclose = function(event) { state.connected = false; updateConnectionStatus('disconnected'); console.log('[Simple Mode] WebSocket closed:', event.code, event.reason); scheduleReconnect(); }; state.ws.onerror = function(error) { console.error('[Simple Mode] WebSocket error:', error); }; state.ws.onmessage = function(event) { handleMessage(event.data); }; } function scheduleReconnect() { if (state.reconnectTimer) { clearTimeout(state.reconnectTimer); } const delay = Math.min(1000 * Math.pow(2, state.reconnectAttempts), 10000); state.reconnectAttempts++; console.log('[Simple Mode] Reconnecting in', Math.round(delay / 1000), 'seconds'); state.reconnectTimer = setTimeout(function() { state.reconnectTimer = null; connectWebSocket(); }, delay); } function handleMessage(data) { try { const msg = JSON.parse(data); // Handle snapshot (first message) if (msg.type === 'snapshot') { handleSnapshot(msg); return; } // Handle incremental updates if (msg.blobs) updateBlobs(msg.blobs); if (msg.zones) updateZones(msg.zones); if (msg.nodes) updateNodes(msg.nodes); if (msg.events) updateEvents(msg.events); if (msg.alerts) updateAlerts(msg.alerts); if (msg.confidence !== undefined) state.system.detection_quality = msg.confidence; if (msg.security_mode !== undefined) { state.system.security_mode = msg.security_mode; updateSecurityButton(); } updateSystemStatus(); } catch (e) { console.error('[Simple Mode] Error handling message:', e); } } function handleSnapshot(msg) { state.blobs = msg.blobs || []; state.zones = msg.zones || []; state.nodes = msg.nodes || []; state.events = msg.events || []; state.alerts = msg.alerts || []; state.system.detection_quality = msg.confidence || 0; state.system.security_mode = msg.security_mode || false; state.system.nodes_online = (msg.nodes || []).filter(n => n.status === 'online').length; state.system.nodes_total = (msg.nodes || []).length; renderAll(); loadSleepSummary(); } // ============================================ // Data Updates // ============================================ function updateBlobs(blobs) { // Replace or add blobs by ID const ids = {}; for (let i = 0; i < blobs.length; i++) { ids[blobs[i].id] = blobs[i]; } state.blobs = state.blobs.filter(function(b) { return !(b.id in ids); }); state.blobs = state.blobs.concat(blobs); renderZoneCards(); } function updateZones(zones) { const ids = {}; for (let i = 0; i < zones.length; i++) { ids[zones[i].id] = zones[i]; } state.zones = state.zones.filter(function(z) { return !(z.id in ids); }); state.zones = state.zones.concat(zones); renderZoneCards(); } function updateNodes(nodes) { state.nodes = nodes; state.system.nodes_online = nodes.filter(n => n.status === 'online').length; state.system.nodes_total = nodes.length; updateSystemStatus(); } function updateEvents(events) { // Add new events to the beginning state.events = events.concat(state.events); // Keep only the most recent events if (state.events.length > CONFIG.MAX_EVENTS) { state.events = state.events.slice(0, CONFIG.MAX_EVENTS); } renderActivityFeed(); } function updateAlerts(alerts) { state.alerts = alerts || []; renderAlertBanner(); } // ============================================ // Rendering // ============================================ function renderAll() { renderZoneCards(); renderActivityFeed(); renderAlertBanner(); updateSecurityButton(); updateSystemStatus(); } function renderZoneCards() { if (!dom.zonesGrid) return; if (state.zones.length === 0) { dom.zonesGrid.innerHTML = '
No rooms configured yet.
'; return; } const html = state.zones.map(function(zone) { const occupancy = zone.count || 0; const people = zone.people || []; const isOccupied = occupancy > 0; const hasAlert = zoneHasAlert(zone); let statusClass = 'simple-zone-card--empty'; if (hasAlert) { statusClass = 'simple-zone-card--alert'; } else if (isOccupied) { statusClass = 'simple-zone-card--occupied'; } let statusText = isOccupied ? (occupancy === 1 ? '1 person' : occupancy + ' people') : 'Empty'; let peopleHtml = ''; if (people.length > 0) { peopleHtml = 'No recent activity
'; return; } const html = state.events.slice(0, 20).map(function(event) { return renderEventItem(event); }).join(''); dom.activityFeed.innerHTML = html; } function renderEventItem(event) { const icon = getEventIcon(event.type); const title = getEventTitle(event); const time = formatTime(event.timestamp_ms); const zone = event.zone ? '' + escapeHtml(event.zone) + '' : ''; return 'No recent activity in ' + escapeHtml(zone.name) + '
'; return; } const html = filteredEvents.slice(0, 20).map(function(event) { return renderEventItem(event); }).join(''); dom.activityFeed.innerHTML = html; } // ============================================ // Morning Briefing (includes sleep summary) // ============================================ function loadSleepSummary() { // First try to load the full morning briefing const today = new Date().toISOString().split('T')[0]; fetch('/api/briefing/today') .then(function(response) { if (!response.ok) { // Fall back to sleep summary if briefing not available return fetch('/api/sleep/summary').then(r => r.json()).then(data => ({ type: 'sleep', data: data })); } return response.json().then(briefing => ({ type: 'briefing', data: briefing })); }) .then(function(result) { if (!result || !result.data) { if (dom.sleepCard) dom.sleepCard.hidden = true; return; } if (result.type === 'briefing') { renderMorningBriefing(result.data); } else { state.sleepData = result.data; renderSleepSummary(); } }) .catch(function(error) { console.error('[Simple Mode] Error loading morning briefing:', error); if (dom.sleepCard) dom.sleepCard.hidden = true; }); } function renderMorningBriefing(briefing) { if (!dom.sleepCard || !dom.sleepContent) return; // Update date header const dateEl = document.getElementById('sleep-date'); if (dateEl) { const date = new Date(briefing.date || Date.now()); dateEl.textContent = date.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' }); } // Build HTML from briefing sections or content let html = ''; if (briefing.sections && briefing.sections.length > 0) { briefing.sections.forEach(function(section) { html += 'No briefing data available.
'; } dom.sleepContent.innerHTML = html; dom.sleepCard.hidden = false; dom.sleepCard.classList.add('morning-briefing-card'); } function renderSleepSummary() { if (!dom.sleepCard || !dom.sleepContent || !state.sleepData) return; const data = state.sleepData; const html = '' + (data.duration_min ? Math.floor(data.duration_min / 60) + 'h ' + (data.duration_min % 60) + 'm' : 'N/A') + ' in bed
' + 'Restlessness: ' + getRestlessnessLabel(data.restlessness) + '
' + 'Breathing: ' + getBreathingRegularityLabel(data.breathing_regularity) + '
'; dom.sleepContent.innerHTML = html; dom.sleepCard.hidden = false; } function getRestlessnessLabel(value) { if (!value) return 'N/A'; if (value < 1) return 'Low'; if (value < 3) return 'Moderate'; return 'High'; } function getBreathingRegularityLabel(value) { if (!value) return 'N/A'; if (value < 0.15) return 'Regular'; if (value < 0.25) return 'Fair'; return 'Irregular'; } // ============================================ // Toast Notifications // ============================================ function showToast(message, type) { const container = document.querySelector('.toast-container'); if (!container) { // Create container if it doesn't exist const tc = document.createElement('div'); tc.className = 'toast-container'; document.body.appendChild(tc); } const toast = document.createElement('div'); toast.className = 'toast toast--' + (type || 'info'); toast.textContent = message; document.querySelector('.toast-container').appendChild(toast); // Remove after 3 seconds setTimeout(function() { toast.style.animation = 'toast-out 0.3s ease-out forwards'; setTimeout(function() { if (toast.parentNode) { toast.parentNode.removeChild(toast); } }, 300); }, 3000); } // ============================================ // Utility Functions // ============================================ function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // ============================================ // Event Bindings // ============================================ function bindEvents() { if (dom.modeToggle) { dom.modeToggle.addEventListener('click', handleModeToggle); } if (dom.securityBtn) { dom.securityBtn.addEventListener('click', handleSecurityToggle); } if (dom.rebaselineBtn) { dom.rebaselineBtn.addEventListener('click', handleRebaseline); } if (dom.silenceBtn) { dom.silenceBtn.addEventListener('click', handleSilenceAlerts); } if (dom.alertDismiss) { dom.alertDismiss.addEventListener('click', function() { // Acknowledge all current alerts state.alerts.forEach(function(alert) { alert.acknowledged = true; }); renderAlertBanner(); }); } } // ============================================ // Initialization // ============================================ function init() { initDOM(); bindEvents(); connectWebSocket(); // Set up polling for zone updates setInterval(loadSleepSummary, 60000); // Check for sleep summary every minute } // Auto-initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();