spaxel/dashboard/js/ambient.js
jedarden 7ed7806828 Implement Component 35: Morning Briefing
- 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
2026-05-06 05:01:05 -04:00

715 lines
21 KiB
JavaScript

/**
* Spaxel Dashboard - Ambient Mode
*
* Simplified, always-on display mode for wall-mounted tablets.
* Time-of-day aware palettes, auto-dim, and calm visualization.
*/
(function() {
'use strict';
// ============================================
// Configuration
// ============================================
const TIME_PERIODS = {
morning: { start: 6, end: 10 }, // 6am - 10am
day: { start: 10, end: 18 }, // 10am - 6pm
evening: { start: 18, end: 22 }, // 6pm - 10pm
night: { start: 22, end: 6 } // 10pm - 6am
};
const UPDATE_INTERVAL = 5000; // 5 seconds for polling fallback
const AUTO_DIM_TIMEOUT = 60 * 1000; // 60 seconds of no presence in ambient zone
// ============================================
// State
// ============================================
let isActive = false;
let canvas = null;
let renderer = null;
let ws = null;
let wsReconnectTimer = null;
let updateTimer = null;
let timeOfDayTimer = null;
let currentState = {
zones: [],
blobs: [],
portals: [],
alerts: [],
nodes: [],
securityMode: false,
nodesOnline: 0,
nodesTotal: 0,
lastUpdate: null
};
// Configuration
let config = {
ambientZone: null // Zone ID for auto-dim detection
};
// ============================================
// Initialization
// ============================================
/**
* Initialize ambient mode
*/
function init() {
console.log('[Ambient Mode] Initializing...');
// Check if we should be in ambient mode
checkAmbientMode();
console.log('[Ambient Mode] Initialized');
}
/**
* Check if ambient mode should be active
*/
function checkAmbientMode() {
// For standalone ambient.html, always enable
// For main dashboard, check hash
if (window.location.pathname.endsWith('/ambient.html') ||
window.location.pathname === '/ambient') {
enableAmbientMode();
return;
}
const hash = window.location.hash.slice(1);
if (hash === 'ambient') {
enableAmbientMode();
} else if (isActive) {
disableAmbientMode();
}
}
/**
* Enable ambient mode
*/
function enableAmbientMode() {
if (isActive) return;
isActive = true;
document.body.classList.add('ambient-mode');
// Create ambient UI
createAmbientUI();
// Set initial time period and start timer
updateTimeOfDay();
startTimeOfDayUpdater();
// Initialize renderer
const canvasEl = document.getElementById('ambient-canvas');
if (canvasEl && window.SpaxelAmbientRenderer) {
renderer = window.SpaxelAmbientRenderer;
renderer.init(canvasEl, {
ambientZone: config.ambientZone
});
// Set up callbacks
renderer.setAlertClickCallback(handleAlertClick);
renderer.setUserActivityCallback(resetDimTimer);
}
// Initialize briefing
if (window.SpaxelAmbientBriefing) {
window.SpaxelAmbientBriefing.init();
window.SpaxelAmbientBriefing.setOnDismiss(() => {
// After briefing dismissed, check for alerts
checkAlerts();
});
}
// Connect WebSocket
connectWebSocket();
// Show briefing if this is first detection today
checkAndShowBriefing();
console.log('[Ambient Mode] Enabled');
}
/**
* Disable ambient mode
*/
function disableAmbientMode() {
if (!isActive) return;
isActive = false;
document.body.classList.remove('ambient-mode');
// Disconnect WebSocket
disconnectWebSocket();
// Destroy renderer
if (renderer) {
renderer.destroy();
renderer = null;
}
// Remove ambient UI
const ambientContainer = document.getElementById('ambient-container');
if (ambientContainer) {
ambientContainer.remove();
}
// Stop timers
stopUpdates();
console.log('[Ambient Mode] Disabled');
}
// ============================================
// UI Creation
// ============================================
/**
* Create ambient mode UI
*/
function createAmbientUI() {
// Check if already exists
if (document.getElementById('ambient-container')) {
return;
}
const container = document.createElement('div');
container.id = 'ambient-container';
container.innerHTML = `
<div class="ambient-floorplan">
<canvas id="ambient-canvas" class="ambient-canvas"></canvas>
</div>
<div class="ambient-status">
<div class="ambient-status-item">
<div class="ambient-status-dot online" id="ambient-status-dot"></div>
<span id="ambient-status-text">Loading...</span>
</div>
<div class="ambient-status-item">
<span id="ambient-time"></span>
</div>
<div class="ambient-status-item">
<span id="ambient-nodes">0 nodes</span>
</div>
</div>
<!-- "All Secure" message -->
<div id="ambient-secure" class="ambient-secure" style="display: none;">
<div class="ambient-secure-icon">&#128274;</div>
<div class="ambient-secure-text">All secure</div>
</div>
`;
document.body.appendChild(container);
canvas = document.getElementById('ambient-canvas');
// Set up event listeners for route changes
window.addEventListener('hashchange', checkAmbientMode);
}
// ============================================
// Time of Day
// ============================================
/**
* Start time-of-day updater
*/
function startTimeOfDayUpdater() {
updateTimeOfDay();
timeOfDayTimer = setInterval(updateTimeOfDay, 60000); // Check every minute
}
/**
* Stop all timers
*/
function stopUpdates() {
if (timeOfDayTimer) {
clearInterval(timeOfDayTimer);
timeOfDayTimer = null;
}
if (updateTimer) {
clearTimeout(updateTimer);
updateTimer = null;
}
}
/**
* Update time-of-day theme
*/
function updateTimeOfDay() {
if (!isActive) return;
const hour = new Date().getHours();
let period = 'night';
for (const [key, range] of Object.entries(TIME_PERIODS)) {
if (range.start <= range.end) {
// Normal period (e.g., morning: 6-10)
if (hour >= range.start && hour < range.end) {
period = key;
break;
}
} else {
// Overnight period (e.g., night: 22-6)
if (hour >= range.start || hour < range.end) {
period = key;
break;
}
}
}
// Remove all time periods
document.body.classList.remove('time-morning', 'time-day', 'time-evening', 'time-night');
// Add current period
document.body.classList.add('time-' + period);
// Update renderer time period
if (renderer) {
// Force a render to update background color
renderer.render();
}
}
// ============================================
// WebSocket Connection
// ============================================
/**
* Connect to WebSocket for real-time updates
*/
function connectWebSocket() {
// Disconnect existing connection
if (ws) {
ws.close();
}
// Determine WebSocket protocol
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = window.location.host;
const wsUrl = `${wsProtocol}//${wsHost}/ws/dashboard`;
console.log('[Ambient Mode] Connecting WebSocket:', wsUrl);
ws = new WebSocket(wsUrl);
ws.onopen = function() {
console.log('[Ambient Mode] WebSocket connected');
updateConnectionStatus(true);
// Clear reconnect timer
if (wsReconnectTimer) {
clearTimeout(wsReconnectTimer);
wsReconnectTimer = null;
}
};
ws.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
handleWebSocketMessage(data);
} catch (e) {
console.error('[Ambient Mode] Error parsing WebSocket message:', e);
}
};
ws.onclose = function(event) {
console.log('[Ambient Mode] WebSocket disconnected:', event.code, event.reason);
updateConnectionStatus(false);
// Attempt to reconnect
if (isActive) {
const delay = 5000; // 5 seconds
wsReconnectTimer = setTimeout(connectWebSocket, delay);
}
};
ws.onerror = function(error) {
console.error('[Ambient Mode] WebSocket error:', error);
updateConnectionStatus(false);
};
}
/**
* Disconnect WebSocket
*/
function disconnectWebSocket() {
if (ws) {
ws.close();
ws = null;
}
if (wsReconnectTimer) {
clearTimeout(wsReconnectTimer);
wsReconnectTimer = null;
}
}
/**
* Update connection status indicator
*/
function updateConnectionStatus(connected) {
const statusDot = document.getElementById('ambient-status-dot');
const statusText = document.getElementById('ambient-status-text');
if (statusDot) {
if (connected) {
statusDot.className = 'ambient-status-dot online';
} else {
statusDot.className = 'ambient-status-dot';
statusDot.style.background = '#ff3b30';
}
}
if (statusText) {
statusText.textContent = connected ? 'Connected' : 'Reconnecting...';
}
}
/**
* Handle WebSocket message
*/
function handleWebSocketMessage(data) {
// Handle snapshot message (first message on connect)
if (data.type === 'snapshot') {
// Full snapshot
if (data.zones) currentState.zones = data.zones;
if (data.blobs) currentState.blobs = data.blobs;
if (data.portals) currentState.portals = data.portals;
if (data.nodes) currentState.nodes = data.nodes;
if (data.events) currentState.alerts = data.events.filter(e =>
e.type === 'alert' || e.type === 'fall_alert' || e.type === 'anomaly'
);
if (data.security_mode !== undefined) currentState.securityMode = data.security_mode;
// Update node counts
currentState.nodesOnline = currentState.nodes.filter(n => n.status === 'online').length;
currentState.nodesTotal = currentState.nodes.length;
currentState.lastUpdate = new Date();
// Update renderer state
if (renderer) {
renderer.updateState(currentState);
}
// Update UI
updateStatus();
checkAlerts();
// Trigger first detection for briefing
if (currentState.blobs.length > 0 && window.SpaxelAmbientBriefing) {
window.SpaxelAmbientBriefing.onFirstDetection();
}
return;
}
// Handle loc_update messages (event-driven blob updates)
if (data.type === 'loc_update') {
const previousBlobCount = currentState.blobs.length;
if (data.blobs) {
currentState.blobs = data.blobs;
}
currentState.lastUpdate = new Date();
// Check for first person detection (for morning briefing)
if (previousBlobCount === 0 && currentState.blobs.length > 0 && window.SpaxelAmbientBriefing) {
window.SpaxelAmbientBriefing.onFirstDetection();
}
// Update renderer state
if (renderer) {
renderer.updateState(currentState);
}
// Update UI
updateStatus();
return;
}
// Handle event-driven messages
if (data.type === 'alert' || data.type === 'anomaly_detected' || data.type === 'fall_alert') {
// Add to alerts
const exists = currentState.alerts.some(a => a.id === data.id);
if (!exists) {
currentState.alerts.push(data);
}
currentState.lastUpdate = new Date();
checkAlerts();
return;
}
// Handle system_mode_change for security mode
if (data.type === 'system_mode_change') {
if (data.security_mode !== undefined) {
currentState.securityMode = data.security_mode;
}
updateStatus();
return;
}
// Handle delta messages (no type field - incremental updates)
if (!data.type) {
const previousBlobCount = currentState.blobs.length;
if (data.blobs) {
currentState.blobs = data.blobs;
}
if (data.zones) {
currentState.zones = data.zones;
}
if (data.portals) {
currentState.portals = data.portals;
}
if (data.nodes) {
currentState.nodes = data.nodes;
currentState.nodesOnline = currentState.nodes.filter(n => n.status === 'online').length;
currentState.nodesTotal = currentState.nodes.length;
}
if (data.events && data.events.length > 0) {
// Add new alerts
data.events.forEach(event => {
if (event.type === 'alert' || event.type === 'fall_alert' || event.type === 'anomaly') {
// Check if alert already exists
const exists = currentState.alerts.some(a => a.id === event.id);
if (!exists) {
currentState.alerts.push(event);
}
}
});
}
// Handle security_mode in delta
if (data.security_mode !== undefined) {
currentState.securityMode = data.security_mode;
}
currentState.lastUpdate = new Date();
// Check for first person detection (for morning briefing)
if (previousBlobCount === 0 && currentState.blobs.length > 0 && window.SpaxelAmbientBriefing) {
window.SpaxelAmbientBriefing.onFirstDetection();
}
// Update renderer state
if (renderer) {
renderer.updateState(currentState);
}
// Update UI
updateStatus();
checkAlerts();
}
// ============================================
// Status Updates
// ============================================
/**
* Update status bar
*/
function updateStatus() {
const statusText = document.getElementById('ambient-status-text');
const timeDisplay = document.getElementById('ambient-time');
const nodesDisplay = document.getElementById('ambient-nodes');
const secureMessage = document.getElementById('ambient-secure');
if (statusText) {
// Determine status based on alerts and security mode
if (currentState.alerts.length > 0) {
statusText.textContent = 'Alert active';
} else if (currentState.securityMode) {
statusText.textContent = 'Security armed';
} else {
statusText.textContent = 'All secure';
}
}
if (timeDisplay) {
const now = new Date();
timeDisplay.textContent = now.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
if (nodesDisplay) {
nodesDisplay.textContent = `${currentState.nodesOnline}/${currentState.nodesTotal} nodes`;
}
// Show/hide "All Secure" message
if (secureMessage) {
const hasPeople = currentState.blobs.length > 0;
const hasAlerts = currentState.alerts.length > 0;
if (!hasPeople && !hasAlerts && !isDimmed()) {
secureMessage.style.display = 'block';
} else {
secureMessage.style.display = 'none';
}
}
}
function isDimmed() {
return renderer && document.getElementById('ambient-canvas')?.style.filter.includes('brightness(0.4)');
}
/**
* Check for alerts and show/hide alert mode
*/
function checkAlerts() {
if (currentState.alerts.length > 0) {
// Show alert mode in renderer
if (renderer) {
const latestAlert = currentState.alerts[0];
renderer.enterAlertMode(latestAlert);
}
} else {
// Hide alert mode
if (renderer) {
renderer.exitAlertMode();
}
}
}
/**
* Handle alert click/acknowledge
*/
function handleAlertClick(alert) {
// Dismiss the alert
currentState.alerts = [];
checkAlerts();
// In a real implementation, this would call an API to acknowledge the alert
// For now, just show a toast
showToast('Alert acknowledged', 'info');
// Check for fall alerts - POST to acknowledge endpoint
if (alert.type === 'fall_alert' && alert.id) {
acknowledgeAlert(alert.id, 'fall');
} else if (alert.type === 'anomaly' && alert.id) {
acknowledgeAlert(alert.id, 'anomaly');
}
}
/**
* Acknowledge an alert via API
*/
async function acknowledgeAlert(alertId, alertType) {
try {
let endpoint = `/api/anomalies/${alertId}/acknowledge`;
if (alertType === 'fall') {
endpoint = `/api/fall/${alertId}/acknowledge`;
}
const response = await fetch(endpoint, {
method: 'POST'
});
if (response.ok) {
console.log('[Ambient Mode] Alert acknowledged:', alertId);
} else {
console.warn('[Ambient Mode] Failed to acknowledge alert:', alertId);
}
} catch (error) {
console.error('[Ambient Mode] Error acknowledging alert:', error);
}
}
/**
* Reset the dim timer (called on user activity)
*/
function resetDimTimer() {
if (renderer) {
renderer.wakeFromDim();
}
}
// ============================================
// Morning Briefing
// ============================================
/**
* Check and show morning briefing
*/
async function checkAndShowBriefing() {
if (!window.SpaxelAmbientBriefing) {
return;
}
// Check if briefing should be shown
if (await window.SpaxelAmbientBriefing.shouldShowToday()) {
window.SpaxelAmbientBriefing.fetchAndShow();
}
}
// ============================================
// Helper Functions
// ============================================
/**
* Show toast notification
*/
function showToast(message, type = 'info') {
if (window.showToast) {
window.showToast(message, type);
return;
}
// Fallback toast
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 12px 20px;
border-radius: 8px;
z-index: 200;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'fadeOut 0.3s ease-out forwards';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// ============================================
// Public API
// ============================================
window.SpaxelAmbientMode = {
init: init,
enable: enableAmbientMode,
disable: disableAmbientMode,
isActive: () => isActive,
setAmbientZone: (zoneId) => {
config.ambientZone = zoneId;
if (renderer) {
renderer.setAmbientZone(zoneId);
}
},
refresh: () => {
// Trigger a render
if (renderer) {
renderer.render();
}
}
};
// Auto-initialize
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
console.log('[Ambient Mode] Module loaded');
})();