diff --git a/app/public/dashboard.html b/app/public/dashboard.html index b791af1..d789707 100644 --- a/app/public/dashboard.html +++ b/app/public/dashboard.html @@ -229,6 +229,20 @@ a { color: var(--cool); text-decoration: none; } .heatmap-cell[title]:hover::after { content: attr(title); position: absolute; bottom: 18px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.8); color: #fff; padding: 3px 7px; border-radius: 5px; font-size: 11px; white-space: nowrap; pointer-events: none; z-index: 10; } .heatmap-legend { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.6rem; font-size: 0.72rem; color: var(--ink-light); } .heatmap-legend-swatch { width: 12px; height: 12px; border-radius: 2px; } +/* Alert history table */ +.alert-history-bar { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.85rem; flex-wrap: wrap; } +.alert-history-table-wrap { overflow-x: auto; } +.alert-history-table { width: 100%; border-collapse: collapse; font-size: 0.83rem; } +.alert-history-table th { text-align: left; padding: 0.4rem 0.75rem; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-light); border-bottom: 1px solid var(--border); white-space: nowrap; } +.alert-history-table td { padding: 0.45rem 0.75rem; border-bottom: 1px solid var(--border); vertical-align: top; } +.alert-history-table tr:last-child td { border-bottom: none; } +.alert-history-table tr:hover td { background: var(--paper-darker); } +.ah-badge { display: inline-block; font-size: 0.68rem; font-weight: 700; padding: 0.15rem 0.45rem; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; } +.ah-badge.nws { background: rgba(194,90,42,0.15); color: var(--accent); border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); } +.ah-badge.threshold { background: rgba(42,107,138,0.15); color: var(--cool); border: 1px solid color-mix(in srgb, var(--cool) 40%, transparent); } +.ah-body { color: var(--ink-light); font-size: 0.78rem; margin-top: 0.1rem; line-height: 1.4; } +.ah-empty { padding: 1.5rem; text-align: center; color: var(--ink-light); font-size: 0.85rem; } + /* Settings sections */ .settings-section-hdr { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink-light); margin: 1.25rem 0 0.6rem; padding-bottom: 0.4rem; border-bottom: 1px solid var(--border); } .threshold-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; } @@ -540,6 +554,14 @@ canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem + +
+ Alert History +
+
Loading…
+
+
+
Station API Keys @@ -1713,6 +1735,74 @@ document.getElementById('saveLocBtn')?.addEventListener('click', async () => { } catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; } }); +// ── Alert History ───────────────────────────────────────────────────────────── +const TAG_LABELS = { + 'nws': 'NWS Alert', + 'wind-threshold': 'Wind Gust', + 'temp-threshold': 'Low Temp', + 'aqi-threshold': 'Air Quality', + 'rain-threshold': 'Rain Rate', + 'temp-high': 'High Temp', + 'temp-low': 'Station Low', + 'freeze': 'Freeze', + 'wind-station': 'Station Wind', + 'station-offline': 'Station Offline', +}; + +let alertHistoryDays = 30; + +async function loadAlertHistory(days) { + alertHistoryDays = days; + const body = document.getElementById('alertHistoryBody'); + body.innerHTML = '
Loading…
'; + try { + const r = await fetch(`/api/dashboard/alert-history?days=${days}`); + if (!r.ok) throw new Error(); + const items = await r.json(); + const dayBtns = body.querySelectorAll?.('.range-btn') || []; + body.innerHTML = ` +
+ Range: + ${[7,30,90].map(d => ``).join('')} + ${items.length} event${items.length !== 1 ? 's' : ''} +
+ ${items.length === 0 + ? `
No alerts logged in the last ${days} days.
` + : `
+ + ${items.map(it => { + const isNws = it.type === 'nws'; + const badge = `${isNws ? 'NWS' : 'Threshold'}`; + const label = isNws ? (it.event || 'Alert') : (TAG_LABELS[it.tag] || it.tag || '—'); + const detail = isNws ? (it.headline || '') : (it.body || ''); + const time = new Date(it.ts).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + return ` + + + + + `; + }).join('')} +
TimeTypeEventDetails
${time}${badge}${label}
${detail}
` + }`; + body.querySelectorAll('[data-ahdays]').forEach(btn => { + btn.addEventListener('click', () => loadAlertHistory(parseInt(btn.dataset.ahdays))); + }); + } catch { + document.getElementById('alertHistoryBody').innerHTML = '
Failed to load alert history.
'; + } +} + +const alertHistoryPanel = document.getElementById('alertHistoryPanel'); +if (alertHistoryPanel) { + alertHistoryPanel.addEventListener('toggle', () => { + if (!alertHistoryPanel.open) return; + const body = document.getElementById('alertHistoryBody'); + if (body.querySelector('table, .ah-empty')) return; + loadAlertHistory(alertHistoryDays); + }, { once: false }); +} + // ── Scroll handler: add blur shadow to sticky header ───────────────────────── window.addEventListener('scroll', () => { document.querySelector('.header').classList.toggle('scrolled', window.scrollY > 5); diff --git a/app/server.js b/app/server.js index d414e58..582292c 100644 --- a/app/server.js +++ b/app/server.js @@ -1404,6 +1404,26 @@ app.get('/api/station/trend', requireDashboardAuth, (_req, res) => { }); }); +// Alert history log +app.get('/api/dashboard/alert-history', requireDashboardAuth, (req, res) => { + const days = Math.min(90, Math.max(1, parseInt(req.query.days) || 30)); + const results = []; + const now = Date.now(); + for (let i = 0; i < days; i++) { + const day = new Date(now - i * 86400000).toISOString().slice(0, 10); + const file = path.join(ALERTS_DIR, `${day}.jsonl`); + if (!fs.existsSync(file)) continue; + try { + const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean); + for (const line of lines) { + try { results.push(JSON.parse(line)); } catch {} + } + } catch {} + } + results.sort((a, b) => b.ts - a.ts); + res.json(results); +}); + app.listen(PORT, () => { console.log(`Weather app running on port ${PORT}`); if (dashAuth) {