alert-dashboard/index.html

671 lines
26 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1024, height=600, initial-scale=1">
<title>Watchboard</title>
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@300;400;600;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a0a; color: #e0ddd5; font-family: 'Sora', sans-serif; overflow: hidden; height: 100vh; display: flex; }
/* Layout */
#left { width: 58%; display: flex; flex-direction: column; padding: 16px 0 16px 20px; gap: 12px; }
#right { width: 42%; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 16px; gap: 12px; }
/* Camera */
#cam-wrap { flex: 1; border-radius: 8px; overflow: hidden; border: 1px solid #222; flex-shrink: 0; }
#pulse-cam { width: 100%; display: block; background: #111; }
/* Clock */
#clock-wrap { position: relative; }
canvas#clock { width: 180px; height: 180px; }
#digital-time { text-align: center; color: #c8b273; font-size: 21px; font-weight: 300; letter-spacing: 2px; margin-top: 6px; font-variant-numeric: tabular-nums; }
#digital-date { text-align: center; color: #999; font-size: 13px; font-weight: 400; letter-spacing: 1px; margin-top: 2px; }
#weather-line { text-align: center; font-size: 14px; font-weight: 300; color: #bbb; margin-top: 4px; letter-spacing: 1px; }
#current-temp { font-size: 16px; font-weight: 600; color: #e0ddd5; }
#weather-line .wx-alert { color: #ff2222; font-weight: 600; }
#claude-usage { text-align: center; font-size: 14px; font-weight: 900; margin-top: 6px; letter-spacing: 1px; font-variant-numeric: tabular-nums; padding: 2px 8px; }
#claude-usage.good { color: #00ffff; }
#claude-usage.ok { color: #ffff00; }
#claude-usage.warn { color: #ff00ff; }
#claude-usage.crit { color: #ff4444; }
#meeting-line { text-align: center; font-size: 13px; font-weight: 300; color: #bbb; margin-top: 8px; letter-spacing: 1px; }
#meeting-line.warning { color: #d4a050; }
#meeting-line.critical { color: #ff2222; animation: pulse 2s ease-in-out infinite; }
/* Cam + sensors row */
#cam-row { display: flex; align-items: stretch; gap: 14px; }
/* Room sensors */
#room-sensors { display: flex; flex-direction: column; gap: 12px; padding: 12px 16px; border: 1px solid #222; border-radius: 8px; align-self: stretch; justify-content: center; }
#room-sensors .sensor { text-align: center; }
#room-sensors .sensor-val { font-size: 26px; font-weight: 300; color: #e0ddd5; font-variant-numeric: tabular-nums; line-height: 1.1; }
#room-sensors .sensor-label { font-size: 9px; color: #c8b273; text-transform: uppercase; letter-spacing: 2px; font-weight: 600; }
#room-sensors .sensor-val.warn { color: #d4a050; }
#room-sensors .sensor-val.crit { color: #ff2222; text-shadow: 0 0 8px rgba(255,34,34,0.6); }
#room-sensors .sensor-val.cold { color: #4488ff; text-shadow: 0 0 8px rgba(68,136,255,0.6); }
/* Calendar */
#calendar { width: 100%; max-width: 280px; }
#cal-nav { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
#cal-nav button { background: none; border: 1px solid #333; color: #c8b273; font-family: 'Sora'; font-size: 15px; width: 26px; height: 26px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; }
#cal-nav button:hover { background: #1a1a1a; border-color: #c8b273; }
#cal-title { color: #c8b273; font-size: 14px; font-weight: 600; letter-spacing: 1px; }
#cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; text-align: center; }
#cal-grid .day-name { color: #555; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; padding: 4px 0; }
#cal-grid .day { padding: 5px 0; font-size: 13px; font-weight: 400; color: #444; border-radius: 50%; aspect-ratio: 1; display: flex; align-items: center; justify-content: center; }
#cal-grid .day.current-month { color: #999; }
#cal-grid .day.today { background: #c8b273; color: #0a0a0a; font-weight: 600; }
#cal-grid .day.sunday { color: #664; }
#cal-grid .day.current-month.sunday { color: #c8b273; opacity: 0.7; }
#cal-grid .day.today.sunday { color: #0a0a0a; opacity: 1; }
/* Alerts */
#alerts-section { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
#alerts-header { color: #c8b273; font-size: 12px; font-weight: 600; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 10px; display: flex; align-items: center; gap: 10px; }
#alerts-header .dot { width: 6px; height: 6px; border-radius: 50%; background: #c8b273; animation: pulse 3s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .2; } }
#alert-list { flex: 1; overflow-y: auto; scrollbar-width: none; }
#alert-list::-webkit-scrollbar { display: none; }
.alert-item { padding: 10px 36px 10px 14px; border-left: 2px solid #333; margin-bottom: 4px; background: #111; border-radius: 0 6px 6px 0; animation: slideIn .4s ease; position: relative; transition: all .3s; }
.alert-item.info { border-left-color: #c8b273; }
.alert-item.warning { border-left-color: #d4a050; }
.alert-item.critical { border-left-color: #c45; }
.alert-msg { font-size: 15px; font-weight: 400; color: #ddd; line-height: 1.45; }
.alert-time { font-size: 12px; color: #777; margin-top: 3px; font-weight: 400; }
@keyframes slideIn { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } }
.alert-item.critical .alert-msg { color: #e88; }
.alert-item.warning .alert-msg { color: #dbc085; }
.alert-remove { position: absolute; top: 8px; right: 10px; background: none; border: none; color: #333; font-size: 16px; cursor: pointer; padding: 4px 8px; line-height: 1; font-family: 'Sora'; }
.alert-remove:hover { color: #c45; }
.alert-item.done { opacity: 0.35; border-left-color: #2a2a2a !important; }
.alert-item.done .alert-msg { text-decoration: line-through; }
/* News streams */
#news-streams { display: flex; flex-direction: column; gap: 3px; margin-top: 8px; flex-shrink: 0; }
.news-bar { padding: 6px 12px; background: #151515; border-radius: 4px; display: flex; align-items: baseline; gap: 8px; overflow: hidden; }
.news-bar .topic-icon { font-size: 13px; flex-shrink: 0; }
.news-bar .topic-label { font-size: 10px; color: #c8b273; text-transform: uppercase; letter-spacing: 1.5px; font-weight: 600; flex-shrink: 0; min-width: 65px; }
.news-bar .news-text { font-size: 12px; color: #bbb; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; }
.news-bar .news-source { font-size: 10px; color: #666; flex-shrink: 0; margin-left: 6px; }
/* Divider line */
#right::before { content: ''; position: absolute; left: 0; top: 10%; height: 80%; width: 1px; background: linear-gradient(transparent, #333, transparent); }
#right { position: relative; }
</style>
</head>
<body>
<div id="left">
<div id="alerts-section">
<div id="alerts-header"><span class="dot"></span> STATUS</div>
<div id="alert-list"></div>
</div>
<div id="news-streams"></div>
<div id="cam-row">
<div id="cam-wrap"><img id="pulse-cam" alt="Pulse-Ox"></div>
<div id="room-sensors" style="display:none">
<div class="sensor"><div class="sensor-val" id="s-temp">--</div><div class="sensor-label">Temp</div></div>
<div class="sensor"><div class="sensor-val" id="s-hum">--</div><div class="sensor-label">Humidity</div></div>
<div class="sensor"><div class="sensor-val" id="s-co2">--</div><div class="sensor-label">CO₂</div></div>
</div>
</div>
</div>
<div id="right">
<div id="clock-wrap"><canvas id="clock" width="440" height="440"></canvas></div>
<div id="digital-time"></div>
<div id="digital-date"></div>
<div id="weather-line"></div>
<div id="claude-usage"></div>
<div id="meeting-line"></div>
<div id="calendar">
<div id="cal-nav">
<button id="cal-prev"></button>
<div id="cal-title"></div>
<button id="cal-next"></button>
</div>
<div id="cal-grid"></div>
</div>
</div>
<script>
// === CLOCK (Braun-inspired) ===
const canvas = document.getElementById('clock');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height, CX = W/2, CY = H/2, R = W/2 - 30;
const GOLD = '#c8b273';
const DIM = '#333';
const FACE = '#0a0a0a';
function drawClock() {
const now = new Date();
ctx.clearRect(0, 0, W, H);
// Outer ring
ctx.beginPath(); ctx.arc(CX, CY, R + 12, 0, Math.PI*2);
ctx.strokeStyle = '#222'; ctx.lineWidth = 1; ctx.stroke();
// Face circle
ctx.beginPath(); ctx.arc(CX, CY, R, 0, Math.PI*2);
ctx.strokeStyle = GOLD; ctx.lineWidth = 2; ctx.stroke();
// Hour numbers
ctx.font = '600 28px Sora';
ctx.fillStyle = '#bbb';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let i = 1; i <= 12; i++) {
const a = (i * Math.PI / 6) - Math.PI/2;
const nr = R - 32;
ctx.fillText(i.toString(), CX + Math.cos(a)*nr, CY + Math.sin(a)*nr);
}
// Minute ticks
for (let i = 0; i < 60; i++) {
const a = (i * Math.PI / 30) - Math.PI/2;
const outer = R - 6;
const inner = i % 5 === 0 ? R - 18 : R - 12;
ctx.beginPath();
ctx.moveTo(CX + Math.cos(a)*inner, CY + Math.sin(a)*inner);
ctx.lineTo(CX + Math.cos(a)*outer, CY + Math.sin(a)*outer);
ctx.strokeStyle = i % 5 === 0 ? GOLD : '#444';
ctx.lineWidth = i % 5 === 0 ? 2 : 1;
ctx.stroke();
}
const h = now.getHours(), m = now.getMinutes(), s = now.getSeconds();
// Hour hand — thick, short
const ha = ((h % 12) + m/60) * Math.PI/6 - Math.PI/2;
ctx.beginPath(); ctx.moveTo(CX, CY);
ctx.lineTo(CX + Math.cos(ha)*R*0.48, CY + Math.sin(ha)*R*0.48);
ctx.strokeStyle = '#ddd'; ctx.lineWidth = 6; ctx.lineCap = 'round'; ctx.stroke();
// Minute hand — thinner, longer
const ma = (m + s/60) * Math.PI/30 - Math.PI/2;
ctx.beginPath(); ctx.moveTo(CX, CY);
ctx.lineTo(CX + Math.cos(ma)*R*0.72, CY + Math.sin(ma)*R*0.72);
ctx.strokeStyle = '#ddd'; ctx.lineWidth = 3.5; ctx.lineCap = 'round'; ctx.stroke();
// Second hand — Braun yellow/gold, thin, with counterweight
const sa = s * Math.PI/30 - Math.PI/2;
// Counterweight
ctx.beginPath(); ctx.moveTo(CX, CY);
ctx.lineTo(CX - Math.cos(sa)*R*0.15, CY - Math.sin(sa)*R*0.15);
ctx.strokeStyle = GOLD; ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.stroke();
// Main
ctx.beginPath(); ctx.moveTo(CX, CY);
ctx.lineTo(CX + Math.cos(sa)*R*0.78, CY + Math.sin(sa)*R*0.78);
ctx.strokeStyle = GOLD; ctx.lineWidth = 1.5; ctx.stroke();
// Center dot
ctx.beginPath(); ctx.arc(CX, CY, 5, 0, Math.PI*2);
ctx.fillStyle = GOLD; ctx.fill();
// Digital time
document.getElementById('digital-time').textContent =
now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
document.getElementById('digital-date').textContent =
now.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
requestAnimationFrame(drawClock);
}
drawClock();
// === CALENDAR ===
let calYear, calMonth;
(function initCal() {
const now = new Date();
calYear = now.getFullYear();
calMonth = now.getMonth();
renderCalendar();
})();
document.getElementById('cal-prev').addEventListener('click', () => { calMonth--; if (calMonth < 0) { calMonth = 11; calYear--; } renderCalendar(); });
document.getElementById('cal-next').addEventListener('click', () => { calMonth++; if (calMonth > 11) { calMonth = 0; calYear++; } renderCalendar(); });
function renderCalendar() {
const now = new Date();
const isCurrentMonth = calYear === now.getFullYear() && calMonth === now.getMonth();
document.getElementById('cal-title').textContent =
new Date(calYear, calMonth).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
const grid = document.getElementById('cal-grid');
grid.innerHTML = '';
['S','M','T','W','T','F','S'].forEach(d => {
const el = document.createElement('div');
el.className = 'day-name'; el.textContent = d; grid.appendChild(el);
});
const first = new Date(calYear, calMonth, 1).getDay();
const days = new Date(calYear, calMonth + 1, 0).getDate();
const today = now.getDate();
const prevDays = new Date(calYear, calMonth, 0).getDate();
for (let i = first - 1; i >= 0; i--) {
const el = document.createElement('div');
el.className = 'day'; el.textContent = prevDays - i; grid.appendChild(el);
}
for (let d = 1; d <= days; d++) {
const el = document.createElement('div');
const dow = new Date(calYear, calMonth, d).getDay();
let cls = 'day current-month';
if (isCurrentMonth && d === today) cls += ' today';
if (dow === 0) cls += ' sunday';
el.className = cls;
el.textContent = d; grid.appendChild(el);
}
const total = first + days;
for (let i = 1; i <= (7 - total % 7) % 7; i++) {
const el = document.createElement('div');
el.className = 'day'; el.textContent = i; grid.appendChild(el);
}
}
// Reset to current month at midnight
setInterval(() => {
const now = new Date();
if (now.getHours() === 0 && now.getMinutes() === 0) {
calYear = now.getFullYear(); calMonth = now.getMonth(); renderCalendar();
}
}, 60000);
// === NOTIFICATION SOUND ===
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
function pling() {
// Warm two-tone chime
[880, 1320].forEach((freq, i) => {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain); gain.connect(audioCtx.destination);
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, audioCtx.currentTime + i * 0.08);
gain.gain.setValueAtTime(0.1, audioCtx.currentTime + i * 0.08);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + i * 0.08 + 0.5);
osc.start(audioCtx.currentTime + i * 0.08);
osc.stop(audioCtx.currentTime + i * 0.08 + 0.5);
});
}
// === ALERTS ===
const alertList = document.getElementById('alert-list');
function formatTime(ts) {
const d = new Date(ts);
const now = new Date();
const diffMs = now - d;
if (diffMs < 60000) return 'just now';
if (diffMs < 3600000) return Math.floor(diffMs/60000) + 'm ago';
if (diffMs < 86400000) return Math.floor(diffMs/3600000) + 'h ago';
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' +
d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
}
// Distinct, obvious background colors for groups
const GROUP_PALETTE = [
'hsla(300, 70%, 22%, 0.9)', // magenta
'hsla(180, 70%, 18%, 0.9)', // teal/cyan
'hsla(50, 80%, 20%, 0.9)', // gold/yellow
'hsla(260, 60%, 22%, 0.9)', // purple
'hsla(140, 60%, 18%, 0.9)', // green
'hsla(20, 70%, 22%, 0.9)', // orange
'hsla(340, 60%, 22%, 0.9)', // rose
'hsla(200, 70%, 20%, 0.9)', // blue
'hsla(90, 60%, 18%, 0.9)', // lime
'hsla(0, 60%, 22%, 0.9)', // red
];
const groupColorMap = {};
let nextColorIdx = 0;
function getGroupColor(group) {
if (!group) return null;
if (groupColorMap[group]) return groupColorMap[group];
groupColorMap[group] = GROUP_PALETTE[nextColorIdx % GROUP_PALETTE.length];
nextColorIdx++;
return groupColorMap[group];
}
function renderAlert(alert, prepend = false) {
const div = document.createElement('div');
div.className = 'alert-item ' + (alert.priority || 'info') + (alert.done ? ' done' : '');
div.dataset.id = alert.id;
if (alert.group) {
div.style.background = getGroupColor(alert.group);
div.dataset.group = alert.group;
}
div.innerHTML = `<button class="alert-remove" onclick="removeAlert('${alert.id}', this.parentElement)">&times;</button><div class="alert-msg">${escHtml(alert.message)}</div><div class="alert-time">${formatTime(alert.timestamp)}</div>`;
// Long-press (300ms) to toggle done
let holdTimer = null;
const startHold = (e) => {
holdTimer = setTimeout(() => {
holdTimer = null;
const isDone = div.classList.toggle('done');
// Persist done state to server
fetch('/api/alerts/' + alert.id + '/done', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ done: isDone })
}).catch(() => {});
pling();
}, 300);
};
const cancelHold = () => { if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; } };
div.addEventListener('touchstart', startHold, { passive: true });
div.addEventListener('touchend', cancelHold);
div.addEventListener('touchmove', cancelHold);
div.addEventListener('mousedown', startHold);
div.addEventListener('mouseup', cancelHold);
div.addEventListener('mouseleave', cancelHold);
if (prepend) alertList.prepend(div);
else alertList.appendChild(div);
while (alertList.children.length > 15) alertList.lastChild.remove();
}
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function removeAlert(id, el) {
const group = el.dataset.group;
if (group) {
// Dismiss all alerts in this group
const siblings = document.querySelectorAll(`.alert-item[data-group="${CSS.escape(group)}"]`);
siblings.forEach(sib => {
sib.style.opacity = '0'; sib.style.transform = 'translateX(30px)'; sib.style.transition = 'all .3s';
fetch('/api/alerts/' + sib.dataset.id, { method: 'DELETE' });
setTimeout(() => sib.remove(), 300);
});
} else {
el.style.opacity = '0'; el.style.transform = 'translateX(30px)'; el.style.transition = 'all .3s';
fetch('/api/alerts/' + id, { method: 'DELETE' });
setTimeout(() => el.remove(), 300);
}
}
// Update relative times every 30s
setInterval(() => {
document.querySelectorAll('.alert-item').forEach(el => {
const id = el.dataset.id;
// Would need stored timestamps; refresh page every hour instead
});
}, 30000);
// Auto-refresh page every 2 hours to keep times fresh
setTimeout(() => location.reload(), 7200000);
// Load existing
fetch('/api/alerts').then(r => r.json()).then(alerts => {
alerts.slice(0, 15).forEach(a => renderAlert(a));
});
// SSE
const evtSource = new EventSource('/api/alerts/stream');
evtSource.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.type === 'connected') return;
if (data.type === 'remove') {
const el = document.querySelector(`.alert-item[data-id="${data.id}"]`);
if (el) { el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }
return;
}
renderAlert(data, true);
pling();
};
evtSource.onerror = () => { setTimeout(() => location.reload(), 5000); };
// Resume audio on first interaction
document.addEventListener('click', () => { if (audioCtx.state === 'suspended') audioCtx.resume(); }, { once: true });
// === WEATHER ===
async function updateWeather() {
try {
const r = await fetch('/api/weather');
const d = await r.json();
let html = '';
if (d.current) {
html = `<span id="current-temp">${d.current}°F (${d.currentC}°C)</span> · ${d.low}${d.high}°F`;
} else {
html = `${d.low}${d.high}°F`;
}
if (d.alerts && d.alerts.length) {
html += ' <span class="wx-alert">⚠️ ' + d.alerts.join(', ') + '</span>';
}
if (d.tropical) {
html += ' <span class="wx-alert">🌀 ' + d.tropical + '</span>';
}
document.getElementById('weather-line').innerHTML = html;
} catch(e) {}
}
updateWeather();
setInterval(updateWeather, 60000); // every minute
// === CLAUDE BUDGET PACE — always visible ===
async function updateClaudeUsage() {
const el = document.getElementById('claude-usage');
try {
const r = await fetch('/api/claude-usage');
const d = await r.json();
if (d.pace != null && d.timePct > 0) {
el.textContent = `pace ${d.pace}%`;
el.title = `Budget pace: ${d.timePct}% of week elapsed, ${d.usage}% used`;
el.className = d.pace < 90 ? 'good' : d.pace <= 100 ? 'ok' : 'crit';
} else {
el.textContent = '';
el.className = '';
}
} catch(e) {
el.textContent = '';
el.className = '';
}
}
updateClaudeUsage();
setInterval(updateClaudeUsage, 300000); // every 5 min (no need to be faster)
// === NEWS STREAMS ===
const topicEmoji = { markets: '🏦', ai: '🤖', news: '📰', nabl: '📉', politics: '🏛️', infrastructure: '🔧', netherlands: '🇳🇱' };
const topicOrder = ['markets', 'ai', 'news', 'nabl', 'politics', 'netherlands'];
async function updateNewsStreams() {
const el = document.getElementById('news-streams');
try {
const r = await fetch('http://localhost:9200/api/news');
const d = await r.json();
const items = d.items || [];
// Group by topic
const byTopic = {};
for (const item of items) {
if (!item.topic) continue;
if (!byTopic[item.topic]) byTopic[item.topic] = [];
byTopic[item.topic].push(item);
}
let html = '';
for (const topic of topicOrder) {
const topicItems = byTopic[topic];
if (!topicItems || !topicItems.length) continue;
const latest = topicItems[0]; // newest first
const icon = topicEmoji[topic] || '📌';
const src = latest.source ? `<span class="news-source">${escHtml(latest.source)}</span>` : '';
html += `<div class="news-bar"><span class="topic-icon">${icon}</span><span class="topic-label">${topic}</span><span class="news-text">${escHtml(latest.title)}</span>${src}</div>`;
}
el.innerHTML = html;
} catch(e) {
console.error('News fetch failed:', e);
}
}
updateNewsStreams();
setInterval(updateNewsStreams, 300000); // every 5 min
// === MEETING COUNTDOWN ===
let currentMeeting = null;
let alertsFired = new Set(); // Track which alerts have been fired
function createBeepSequence(count, duration = 3000) {
// Enhanced beep function for meeting alerts
const baseFreq = 880;
for (let i = 0; i < count; i++) {
setTimeout(() => {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain); gain.connect(audioCtx.destination);
osc.type = 'sine';
osc.frequency.setValueAtTime(baseFreq, audioCtx.currentTime);
gain.gain.setValueAtTime(0.15, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.3);
osc.start(audioCtx.currentTime);
osc.stop(audioCtx.currentTime + 0.3);
}, i * 400);
}
}
function createContinuousAlert() {
// 3-second continuous tone for meeting start
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain); gain.connect(audioCtx.destination);
osc.type = 'square';
osc.frequency.setValueAtTime(660, audioCtx.currentTime);
gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 3);
osc.start(audioCtx.currentTime);
osc.stop(audioCtx.currentTime + 3);
}
function formatMinutesUntil(minutes) {
if (minutes < 1) return 'starting now';
if (minutes < 60) return `in ${Math.floor(minutes)}m`;
const hours = Math.floor(minutes / 60);
const mins = Math.floor(minutes % 60);
if (hours >= 24) {
const days = Math.floor(hours / 24);
return `in ${days}d`;
}
return mins > 0 ? `in ${hours}h ${mins}m` : `in ${hours}h`;
}
async function updateMeeting() {
try {
const r = await fetch('/api/meeting');
const meeting = await r.json();
if (!meeting) {
document.getElementById('meeting-line').textContent = '';
currentMeeting = null;
alertsFired.clear();
return;
}
const now = new Date();
const meetingTime = new Date(meeting.time);
const minutesUntil = (meetingTime - now) / 60000;
// Clear alerts if this is a new meeting
if (!currentMeeting || currentMeeting.id !== meeting.id) {
alertsFired.clear();
currentMeeting = meeting;
}
// Format display
const timeStr = meetingTime.toLocaleTimeString('en-US', {
hour: '2-digit', minute: '2-digit', hour12: true
});
const countdownStr = formatMinutesUntil(minutesUntil);
const meetingEl = document.getElementById('meeting-line');
meetingEl.textContent = `📅 ${meeting.title} · ${timeStr} · ${countdownStr}`;
// Apply warning/critical styles and beep alerts
meetingEl.className = 'meeting-line';
if (minutesUntil <= 0 && !alertsFired.has('start')) {
meetingEl.className += ' critical';
createContinuousAlert();
alertsFired.add('start');
} else if (minutesUntil <= 1 && !alertsFired.has('1min')) {
meetingEl.className += ' critical';
createBeepSequence(3);
alertsFired.add('1min');
} else if (minutesUntil <= 5 && !alertsFired.has('5min')) {
meetingEl.className += ' critical';
createBeepSequence(2);
alertsFired.add('5min');
} else if (minutesUntil <= 10 && !alertsFired.has('10min')) {
meetingEl.className += ' warning';
createBeepSequence(1);
alertsFired.add('10min');
} else if (minutesUntil <= 10) {
meetingEl.className += ' warning';
} else if (minutesUntil <= 5) {
meetingEl.className += ' critical';
}
} catch(e) {
document.getElementById('meeting-line').textContent = '';
}
}
updateMeeting();
setInterval(updateMeeting, 1000); // Update every second for countdown
// === ROOM SENSORS (7pm - 8am only) ===
const sensorEl = document.getElementById('room-sensors');
function isNightTime() {
const h = new Date().getHours();
return h >= 19 || h < 8;
}
async function updateSensors() {
if (!isNightTime()) { sensorEl.style.display = 'none'; return; }
sensorEl.style.display = '';
try {
const r = await fetch('/api/sensors/bed1');
const d = await r.json();
const tempF = parseFloat(d.temperature);
const tempC = ((tempF - 32) * 5/9).toFixed(1);
const tempEl = document.getElementById('s-temp');
tempEl.textContent = tempF.toFixed(1) + '°';
tempEl.title = tempC + '°C';
tempEl.className = 'sensor-val' + (tempF < 73 ? ' cold' : tempF > 76 ? ' crit' : '');
const hum = parseFloat(d.humidity);
const humEl = document.getElementById('s-hum');
humEl.textContent = Math.round(hum) + '%';
humEl.className = 'sensor-val' + (hum < 30 || hum > 65 ? ' warn' : '');
const co2 = parseInt(d.co2);
const co2El = document.getElementById('s-co2');
co2El.textContent = co2;
co2El.className = 'sensor-val' + (co2 > 1100 ? ' crit' : co2 > 900 ? ' warn' : '');
} catch(e) { console.error('Sensor fetch failed:', e); }
}
updateSensors();
setInterval(updateSensors, 30000);
// === PULSE-OX CAMERA (7pm - 8am only) ===
const camImg = document.getElementById('pulse-cam');
const camWrap = document.getElementById('cam-wrap');
function updateCam() {
if (isNightTime()) {
camWrap.style.display = '';
if (!camImg.src.includes('/stream')) {
camImg.src = '/api/cam/pulse-ox/stream';
}
} else {
camWrap.style.display = 'none';
camImg.src = '';
}
}
updateCam();
setInterval(updateCam, 60000);
</script>
</body>
</html>