alert-dashboard/index.html

369 lines
15 KiB
HTML
Raw 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 { width: 70%; 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: 22px; font-weight: 300; letter-spacing: 3px; margin-top: 6px; font-variant-numeric: tabular-nums; }
#digital-date { text-align: center; color: #666; font-size: 13px; font-weight: 300; letter-spacing: 1px; margin-top: 2px; }
/* 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: 16px; width: 28px; height: 28px; 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: 15px; 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: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; padding: 4px 0; }
#cal-grid .day { padding: 5px 0; font-size: 13px; font-weight: 300; 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: 700; }
#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: 3px; 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: 12px 40px 12px 16px; border-left: 2px solid #333; margin-bottom: 6px; 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: 16px; font-weight: 400; color: #ddd; line-height: 1.4; }
.alert-time { font-size: 11px; color: #888; margin-top: 4px; 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: #d4a050; }
.alert-remove { position: absolute; top: 10px; right: 12px; background: none; border: none; color: #333; font-size: 18px; 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; }
/* 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="cam-wrap"><img id="pulse-cam" alt="Pulse-Ox"></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="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' });
}
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;
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) {
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 });
// === PULSE-OX CAMERA (7pm - 8am only) ===
const camImg = document.getElementById('pulse-cam');
const camWrap = document.getElementById('cam-wrap');
function isCamTime() {
const h = new Date().getHours();
return h >= 19 || h < 8;
}
// Double-buffer: load in hidden img, swap on load (no flicker)
const bufImg = new Image();
bufImg.onload = () => { camImg.src = bufImg.src; };
function refreshCam() {
if (isCamTime()) {
camWrap.style.display = '';
bufImg.src = '/api/cam/pulse-ox?' + Date.now();
} else {
camWrap.style.display = 'none';
}
}
refreshCam();
setInterval(refreshCam, 1000);
</script>
</body>
</html>