spaxel/dashboard/js/ambient_briefing.js
jedarden d81d1cb82c feat: implement ambient dashboard mode with Canvas 2D renderer
- Added /ambient route serving ambient.html for wall-mounted tablet display
- Canvas 2D renderer at 2Hz with lerp interpolation for smooth person movement
- Time-of-day palette with 30-minute transitions (morning/day/evening/night)
- Auto-dim: reduces brightness to 40% after 60s of no presence
- Alert mode: pulsing red background for fall/security alerts
- Morning briefing overlay: 15-second overlay on first detection after 6am
- Unified alerts API for fall, anomaly, and node_offline events
- Jest test setup mocking Canvas 2D context for jsdom

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 23:16:52 -04:00

385 lines
12 KiB
JavaScript

/**
* Spaxel Dashboard - Ambient Mode Morning Briefing
*
* Morning briefing overlay for ambient display mode.
* Shows sleep summary, expected departures, and system status.
* Appears once per day when first presence is detected after 6am.
*/
(function() {
'use strict';
// ============================================
// Configuration
// ============================================
const BRIEFING_DURATION_MS = 15000; // 15 seconds
const MORNING_START_HOUR = 6; // 6am
const BRIEFING_END_HOUR = 12; // 12pm (noon)
const LOCAL_STORAGE_KEY = 'ambient_briefing_last_shown';
// ============================================
// State
// ============================================
let briefingElement = null;
let briefingTimer = null;
let isActive = false;
let isFirstDetectionToday = false;
let hasShownToday = false;
// Callbacks
let onDismiss = null;
let onFetchBriefing = null;
// ============================================
// Public API
// ============================================
const AmbientBriefing = {
/**
* Initialize the morning briefing module
*/
init() {
// Create briefing element if it doesn't exist
ensureBriefingElement();
// Set up event listeners
setupEventListeners();
// Don't check for first detection during init - it will be checked when needed
console.log('[AmbientBriefing] Initialized');
},
/**
* Show the morning briefing
* @param {Object} briefingData - Briefing content from API
*/
show(briefingData) {
if (isActive) {
return; // Already showing
}
ensureBriefingElement();
populateBriefing(briefingData);
briefingElement.classList.remove('hidden');
briefingElement.classList.add('visible');
isActive = true;
// Auto-dismiss after duration
briefingTimer = setTimeout(() => {
dismiss();
}, BRIEFING_DURATION_MS);
console.log('[AmbientBriefing] Showing briefing');
},
/**
* Dismiss the morning briefing
*/
dismiss() {
if (!isActive) {
return;
}
if (briefingElement) {
briefingElement.classList.remove('visible');
briefingElement.classList.add('hidden');
}
isActive = false;
if (briefingTimer) {
clearTimeout(briefingTimer);
briefingTimer = null;
}
// Mark as shown for today
markAsShown();
if (onDismiss) {
onDismiss();
}
console.log('[AmbientBriefing] Dismissed');
},
/**
* Check if briefing should be shown today
* @returns {Promise<boolean>} - True if briefing should be shown
*/
async shouldShowToday() {
// Check if already shown today
if (hasShownToday) {
return false;
}
const lastShown = localStorage.getItem(LOCAL_STORAGE_KEY);
const today = new Date().toISOString().split('T')[0];
if (lastShown === today) {
hasShownToday = true;
return false;
}
// Check if it's morning
const hour = new Date().getHours();
if (hour < MORNING_START_HOUR || hour >= BRIEFING_END_HOUR) {
return false;
}
return true;
},
/**
* Fetch and show briefing from API
*/
async fetchAndShow() {
if (!(await this.shouldShowToday())) {
return;
}
try {
const today = new Date().toISOString().split('T')[0];
let briefingData;
if (onFetchBriefing) {
// Use custom fetch function
briefingData = await onFetchBriefing(today);
} else {
// Default fetch from API
const response = await fetch(`/api/briefing?date=${today}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
briefingData = await response.json();
}
this.show(briefingData);
} catch (error) {
console.error('[AmbientBriefing] Error fetching briefing:', error);
}
},
/**
* Set dismiss callback
* @param {Function} callback - Function to call when briefing is dismissed
*/
setOnDismiss(callback) {
onDismiss = callback;
},
/**
* Set fetch briefing callback
* @param {Function} callback - Function to fetch briefing data
*/
setOnFetchBriefing(callback) {
onFetchBriefing = callback;
},
/**
* Trigger first detection (call when first person detected)
*/
onFirstDetection() {
if (isFirstDetectionToday && !hasShownToday) {
isFirstDetectionToday = false;
this.fetchAndShow();
}
},
/**
* Reset the daily flag (for testing)
*/
resetDailyFlag() {
localStorage.removeItem(LOCAL_STORAGE_KEY);
hasShownToday = false;
isFirstDetectionToday = false;
}
};
// ============================================
// Internal Functions
// ============================================
function ensureBriefingElement() {
if (briefingElement) {
return;
}
// Check if element already exists in DOM
briefingElement = document.getElementById('ambient-briefing');
if (briefingElement) {
return;
}
// Create briefing element
briefingElement = document.createElement('div');
briefingElement.id = 'ambient-briefing';
briefingElement.className = 'ambient-briefing hidden';
briefingElement.innerHTML = `
<div class="ambient-briefing-content">
<div class="ambient-briefing-greeting" id="briefing-greeting"></div>
<div id="briefing-content" class="ambient-briefing-sections"></div>
<button class="ambient-briefing-dismiss" id="briefing-dismiss">Got it</button>
</div>
`;
document.body.appendChild(briefingElement);
}
function setupEventListeners() {
// Dismiss button
const dismissBtn = document.getElementById('briefing-dismiss');
if (dismissBtn) {
// Remove any existing listeners to avoid duplicates
const newBtn = dismissBtn.cloneNode(true);
dismissBtn.parentNode.replaceChild(newBtn, dismissBtn);
newBtn.addEventListener('click', () => {
AmbientBriefing.dismiss();
});
}
// Dismiss on tap/click outside content
if (briefingElement) {
briefingElement.addEventListener('click', (e) => {
if (e.target === briefingElement) {
AmbientBriefing.dismiss();
}
});
}
}
function populateBriefing(data) {
const greetingEl = document.getElementById('briefing-greeting');
const contentEl = document.getElementById('briefing-content');
if (!greetingEl || !contentEl) {
return;
}
// Set greeting based on time of day
const hour = new Date().getHours();
let greeting = 'Good morning';
if (hour < 12) {
greeting = 'Good morning';
} else if (hour < 17) {
greeting = 'Good afternoon';
} else {
greeting = 'Good evening';
}
greetingEl.textContent = greeting;
// Parse briefing content
const sections = parseBriefingContent(data);
contentEl.innerHTML = sections;
}
function parseBriefingContent(data) {
let html = '';
// Handle different briefing data formats
if (data.content) {
// Text content - parse for sections
const lines = data.content.split('\n').filter(line => line.trim());
// Group lines into sections
let currentSection = null;
let sectionContent = [];
lines.forEach(line => {
// Check if this is a section header
if (line.includes(':') && line.length < 50) {
// Save previous section
if (currentSection && sectionContent.length > 0) {
html += createBriefingSection(currentSection, sectionContent.join('<br>'));
}
// Start new section
const parts = line.split(':');
currentSection = parts[0].trim();
sectionContent = [parts.slice(1).join(':').trim()];
} else if (currentSection) {
sectionContent.push(line);
} else {
// No section yet, add as general content
if (!currentSection) {
currentSection = 'Summary';
sectionContent = [];
}
sectionContent.push(line);
}
});
// Don't forget the last section
if (currentSection && sectionContent.length > 0) {
html += createBriefingSection(currentSection, sectionContent.join('<br>'));
}
} else {
// Structured data - extract key information
if (data.sleep_summary) {
html += createBriefingSection('Sleep Summary', data.sleep_summary);
}
if (data.departures && data.departures.length > 0) {
const departuresText = data.departures.map(d =>
`${d.person}: likely leaves at ${d.time}`
).join('<br>');
html += createBriefingSection('Expected Departures', departuresText);
}
if (data.system_status) {
html += createBriefingSection('System Status', data.system_status);
}
if (data.accuracy) {
html += createBriefingSection('Accuracy', data.accuracy);
}
}
return html || '<div class="ambient-briefing-section">No briefing data available</div>';
}
function createBriefingSection(label, content) {
return `
<div class="ambient-briefing-section">
<div class="ambient-briefing-section-label">${label}</div>
<div class="ambient-briefing-section-value">${content}</div>
</div>
`;
}
function dismiss() {
AmbientBriefing.dismiss();
}
function markAsShown() {
const today = new Date().toISOString().split('T')[0];
localStorage.setItem(LOCAL_STORAGE_KEY, today);
hasShownToday = true;
}
function checkFirstDetectionToday() {
const hour = new Date().getHours();
if (hour >= MORNING_START_HOUR && hour < BRIEFING_END_HOUR) {
// It's morning - listen for first detection
isFirstDetectionToday = true;
} else {
isFirstDetectionToday = false;
}
// Check if already shown today
const lastShown = localStorage.getItem(LOCAL_STORAGE_KEY);
const today = new Date().toISOString().split('T')[0];
if (lastShown === today) {
hasShownToday = true;
isFirstDetectionToday = false;
}
}
// ============================================
// Export
// ============================================
window.SpaxelAmbientBriefing = AmbientBriefing;
console.log('[AmbientBriefing] Module loaded');
})();