chore: auto-commit uncommitted changes
This commit is contained in:
parent
1b2c0b51c4
commit
baf49db235
237
index.html
237
index.html
|
|
@ -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)">×</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
122
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}`);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue