chore: auto-commit uncommitted changes

This commit is contained in:
James 2026-02-17 18:00:59 -05:00
parent 1b2c0b51c4
commit baf49db235
2 changed files with 344 additions and 21 deletions

View File

@ -21,9 +21,18 @@ body { background: #0a0a0a; color: #e0ddd5; font-family: 'Sora', sans-serif; ove
#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: #666; font-size: 13px; font-weight: 400; letter-spacing: 1px; margin-top: 2px; }
#weather-line { text-align: center; font-size: 13px; font-weight: 300; color: #888; margin-top: 4px; letter-spacing: 1px; }
#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; border-radius: 4px; }
#claude-usage.good { color: #000; background: #00ffff; }
#claude-usage.ok { color: #000; background: #ffff00; }
#claude-usage.warn { color: #000; background: #ff00ff; }
#claude-usage.crit { color: #fff; background: #ff0000; }
#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; }
@ -75,6 +84,14 @@ canvas#clock { width: 180px; height: 180px; }
.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; }
@ -86,6 +103,7 @@ canvas#clock { width: 180px; height: 180px; }
<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">
@ -100,6 +118,8 @@ canvas#clock { width: 180px; height: 180px; }
<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>
@ -285,10 +305,37 @@ function formatTime(ts) {
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
@ -322,10 +369,21 @@ function renderAlert(alert, prepend = false) {
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(() => {
@ -366,7 +424,12 @@ async function updateWeather() {
try {
const r = await fetch('/api/weather');
const d = await r.json();
let html = d.current ? `${d.current}°F (${d.low}${d.high})` : `${d.low}${d.high}°F`;
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>';
}
@ -379,6 +442,174 @@ async function updateWeather() {
updateWeather();
setInterval(updateWeather, 60000); // every minute
// === CLAUDE BUDGET PACE ===
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) {
el.textContent = `⚡ ${d.pace}%`;
el.title = `Budget pace: ${d.timePct}% of week elapsed, ${d.usage}% used`;
// Color: <90 green, 90-100 white, >100 red
el.className = d.pace < 90 ? 'good' : d.pace <= 100 ? 'ok' : 'crit';
} else {
el.textContent = '';
}
} catch(e) {
el.textContent = '';
}
}
updateClaudeUsage();
setInterval(updateClaudeUsage, 60000); // every minute (pace changes with time)
// === 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');

122
server.js
View File

@ -39,12 +39,13 @@ app.get('/api/alerts', (req, res) => {
// Push alert
app.post('/api/alerts', (req, res) => {
const { message, priority = 'info' } = req.body;
const { message, priority = 'info', group } = req.body;
if (!message) return res.status(400).json({ error: 'message required' });
const alert = {
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
message,
priority,
group: group || null,
timestamp: new Date().toISOString()
};
const alerts = loadAlerts();
@ -101,43 +102,53 @@ let weatherCache = { data: null, ts: 0 };
const SEVERE_EVENTS = ['hurricane', 'tropical storm', 'tornado', 'severe thunderstorm', 'flash flood', 'storm surge', 'tsunami', 'extreme wind'];
app.get('/api/weather', async (req, res) => {
// Refresh current temp from HA every request (cheap, local), forecast/NHC daily
// Refresh current temp from NWS every request, forecast/NHC daily
const forecastFresh = weatherCache.data && Date.now() - weatherCache.ts < 86400000;
if (forecastFresh) {
// Just update current temp from HA
// Just update current temp from NWS
try {
const haData = await new Promise((resolve, reject) => {
const r = http.get(`${HA_URL}/api/states/sensor.air_temp`, {
headers: { 'Authorization': `Bearer ${HA_TOKEN}` }
const nwsData = await new Promise((resolve, reject) => {
const r = require('https').get('https://api.weather.gov/stations/KSPG/observations/latest', {
headers: { 'User-Agent': 'james-dashboard' }
}, (resp) => {
let body = '';
resp.on('data', c => body += c);
resp.on('end', () => { try { resolve(JSON.parse(body)); } catch(e) { reject(e); } });
});
r.on('error', reject);
r.setTimeout(3000, () => { r.destroy(); reject(new Error('timeout')); });
r.setTimeout(5000, () => { r.destroy(); reject(new Error('timeout')); });
});
weatherCache.data.current = parseInt(haData.state);
// Convert Celsius to Fahrenheit
const tempC = nwsData.properties.temperature.value;
if (tempC !== null) {
weatherCache.data.current = Math.round(tempC * 9/5 + 32);
weatherCache.data.currentC = Math.round(tempC);
}
} catch(e) {}
return res.json(weatherCache.data);
}
try {
const result = { high: null, low: null, current: null, alerts: [] };
const result = { high: null, low: null, current: null, currentC: null, alerts: [] };
// Current outside temp from HA
// Current outside temp from NWS
try {
const haData = await new Promise((resolve, reject) => {
const r = http.get(`${HA_URL}/api/states/sensor.air_temp`, {
headers: { 'Authorization': `Bearer ${HA_TOKEN}` }
const nwsData = await new Promise((resolve, reject) => {
const r = require('https').get('https://api.weather.gov/stations/KSPG/observations/latest', {
headers: { 'User-Agent': 'james-dashboard' }
}, (resp) => {
let body = '';
resp.on('data', c => body += c);
resp.on('end', () => { try { resolve(JSON.parse(body)); } catch(e) { reject(e); } });
});
r.on('error', reject);
r.setTimeout(3000, () => { r.destroy(); reject(new Error('timeout')); });
r.setTimeout(5000, () => { r.destroy(); reject(new Error('timeout')); });
});
result.current = parseInt(haData.state);
// Convert Celsius to Fahrenheit
const tempC = nwsData.properties.temperature.value;
if (tempC !== null) {
result.current = Math.round(tempC * 9/5 + 32);
result.currentC = Math.round(tempC);
}
} catch(e) {}
// Temp range from wttr.in
@ -302,6 +313,87 @@ setInterval(() => {
}
}, 300000); // Check every 5 min
// Meeting storage
let nextMeeting = null;
// Claude usage endpoint
app.get('/api/claude-usage', async (req, res) => {
try {
const jamesData = await new Promise((resolve, reject) => {
const r = http.get('http://localhost:9200/api/status', (resp) => {
let body = '';
resp.on('data', c => body += c);
resp.on('end', () => { try { resolve(JSON.parse(body)); } catch(e) { reject(e); } });
});
r.on('error', reject);
r.setTimeout(3000, () => { r.destroy(); reject(new Error('timeout')); });
});
// James dashboard returns {status: {key: {value: "..."}, ...}}
const statuses = jamesData.status || jamesData;
const usage = statuses['claude-usage'] || statuses['claude'];
// Extract usage percentage from value like "📊 Weekly: 46% used" or "46% used · 2:00 PM"
let usagePct = null;
if (usage && usage.value) {
const m = usage.value.match(/(\d+)%/);
if (m) usagePct = parseInt(m[1]);
}
// Calculate time% into budget week (Sat 2pm - Sat 2pm ET)
const now = new Date();
// Find most recent Saturday 2pm ET
const et = new Date(now.toLocaleString('en-US', { timeZone: 'America/New_York' }));
const day = et.getDay(); // 0=Sun
// Days since last Saturday
const daysSinceSat = day === 6 ? 0 : day + 1;
const lastSat = new Date(et);
lastSat.setDate(lastSat.getDate() - daysSinceSat);
lastSat.setHours(14, 0, 0, 0); // 2pm
// If we're before 2pm on Saturday, go back one more week
if (et < lastSat) {
lastSat.setDate(lastSat.getDate() - 7);
}
const weekMs = 7 * 24 * 60 * 60 * 1000;
const elapsed = et - lastSat;
const timePct = Math.min(100, Math.max(0, (elapsed / weekMs) * 100));
// Burn rate = usage% / time% — >100 means overspending
let pace = null;
if (timePct > 0) {
pace = Math.round((usagePct / timePct) * 100);
}
res.json({ usage: usagePct, timePct: Math.round(timePct), pace });
} catch(e) {
res.status(502).json({ error: 'james dashboard unavailable' });
}
});
// Meeting endpoints
app.get('/api/meeting', (req, res) => {
if (!nextMeeting) return res.json(null);
const now = new Date();
const meetingTime = new Date(nextMeeting.time);
// Remove if in the past
if (meetingTime < now) {
nextMeeting = null;
return res.json(null);
}
res.json(nextMeeting);
});
app.post('/api/meeting', (req, res) => {
const { title, time, id } = req.body;
if (!title || !time) return res.status(400).json({ error: 'title and time required' });
nextMeeting = { title, time, id: id || Date.now().toString(36) };
res.status(201).json(nextMeeting);
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Alert dashboard running on http://0.0.0.0:${PORT}`);
});