/** * 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 = '
' + people.map(function(p) { return '' + escapeHtml(p) + ''; }).join('') + '
'; } return '
' + '
' + escapeHtml(zone.name) + '
' + '
' + statusText + '
' + peopleHtml + '
'; }).join(''); dom.zonesGrid.innerHTML = html; // Add click handlers for zone cards dom.zonesGrid.querySelectorAll('.simple-zone-card').forEach(function(card) { card.addEventListener('click', function() { const zoneId = this.getAttribute('data-zone-id'); showZoneActivity(zoneId); }); }); } function zoneHasAlert(zone) { // Check if any active alerts are for this zone return state.alerts.some(function(alert) { return alert.zone === zone.name && !alert.acknowledged; }); } function renderActivityFeed() { if (!dom.activityFeed) return; if (state.events.length === 0) { dom.activityFeed.innerHTML = '

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 '
' + '
' + icon + '
' + '
' + '
' + escapeHtml(title) + '
' + '
' + '' + time + '' + zone + '
' + '
' + '
'; } function getEventIcon(type) { const icons = { 'detection': '👤', // person 'zone_entry': '➡', // arrow right 'zone_exit': '⬅', // arrow left 'portal_crossing': '❄', // snowflake 'trigger_fired': '⚡', // high voltage 'fall_alert': '⚠', // warning 'anomaly': '🔕', // radio 'security_alert': '🔒', // lock 'node_online': '📡', // antenna 'node_offline': '📥', // crossed antenna 'system': '⚙', // gear 'learning': '📖' // book }; return icons[type] || '💬'; // speech bubble } function getEventTitle(event) { if (event.person) { return event.person + ' — ' + (event.title || event.type); } return event.title || formatEventType(event.type); } function formatEventType(type) { return type.replace(/_/g, ' ').replace(/\b\w/g, function(l) { return l.toUpperCase(); }); } function formatTime(timestampMs) { const diff = Date.now() - timestampMs; 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'; return new Date(timestampMs).toLocaleDateString(); } function renderAlertBanner() { if (!dom.alertBanner) return; // Check if alerts are silenced if (state.silencedUntil && Date.now() < state.silencedUntil) { dom.alertBanner.hidden = true; return; } // Find the highest priority unacknowledged alert const alert = state.alerts.find(function(a) { return !a.acknowledged; }); if (!alert) { dom.alertBanner.hidden = true; return; } dom.alertTitle.textContent = alert.title || 'Alert'; dom.alertMessage.textContent = alert.message || ''; dom.alertBanner.hidden = false; dom.alertBanner.className = 'simple-alerts simple-alerts--' + (alert.severity || 'warning'); // Show silence button if there are multiple alerts dom.silenceBtn.hidden = state.alerts.filter(a => !a.acknowledged).length <= 1; } function updateSecurityButton() { if (!dom.securityBtn || !dom.securityLabel) return; if (state.system.security_mode) { dom.securityLabel.textContent = 'Disarm Security'; dom.securityBtn.classList.add('simple-action-btn--active'); } else { dom.securityLabel.textContent = 'Arm Security'; dom.securityBtn.classList.remove('simple-action-btn--active'); } } function updateSystemStatus() { if (!dom.systemStatus) return; const quality = state.system.detection_quality; const nodes = state.system.nodes_online + '/' + state.system.nodes_total; let status = 'System: ' + nodes + ' nodes online'; if (quality > 0) { status += ' • ' + quality + '% quality'; } dom.systemStatus.textContent = status; } function updateConnectionStatus(status) { if (!dom.connectionStatus) return; const dot = dom.connectionStatus.querySelector('.simple-status__dot'); const text = dom.connectionStatus.querySelector('.simple-status__text'); if (status === 'connected') { dot.className = 'simple-status__dot simple-status__dot--connected'; text.textContent = 'Connected'; } else if (status === 'connecting') { dot.className = 'simple-status__dot simple-status__dot--connecting'; text.textContent = 'Connecting...'; } else { dot.className = 'simple-status__dot simple-status__dot--disconnected'; text.textContent = 'Disconnected — Reconnecting...'; } } // ============================================ // Actions // ============================================ function handleSecurityToggle() { const newState = !state.system.security_mode; fetch('/api/security/' + (newState ? 'arm' : 'disarm'), { method: 'POST', headers: { 'Content-Type': 'application/json' } }).then(function(response) { if (!response.ok) throw new Error('Failed to toggle security mode'); return response.json(); }).then(function(data) { state.system.security_mode = data.security_mode; updateSecurityButton(); showToast(newState ? 'Security mode armed' : 'Security mode disarmed', 'success'); }).catch(function(error) { console.error('[Simple Mode] Error toggling security:', error); showToast('Failed to toggle security mode', 'error'); }); } function handleRebaseline() { fetch('/api/nodes/rebaseline-all', { method: 'POST', headers: { 'Content-Type': 'application/json' } }).then(function(response) { if (!response.ok) throw new Error('Failed to trigger re-baseline'); return response.json(); }).then(function(data) { showToast('Re-baseline started — this takes about 60 seconds', 'info'); }).catch(function(error) { console.error('[Simple Mode] Error triggering re-baseline:', error); showToast('Failed to trigger re-baseline', 'error'); }); } function handleSilenceAlerts() { // Silence for 1 hour state.silencedUntil = Date.now() + 3600000; renderAlertBanner(); showToast('Alerts silenced for 1 hour', 'info'); } function handleModeToggle() { // Save preference to localStorage localStorage.setItem(CONFIG.STORAGE_KEY_MODE, 'expert'); // Navigate to expert mode window.location.href = '/live'; } function showZoneActivity(zoneId) { // Filter activity feed to show only events for this zone const zone = state.zones.find(function(z) { return z.id == zoneId; }); if (!zone) return; state.filters.zone = zone.name; const filteredEvents = state.events.filter(function(e) { return e.zone === zone.name; }); if (filteredEvents.length === 0) { dom.activityFeed.innerHTML = '

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 += '
' + escapeHtml(section.content) + '
'; }); } else if (briefing.content) { // Parse content into paragraphs const paragraphs = briefing.content.split('\n\n').filter(p => p.trim()); paragraphs.forEach(function(p) { html += '
' + escapeHtml(p) + '
'; }); } else { 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(); } })();