- Go function GenerateBriefing(date, person) assembles briefing in priority order - Sections: critical alerts → sleep → who's home → anomalies → system health → predictions → learning - Stored as daily record in briefings table with sections_json - Expert mode: card overlay on first open, dismissible, slides away after 10s - Simple mode: morning card as first card in layout - Ambient mode: text fades in on first person detection, stays for 30s - Delivery via dashboard, push notification, or webhook - All acceptance criteria met
706 lines
25 KiB
JavaScript
706 lines
25 KiB
JavaScript
/**
|
|
* 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 = '<p class="simple-zone-card simple-zone-card--loading">No rooms configured yet.</p>';
|
|
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 = '<div class="simple-zone-card__people">' +
|
|
people.map(function(p) {
|
|
return '<span class="simple-zone-card__person">' + escapeHtml(p) + '</span>';
|
|
}).join('') +
|
|
'</div>';
|
|
}
|
|
|
|
return '<div class="simple-zone-card ' + statusClass + '" data-zone-id="' + zone.id + '">' +
|
|
'<div class="simple-zone-card__name">' + escapeHtml(zone.name) + '</div>' +
|
|
'<div class="simple-zone-card__status">' + statusText + '</div>' +
|
|
peopleHtml +
|
|
'</div>';
|
|
}).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 = '<p class="simple-feed__empty">No recent activity</p>';
|
|
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 ? '<span class="simple-feed-item__zone">' + escapeHtml(event.zone) + '</span>' : '';
|
|
|
|
return '<div class="simple-feed-item" data-event-id="' + event.id + '">' +
|
|
'<div class="simple-feed-item__icon">' + icon + '</div>' +
|
|
'<div class="simple-feed-item__content">' +
|
|
'<div class="simple-feed-item__title">' + escapeHtml(title) + '</div>' +
|
|
'<div class="simple-feed-item__meta">' +
|
|
'<span class="simple-feed-item__time">' + time + '</span>' +
|
|
zone +
|
|
'</div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
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 = '<p class="simple-feed__empty">No recent activity in ' + escapeHtml(zone.name) + '</p>';
|
|
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 += '<div class="simple-briefing-section simple-briefing-section--' + section.type + '">' +
|
|
escapeHtml(section.content) +
|
|
'</div>';
|
|
});
|
|
} else if (briefing.content) {
|
|
// Parse content into paragraphs
|
|
const paragraphs = briefing.content.split('\n\n').filter(p => p.trim());
|
|
paragraphs.forEach(function(p) {
|
|
html += '<div class="simple-briefing-section">' + escapeHtml(p) + '</div>';
|
|
});
|
|
} else {
|
|
html = '<p>No briefing data available.</p>';
|
|
}
|
|
|
|
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 =
|
|
'<p><strong>' + (data.duration_min ? Math.floor(data.duration_min / 60) + 'h ' + (data.duration_min % 60) + 'm' : 'N/A') +
|
|
' in bed</strong></p>' +
|
|
'<p>Restlessness: ' + getRestlessnessLabel(data.restlessness) + '</p>' +
|
|
'<p>Breathing: ' + getBreathingRegularityLabel(data.breathing_regularity) + '</p>';
|
|
|
|
dom.sleepContent.innerHTML = html;
|
|
dom.sleepCard.hidden = false;
|
|
}
|
|
|
|
function getRestlessnessLabel(value) {
|
|
if (!value) return 'N/A';
|
|
if (value < 1) return '<span class="simple-sleep__good">Low</span>';
|
|
if (value < 3) return '<span class="simple-sleep__ok">Moderate</span>';
|
|
return '<span class="simple-sleep__poor">High</span>';
|
|
}
|
|
|
|
function getBreathingRegularityLabel(value) {
|
|
if (!value) return 'N/A';
|
|
if (value < 0.15) return '<span class="simple-sleep__good">Regular</span>';
|
|
if (value < 0.25) return '<span class="simple-sleep__ok">Fair</span>';
|
|
return '<span class="simple-sleep__poor">Irregular</span>';
|
|
}
|
|
|
|
// ============================================
|
|
// 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();
|
|
}
|
|
|
|
})();
|