spaxel/dashboard/js/sleep.js
jedarden 6bf1e0394a feat(explainability): detection explainability overlay with per-link contributions, Fresnel zones, and BLE identity
Implements the full explainability overlay for understanding why a blob was detected:
- ExplainabilitySnapshot generation with per-link contribution tracking and zone decay
- Fresnel zone ellipsoid geometry computation and 3D wireframe rendering
- WebSocket request_explain / blob_explain flow for on-demand snapshots
- Right-click, long-press, click, and hover tooltip activation paths
- X-ray overlay dims non-contributing elements, highlights contributing links
- Sidebar panel with confidence gauge, links table, sparklines, BLE match card
- Escape key and backdrop click to exit, restoring scene state

Also includes: simple mode removal, CSS cleanup, fleet page enhancements, sidebar timeline fixes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 19:23:55 -04:00

500 lines
21 KiB
JavaScript

/**
* Spaxel Sleep Quality Monitoring UI
*
* Handles: morning summary card, sleep panel with weekly trends,
* and live sleep session display.
*/
(function() {
'use strict';
// ── module state ──────────────────────────────────────────────────────────
let _currentSummary = null; // Most recent morning summary
let _weeklyTrends = null; // Weekly trends data
let _sleepRecords = []; // Historical sleep records
let _summaryDismissed = false; // Whether morning summary was dismissed this session
let _panelVisible = false; // Whether sleep panel is showing
// DOM element cache
let _summaryCardEl = null;
let _sleepPanelEl = null;
// ── initialization ────────────────────────────────────────────────────────
function init() {
ensureSummaryCard();
ensureSleepPanel();
console.log('[Sleep] Module initialized');
}
// ── Morning Summary Card ────────────────────────────────────────────────
function ensureSummaryCard() {
if (document.getElementById('sleep-summary-card')) return;
const card = document.createElement('div');
card.id = 'sleep-summary-card';
card.className = 'sleep-summary-card hidden';
card.innerHTML = `
<div class="sleep-summary-header">
<span class="sleep-summary-icon">
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M17.75,4.09L15.22,6.03L16.13,9.09L13.5,7.28L10.87,9.09L11.78,6.03L9.25,4.09L12.44,4L13.5,1L14.56,4L17.75,4.09M21.25,11L19.61,12.25L20.2,14.23L18.5,13.06L16.8,14.23L17.39,12.25L15.75,11L17.81,10.95L18.5,9L19.19,10.95L21.25,11M18.97,15.95C19.8,15.87 20.69,17.05 20.16,17.8C19.84,18.25 19.17,18.7 18.46,18.7H14.5L18.27,21.63C18.5,21.8 18.5,22.12 18.27,22.29C18.1,22.5 17.77,22.5 17.56,22.29L12.5,18.25L7.44,22.29C7.23,22.5 6.9,22.5 6.73,22.29C6.5,22.12 6.5,21.8 6.73,21.63L10.5,18.7H6.54C5.83,18.7 5.16,18.25 4.84,17.8C4.31,17.05 5.2,15.87 6.03,15.95L8.25,16.13L9.37,14.5L7.5,13.87L4.5,14.57L3.86,12.24L7.5,11.37L11.25,10.5L12.5,8.16L13.75,10.5L17.5,11.37L21.14,12.24L20.5,14.57L17.5,13.87L18.63,15.5L18.97,15.95Z"/>
</svg>
</span>
<span class="sleep-summary-title">Last Night's Sleep</span>
<button class="sleep-summary-dismiss" title="Dismiss">&times;</button>
</div>
<div class="sleep-summary-body">
<div class="sleep-summary-duration"></div>
<div class="sleep-summary-efficiency"></div>
<div class="sleep-summary-wake-episodes"></div>
<div class="sleep-summary-breathing"></div>
<div class="sleep-summary-anomaly hidden"></div>
<button class="sleep-summary-details-btn hidden">View full sleep report</button>
</div>
`;
document.body.appendChild(card);
_summaryCardEl = card;
// Dismiss button
card.querySelector('.sleep-summary-dismiss').addEventListener('click', function() {
dismissSummary();
});
// View details button
card.querySelector('.sleep-summary-details-btn').addEventListener('click', function() {
showSleepPanel();
});
}
/**
* Show morning summary card with data from the backend.
* @param {Object} report - Sleep report data from /api/sleep/summary or WebSocket morning_summary message
*/
function showMorningSummary(report) {
if (!report) return;
_currentSummary = report;
_summaryDismissed = false;
ensureSummaryCard();
const card = _summaryCardEl;
const metrics = report.metrics || {};
// Duration
const durationEl = card.querySelector('.sleep-summary-duration');
if (metrics.total_duration_hours) {
const hours = Math.floor(metrics.total_duration_hours);
const mins = Math.round((metrics.total_duration_hours - hours) * 60);
durationEl.textContent = 'Last night: ' + hours + 'h ' + mins + 'm';
} else if (report.time_in_bed_hours) {
const hours = Math.floor(report.time_in_bed_hours);
const mins = Math.round((report.time_in_bed_hours - hours) * 60);
durationEl.textContent = 'Last night: ' + hours + 'h ' + mins + 'm in bed';
}
// Efficiency with color indicator
const effEl = card.querySelector('.sleep-summary-efficiency');
const efficiency = metrics.sleep_efficiency || metrics.overall_score || 0;
let effColor = 'red';
let effLabel = 'Poor';
if (efficiency >= 85) { effColor = 'green'; effLabel = 'Good'; }
else if (efficiency >= 70) { effColor = 'amber'; effLabel = 'Fair'; }
effEl.innerHTML = '<span class="sleep-efficiency-dot ' + effColor + '"></span> Sleep efficiency: ' + efficiency.toFixed(0) + '% (' + effLabel + ')';
// Wake episodes
const wakeEl = card.querySelector('.sleep-summary-wake-episodes');
const wakeCount = metrics.wake_episode_count || 0;
const waso = metrics.waso_minutes || 0;
if (wakeCount > 0) {
wakeEl.textContent = wakeCount + ' wake episode' + (wakeCount !== 1 ? 's' : '') + ', ' + Math.round(waso) + ' min awake after onset';
} else {
wakeEl.textContent = 'No wake episodes detected';
}
// Breathing
const breathEl = card.querySelector('.sleep-summary-breathing');
const avgBPM = metrics.avg_breathing_rate || 0;
if (avgBPM > 0) {
breathEl.textContent = 'Average breathing: ' + avgBPM.toFixed(1) + ' breaths/min';
} else {
breathEl.textContent = 'No breathing data available';
}
// Anomaly note
const anomalyEl = card.querySelector('.sleep-summary-anomaly');
if (metrics.breathing_anomaly || (metrics.breathing_anomaly_count > 0)) {
anomalyEl.classList.remove('hidden');
anomalyEl.innerHTML = '<span class="sleep-anomaly-warning">Unusual breathing detected</span>' +
(metrics.personal_avg_bpm ? ' (' + avgBPM.toFixed(0) + ' bpm vs. ' + metrics.personal_avg_bpm.toFixed(0) + ' bpm average)' : '');
} else {
anomalyEl.classList.add('hidden');
}
const detailsBtn = card.querySelector('.sleep-summary-details-btn');
if (detailsBtn) {
detailsBtn.classList.remove('hidden');
}
// Show the card
card.classList.remove('hidden');
}
function dismissSummary() {
_summaryDismissed = true;
if (_summaryCardEl) {
_summaryCardEl.classList.add('hidden');
}
}
// ── Sleep Panel ──────────────────────────────────────────────────────────
function ensureSleepPanel() {
if (document.getElementById('sleep-panel')) return;
const panel = document.createElement('div');
panel.id = 'sleep-panel';
panel.className = 'sleep-panel hidden';
panel.innerHTML = `
<div class="sleep-panel-header">
<h3>Sleep Monitoring</h3>
<button class="sleep-panel-close" title="Close">&times;</button>
</div>
<div class="sleep-panel-content">
<div class="sleep-panel-section">
<h4>Weekly Trends</h4>
<div class="sleep-trends-container">
<div class="sleep-trend-row">
<span class="sleep-trend-label">Sleep Duration</span>
<div class="sleep-sparkline" id="sleep-duration-sparkline"></div>
<span class="sleep-trend-value" id="sleep-duration-avg"></span>
</div>
<div class="sleep-trend-row">
<span class="sleep-trend-label">Sleep Efficiency</span>
<div class="sleep-sparkline" id="sleep-efficiency-sparkline"></div>
<span class="sleep-trend-value" id="sleep-efficiency-avg"></span>
</div>
</div>
<div class="sleep-week-comparison" id="sleep-week-comparison"></div>
</div>
<div class="sleep-panel-section">
<h4>Breathing</h4>
<div class="sleep-breathing-stats">
<div class="sleep-stat">
<span class="sleep-stat-label">Average Rate</span>
<span class="sleep-stat-value" id="sleep-avg-breathing"></span>
</div>
<div class="sleep-stat">
<span class="sleep-stat-label">Variability</span>
<span class="sleep-stat-value" id="sleep-breathing-variability"></span>
</div>
</div>
</div>
<div class="sleep-panel-section">
<h4>Recent Nights</h4>
<div class="sleep-history" id="sleep-history-list"></div>
</div>
</div>
`;
document.body.appendChild(panel);
_sleepPanelEl = panel;
// Close button
panel.querySelector('.sleep-panel-close').addEventListener('click', function() {
hideSleepPanel();
});
}
function showSleepPanel() {
ensureSleepPanel();
_sleepPanelEl.classList.remove('hidden');
_panelVisible = true;
fetchSleepData();
}
function hideSleepPanel() {
if (_sleepPanelEl) {
_sleepPanelEl.classList.add('hidden');
}
_panelVisible = false;
}
function toggleSleepPanel() {
if (_panelVisible) {
hideSleepPanel();
} else {
showSleepPanel();
}
}
// ── Data Fetching ────────────────────────────────────────────────────────
function fetchSleepData() {
fetchSleepRecords();
fetchWeeklyTrends();
}
function fetchSleepRecords() {
fetch('/api/sleep?limit=14')
.then(function(r) { return r.json(); })
.then(function(records) {
_sleepRecords = records || [];
renderHistory();
})
.catch(function(e) {
console.warn('[Sleep] Failed to fetch sleep records:', e);
});
}
function fetchWeeklyTrends() {
fetch('/api/sleep/summary')
.then(function(r) { return r.json(); })
.then(function(summary) {
if (summary) {
_currentSummary = summary;
renderBreathingStats(summary);
}
})
.catch(function(e) {
console.warn('[Sleep] Failed to fetch sleep summary:', e);
});
// Fetch weekly trends from storage
fetch('/api/sleep/reports')
.then(function(r) { return r.json(); })
.then(function(reports) {
if (reports) {
renderWeeklyTrends(reports);
}
})
.catch(function(e) {
console.warn('[Sleep] Failed to fetch weekly trends:', e);
});
}
// ── Rendering ────────────────────────────────────────────────────────────
function renderWeeklyTrends(reports) {
if (!reports || typeof reports !== 'object') return;
// Extract per-link reports into arrays sorted by date
const entries = [];
for (var linkID in reports) {
var r = reports[linkID];
if (r && r.metrics) {
entries.push({
date: r.session_date || linkID,
duration: (r.metrics.total_duration_hours || r.metrics.time_in_bed_hours || 0) * 60,
efficiency: r.metrics.sleep_efficiency || r.metrics.overall_score || 0,
breathing: r.metrics.avg_breathing_rate || 0
});
}
}
if (entries.length === 0) return;
// Sort by date
entries.sort(function(a, b) { return (a.date > b.date) - (a.date < b.date); });
// Duration sparkline
renderSparkline('sleep-duration-sparkline', entries.map(function(e) { return e.duration; }), 'min');
var avgDuration = entries.reduce(function(s, e) { return s + e.duration; }, 0) / entries.length;
var durH = Math.floor(avgDuration / 60);
var durM = Math.round(avgDuration % 60);
var durAvgEl = document.getElementById('sleep-duration-avg');
if (durAvgEl) durAvgEl.textContent = durH + 'h ' + durM + 'm avg';
// Efficiency sparkline
renderSparkline('sleep-efficiency-sparkline', entries.map(function(e) { return e.efficiency; }), '%');
var avgEff = entries.reduce(function(s, e) { return s + e.efficiency; }, 0) / entries.length;
var effAvgEl = document.getElementById('sleep-efficiency-avg');
if (effAvgEl) effAvgEl.textContent = avgEff.toFixed(0) + '% avg';
// Average breathing rate
var breathEntries = entries.filter(function(e) { return e.breathing > 0; });
if (breathEntries.length > 0) {
var avgBreath = breathEntries.reduce(function(s, e) { return s + e.breathing; }, 0) / breathEntries.length;
var breathAvgEl = document.getElementById('sleep-avg-breathing');
if (breathAvgEl) breathAvgEl.textContent = avgBreath.toFixed(1) + ' bpm';
}
// Week comparison
var compEl = document.getElementById('sleep-week-comparison');
if (compEl && entries.length >= 7) {
var thisWeek = entries.slice(-7);
var lastWeek = entries.slice(-14, -7);
if (lastWeek.length > 0) {
var thisAvg = thisWeek.reduce(function(s, e) { return s + e.duration; }, 0) / thisWeek.length;
var lastAvg = lastWeek.reduce(function(s, e) { return s + e.duration; }, 0) / lastWeek.length;
var diff = thisAvg - lastAvg;
var sign = diff >= 0 ? '+' : '';
var diffH = Math.floor(Math.abs(diff) / 60);
var diffM = Math.round(Math.abs(diff) % 60);
compEl.textContent = 'This week you slept ' + Math.floor(thisAvg / 60) + 'h ' + Math.round(thisAvg % 60) + 'm on average (vs. ' + Math.floor(lastAvg / 60) + 'h ' + Math.round(lastAvg % 60) + 'm last week, ' + sign + diffH + 'h ' + diffM + 'm)';
}
}
}
function renderSparkline(containerId, values, unit) {
var container = document.getElementById(containerId);
if (!container || values.length === 0) return;
// Clear previous sparkline
container.innerHTML = '';
var width = 120;
var height = 30;
var max = Math.max.apply(null, values);
var min = Math.min.apply(null, values);
var range = max - min || 1;
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 ' + width + ' ' + height);
svg.setAttribute('class', 'sleep-sparkline-svg');
// Build polyline points
var points = values.map(function(v, i) {
var x = (i / Math.max(1, values.length - 1)) * (width - 4) + 2;
var y = height - 2 - ((v - min) / range) * (height - 4);
return x.toFixed(1) + ',' + y.toFixed(1);
}).join(' ');
var polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
polyline.setAttribute('points', points);
polyline.setAttribute('fill', 'none');
polyline.setAttribute('stroke', '#4a9eff');
polyline.setAttribute('stroke-width', '1.5');
polyline.setAttribute('stroke-linecap', 'round');
polyline.setAttribute('stroke-linejoin', 'round');
svg.appendChild(polyline);
// Latest value dot
if (values.length > 0) {
var lastVal = values[values.length - 1];
var lx = width - 2;
var ly = height - 2 - ((lastVal - min) / range) * (height - 4);
var dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
dot.setAttribute('cx', lx);
dot.setAttribute('cy', ly);
dot.setAttribute('r', '2');
dot.setAttribute('fill', '#4a9eff');
svg.appendChild(dot);
}
container.appendChild(svg);
}
function renderBreathingStats(summary) {
if (!summary) return;
var metrics = summary.metrics || summary;
var avgEl = document.getElementById('sleep-avg-breathing');
if (avgEl && (metrics.avg_breathing_rate || metrics.breathing_rate_avg)) {
var rate = metrics.avg_breathing_rate || metrics.breathing_rate_avg;
avgEl.textContent = rate.toFixed(1) + ' bpm';
}
var varEl = document.getElementById('sleep-breathing-variability');
if (varEl && metrics.breathing_regularity !== undefined) {
var reg = metrics.breathing_regularity;
var label = 'Regular';
if (reg > 0.25) label = 'Irregular';
else if (reg > 0.10) label = 'Moderate';
varEl.textContent = reg.toFixed(2) + ' CV (' + label + ')';
}
}
function renderHistory() {
var listEl = document.getElementById('sleep-history-list');
if (!listEl) return;
listEl.innerHTML = '';
if (_sleepRecords.length === 0) {
listEl.innerHTML = '<div class="sleep-history-empty">No sleep data yet. Sleep monitoring requires a bedroom zone with stationary detection.</div>';
return;
}
_sleepRecords.forEach(function(rec) {
var row = document.createElement('div');
row.className = 'sleep-history-row';
var date = rec.date || '';
var duration = '';
if (rec.duration_min) {
var h = Math.floor(rec.duration_min / 60);
var m = rec.duration_min % 60;
duration = h + 'h ' + m + 'm';
}
var effColor = 'red';
if (rec.breathing_regularity !== undefined) {
// Use regularity as a rough proxy if efficiency not available
effColor = 'amber';
}
var breathing = '';
if (rec.breathing_rate_avg) {
breathing = rec.breathing_rate_avg.toFixed(1) + ' bpm';
}
row.innerHTML =
'<span class="sleep-history-date">' + date + '</span>' +
'<span class="sleep-history-duration">' + duration + '</span>' +
'<span class="sleep-history-breathing">' + breathing + '</span>';
listEl.appendChild(row);
});
}
// ── WebSocket Message Handler ────────────────────────────────────────────
/**
* Handle a morning_summary WebSocket message.
* Called from app.js handleJSONMessage when msg.type === 'morning_summary'.
* @param {Object} msg - { type: 'morning_summary', report: { ... } }
*/
function handleMorningSummary(msg) {
// Backend sends "sleep" field (from BroadcastMorningSummary in hub.go)
var report = msg.report || msg.sleep;
if (report) {
showMorningSummary(report);
}
}
/**
* Handle a sleep_status WebSocket message.
* @param {Object} msg - { type: 'sleep_status', data: { ... } }
*/
function handleSleepStatus(msg) {
if (msg.data && _panelVisible) {
// Update live breathing rate in panel if visible
var states = msg.data.link_states || {};
for (var linkID in states) {
var ls = states[linkID];
if (ls.current_breathing_rate > 0) {
var avgEl = document.getElementById('sleep-avg-breathing');
if (avgEl) avgEl.textContent = ls.current_breathing_rate.toFixed(1) + ' bpm (live)';
}
}
}
}
// ── Public API ───────────────────────────────────────────────────────────
window.SpaxelSleep = {
init: init,
showMorningSummary: showMorningSummary,
dismissSummary: dismissSummary,
showPanel: showSleepPanel,
hidePanel: hideSleepPanel,
togglePanel: toggleSleepPanel,
handleMorningSummary: handleMorningSummary,
handleSleepStatus: handleSleepStatus,
fetchSleepData: fetchSleepData
};
// Auto-initialize on load
init();
})();