@@ -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 = `
${escHtml(alert.message)}
${formatTime(alert.timestamp)}
`;
// Long-press (300ms) to toggle done
@@ -322,9 +369,20 @@ function renderAlert(alert, prepend = false) {
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);
+ 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
@@ -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 = `
${d.current}°F (${d.currentC}°C) · ${d.low}–${d.high}°F`;
+ } else {
+ html = `${d.low}–${d.high}°F`;
+ }
if (d.alerts && d.alerts.length) {
html += '
⚠️ ' + d.alerts.join(', ') + '';
}
@@ -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 ? `
${escHtml(latest.source)}` : '';
+ html += `
${icon}${topic}${escHtml(latest.title)}${src}
`;
+ }
+ 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');
diff --git a/server.js b/server.js
index 866f714..d00063f 100644
--- a/server.js
+++ b/server.js
@@ -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}`);
});