From 114386a1b2e5e02021293cc8b0f0eb5477a7c9e0 Mon Sep 17 00:00:00 2001 From: Nate Emmert Date: Thu, 15 Oct 2026 13:28:28 -0400 Subject: [PATCH] Alert history: say what each alert is for, where, and when - Plain-language meaning for common NWS products, shown in the row and expanded view - Details column: reason, area, severity, effective window; click for full text/instructions - New alerts log area, severity, urgency, certainty and sender - Older entries are filled in from NWS by alert id (cached in data/alert-details.json); alerts NWS no longer serves fall back to the built-in meaning Co-Authored-By: Claude Sonnet 5.5 --- app/public/dashboard-ui.css | 14 +++ app/public/dashboard.html | 186 ++++++++++++++++++++++++++++++------ app/server.js | 64 ++++++++++++- 3 files changed, 231 insertions(+), 33 deletions(-) diff --git a/app/public/dashboard-ui.css b/app/public/dashboard-ui.css index c599ff4..1f5943e 100644 --- a/app/public/dashboard-ui.css +++ b/app/public/dashboard-ui.css @@ -131,3 +131,17 @@ a { color: var(--ui); } .rec-yr { display: block; font-size: 0.68rem; color: var(--ink-light); font-weight: 600; white-space: nowrap; } .rec-cal tr.empty { opacity: .4; } + +/* Alert history: what / where / when */ +.ah-what { font-size: 0.86rem; line-height: 1.4; } +.ah-meta { margin-top: 0.25rem; font-size: 0.74rem; color: var(--ink-light); line-height: 1.5; } +.ah-sev { display: inline-block; padding: 0 0.45rem; border-radius: 99px; font-weight: 700; font-size: 0.68rem; border: 1px solid var(--border); } +.ah-sev.sev-extreme { color: #ef4444; border-color: #ef4444; } .ah-sev.sev-severe { color: #f97316; border-color: #f97316; } +.ah-sev.sev-moderate { color: #eab308; border-color: #eab308; } .ah-sev.sev-minor { color: var(--cool); border-color: var(--cool); } +.ah-row { cursor: pointer; } +.ah-row.open td { background: color-mix(in srgb, var(--ui) 6%, transparent); } +.ah-full { margin-top: 0.6rem; padding-top: 0.6rem; border-top: 1px dashed var(--border); font-size: 0.82rem; line-height: 1.5; cursor: default; } +.ah-full p { margin: 0 0 0.5rem; } +.ah-desc { white-space: pre-wrap; } +.ah-meaning { padding: 0.5rem 0.7rem; border-radius: 10px; background: color-mix(in srgb, var(--ui) 10%, transparent); } +.ah-note { font-size: 0.74rem; color: var(--ink-light); } diff --git a/app/public/dashboard.html b/app/public/dashboard.html index 275bfb6..80b75d8 100644 --- a/app/public/dashboard.html +++ b/app/public/dashboard.html @@ -1603,6 +1603,151 @@ const TAG_LABELS = { let alertHistoryDays = 30; +// Plain-language meaning of common NWS products, so the history says what an alert is *for* +const ALERT_GLOSSARY = { + 'heat advisory': 'Dangerously hot conditions expected. Limit time outdoors, drink plenty of water, and check on children, older adults and pets.', + 'excessive heat watch': 'Conditions are favorable for life-threatening heat in the next day or two. Plan ahead.', + 'excessive heat warning': 'Life-threatening heat is occurring or imminent. Avoid outdoor activity and stay cool.', + 'extreme heat watch': 'Conditions are favorable for life-threatening heat in the next day or two. Plan ahead.', + 'extreme heat warning': 'Life-threatening heat is occurring or imminent. Avoid outdoor activity and stay cool.', + 'severe thunderstorm watch': 'Storms capable of damaging wind or large hail are possible in the area. Stay alert and be ready to take shelter.', + 'severe thunderstorm warning': 'A severe storm with damaging wind and/or large hail is happening or imminent. Move indoors away from windows.', + 'tornado watch': 'Conditions are favorable for tornadoes. Stay alert and know where you would take shelter.', + 'tornado warning': 'A tornado is occurring or imminent. Take shelter immediately in the lowest, most interior room.', + 'special weather statement': 'A short notice about a significant but non-warning weather event, often a strong storm, gusty winds, dense fog or sudden changes.', + 'flood watch': 'Flooding is possible. Be prepared to move to higher ground and avoid low-lying areas.', + 'flood advisory': 'Minor flooding is occurring or expected. Avoid flooded roads and low spots.', + 'flood warning': 'Flooding is happening or imminent. Move to higher ground and never drive through flood water.', + 'flash flood watch': 'Flash flooding is possible. Be ready to move to higher ground quickly.', + 'flash flood warning': 'Flash flooding is occurring or imminent. Move to higher ground now.', + 'wind advisory': 'Sustained or gusty winds that can blow around unsecured objects and make driving difficult for high-profile vehicles.', + 'high wind watch': 'Damaging winds are possible. Secure outdoor objects.', + 'high wind warning': 'Damaging winds are occurring or imminent. Secure objects and avoid travel if possible.', + 'freeze watch': 'A freeze is possible. Protect sensitive plants, pets and exposed pipes.', + 'freeze warning': 'Temperatures at or below freezing are expected. Protect plants, pets and exposed pipes.', + 'frost advisory': 'Frost is expected and can damage sensitive plants.', + 'hard freeze warning': 'Severe freezing temperatures are expected. Protect plants, pets and pipes.', + 'winter storm watch': 'Heavy snow and/or ice is possible. Prepare for hazardous travel.', + 'winter storm warning': 'Heavy snow and/or ice is occurring or imminent. Travel may be dangerous or impossible.', + 'winter weather advisory': 'Snow, sleet or freezing rain will cause slippery roads and travel difficulty.', + 'ice storm warning': 'Significant ice accumulation is expected. Power outages and dangerous travel are likely.', + 'blizzard warning': 'Heavy snow with strong winds and very low visibility. Avoid travel.', + 'wind chill advisory': 'Dangerously cold wind chills are expected. Dress in layers and limit time outside.', + 'wind chill warning': 'Life-threatening wind chills are expected. Frostbite can occur quickly.', + 'dense fog advisory': 'Fog is reducing visibility to a quarter mile or less. Slow down and use low-beam headlights.', + 'dense smoke advisory': 'Smoke is reducing visibility and air quality.', + 'air quality alert': 'Air quality is unhealthy for some or all people. Limit prolonged outdoor exertion.', + 'red flag warning': 'Dry, windy and warm conditions create a high wildfire risk. Avoid open flames and outdoor burning.', + 'fire weather watch': 'Conditions may soon create a high wildfire risk.', + 'rip current statement': 'Dangerous rip currents at the beach. Stay out of the water or swim near a lifeguard.', + 'coastal flood advisory': 'Minor coastal flooding is expected.', + 'coastal flood warning': 'Significant coastal flooding is occurring or imminent.', + 'small craft advisory': 'Winds or seas hazardous to small boats.', + 'tropical storm watch': 'Tropical storm conditions are possible within about 48 hours.', + 'tropical storm warning': 'Tropical storm conditions are expected within about 36 hours.', + 'hurricane watch': 'Hurricane conditions are possible within about 48 hours.', + 'hurricane warning': 'Hurricane conditions are expected within about 36 hours.', + 'hazardous weather outlook': 'An outlook of possible hazardous weather in the next several days.', + 'severe weather statement': 'An update on an ongoing severe storm warning.', + 'flood statement': 'An update on an ongoing flood warning.', +}; +function alertMeaning(event) { + const e = String(event || '').toLowerCase().trim(); + if (ALERT_GLOSSARY[e]) return ALERT_GLOSSARY[e]; + if (e.endsWith('watch')) return 'A Watch: conditions are favorable for this hazard. Stay alert and be ready to act.'; + if (e.endsWith('warning')) return 'A Warning: this hazard is happening or imminent. Take action now.'; + if (e.endsWith('advisory')) return 'An Advisory: conditions may cause inconvenience or danger if you are not careful.'; + if (e.endsWith('statement')) return 'An informational statement about developing or ongoing weather.'; + return ''; +} + +const ahState = { items: [], open: new Set(), loading: new Set(), gone: new Set(), queue: [], running: false }; +const ahWhen = (iso) => iso ? new Date(iso).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''; +const ahFirstSentence = (t) => { + // NWS text is often structured "* WHAT...", "* WHERE...", "* WHEN..."; lead with the WHAT part + const what = String(t || '').match(/\*\s*WHAT\.\.\.([\s\S]*?)(?:\n\s*\n|\*\s*[A-Z ]+\.\.\.|$)/); + const c = (what ? what[1] : String(t || '')).replace(/\s+/g, ' ').trim(); const m = c.match(/^.*?[.!?](\s|$)/); const r = (m ? m[0] : c).trim(); return r.length > 220 ? r.slice(0, 217) + '…' : r; +}; +const ahArea = (a) => String(a || '').replace(/;\s*/g, ', '); +const SEV_ORDER = { Extreme: 'sev-extreme', Severe: 'sev-severe', Moderate: 'sev-moderate', Minor: 'sev-minor' }; + +function ahRow(it, i) { + const isNws = it.type === 'nws'; + const time = new Date(it.ts).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + const badge = `${isNws ? 'NWS' : 'Threshold'}`; + const label = isNws ? (it.event || 'Alert') : (TAG_LABELS[it.tag] || it.tag || 'β€”'); + const open = ahState.open.has(i); + let what, meta = []; + if (isNws) { + what = it.description ? ahFirstSentence(it.description) : alertMeaning(it.event) || it.headline || ''; + if (it.areaDesc) meta.push(`πŸ“ ${esc(ahArea(it.areaDesc))}`); + else if (ahState.loading.has(it.alertId)) meta.push('πŸ“ looking up area…'); + if (it.severity && it.severity !== 'Unknown') meta.push(`${esc(it.severity)}`); + if (it.effective || it.expires) meta.push(`πŸ•‘ ${esc(ahWhen(it.effective))}${it.expires ? ' β†’ ' + esc(ahWhen(it.expires)) : ''}`); + } else { + what = it.body || ''; + } + let full = ''; + if (open) { + if (isNws) { + const meaning = alertMeaning(it.event); + const loading = ahState.loading.has(it.alertId); + full = `
+ ${meaning ? `

What this means: ${esc(meaning)}

` : ''} + ${it.headline ? `

Headline: ${esc(it.headline)}

` : ''} + ${it.areaDesc ? `

Area: ${esc(ahArea(it.areaDesc))}

` : ''} + ${it.description ? `

Details:\n${esc(it.description)}

` : loading ? '

Fetching the full text from the National Weather Service…

' : ahState.gone.has(it.alertId) ? '

The National Weather Service no longer provides the full text for this older alert.

' : ''} + ${it.instruction ? `

What to do:\n${esc(it.instruction)}

` : ''} +

${[it.severity && 'Severity: ' + it.severity, it.urgency && 'Urgency: ' + it.urgency, it.certainty && 'Certainty: ' + it.certainty, it.senderName && 'Issued by ' + it.senderName].filter(Boolean).map(esc).join(' Β· ')}

+
`; + } else full = `

${esc(it.body || '')}

`; + } + return ` + ${time} + ${badge} + ${esc(label)} +
${esc(what)}
${meta.length ? `
${meta.join(' Β· ')}
` : ''}${full} + `; +} + +function ahRender() { + const tbody = document.getElementById('ahBody'); + if (!tbody) return; + tbody.innerHTML = ahState.items.map(ahRow).join(''); + tbody.querySelectorAll('.ah-full').forEach((f) => f.addEventListener('click', (e) => e.stopPropagation())); // allow selecting/copying text + tbody.querySelectorAll('.ah-row').forEach((row) => row.addEventListener('click', () => { + const i = +row.dataset.i, it = ahState.items[i]; + if (ahState.open.has(i)) ahState.open.delete(i); else { ahState.open.add(i); ahLookup(it, true); } + ahRender(); + })); +} + +// Fetch missing text/area for older NWS entries (cached on the server), a few at a time +function ahApply(id, detail) { + for (const it of ahState.items) if (it.alertId === id) for (const [k, v] of Object.entries(detail || {})) if (v != null && it[k] == null) it[k] = v; +} +function ahLookup(it, urgent) { + if (!it || it.type !== 'nws' || !it.alertId || (it.description && it.areaDesc) || ahState.gone.has(it.alertId) || ahState.loading.has(it.alertId)) return; + if (urgent) ahState.queue.unshift(it.alertId); else ahState.queue.push(it.alertId); + ahState.loading.add(it.alertId); + ahPump(); +} +async function ahPump() { + if (ahState.running) return; + ahState.running = true; + while (ahState.queue.length) { + const id = ahState.queue.shift(); + try { + const r = await fetch('/api/dashboard/alert-detail?id=' + encodeURIComponent(id)); + if (r.ok) ahApply(id, await r.json()); else ahState.gone.add(id); + } catch { ahState.gone.add(id); } + ahState.loading.delete(id); + ahRender(); + await new Promise((res) => setTimeout(res, 120)); + } + ahState.running = false; +} + async function loadAlertHistory(days) { alertHistoryDays = days; const body = document.getElementById('alertHistoryBody'); @@ -1611,7 +1756,7 @@ async function loadAlertHistory(days) { 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') || []; + ahState.items = items; ahState.open = new Set(); ahState.queue = []; ahState.loading = new Set(); ahState.gone = new Set(); body.innerHTML = `
Range: @@ -1621,35 +1766,16 @@ async function loadAlertHistory(days) { ${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 full = isNws ? [it.headline, it.description, it.instruction && ('What to do: ' + it.instruction)].filter(Boolean).join('\n\n') : detail; - const time = new Date(it.ts).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); - return ` - - - - - `; - }).join('')} -
TimeTypeEventDetails
${time}${badge}${label}
${esc(detail)}
` - }`; - body.querySelectorAll('.ah-row').forEach(row => { - row.addEventListener('click', () => { - const el = row.querySelector('.ah-body'); - const open = row.classList.toggle('open'); - el.textContent = open ? row.dataset.full : el.dataset.short; - }); - const el = row.querySelector('.ah-body'); - el.dataset.short = el.textContent; - }); - body.querySelectorAll('[data-ahdays]').forEach(btn => { - btn.addEventListener('click', () => loadAlertHistory(parseInt(btn.dataset.ahdays))); - }); + TimeTypeAlertWhat it's for Β· where Β· when + +
`}`; + body.querySelectorAll('[data-ahdays]').forEach(btn => btn.addEventListener('click', () => loadAlertHistory(parseInt(btn.dataset.ahdays)))); + if (items.length) { + ahRender(); + // Look up missing details in the background, newest first + const seen = new Set(); + for (const it of items) if (it.type === 'nws' && it.alertId && !(it.description && it.areaDesc) && !seen.has(it.alertId)) { seen.add(it.alertId); ahLookup(it, false); } + } } catch { document.getElementById('alertHistoryBody').innerHTML = '
Failed to load alert history.
'; } diff --git a/app/server.js b/app/server.js index 596191c..0f93fb9 100644 --- a/app/server.js +++ b/app/server.js @@ -190,6 +190,13 @@ function recordAlertLog(entry) { } } +// Fields worth keeping from an NWS alert so the history can say what it was for and where +function nwsLogFields(p) { + return { event: p.event, headline: p.headline, description: p.description, instruction: p.instruction, + areaDesc: p.areaDesc, severity: p.severity, urgency: p.urgency, certainty: p.certainty, senderName: p.senderName, + effective: p.effective, expires: p.expires }; +} + // Tracks NWS alert IDs already written this process lifetime to avoid duplicate log lines // across multiple subscribers covering the same geographic alert. const loggedAlertIds = new Set(); @@ -530,7 +537,7 @@ try { else rebuildRecordsFromFiles(); } catch (e) { console.error('[records] startup rebuild error:', e.message); } setInterval(saveRecordsStore, 30 * 1000).unref(); -for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => { saveRecordsStore(); process.exit(0); }); +for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => { saveRecordsStore(); try { saveAlertDetails(); } catch {} process.exit(0); }); function loadStationReadings(rangeMs) { const now = Date.now(); @@ -1254,7 +1261,7 @@ async function alertWatcherCron() { if (alertId && !loggedAlertIds.has(alertId)) { loggedAlertIds.add(alertId); const p = a.properties || {}; - recordAlertLog({ type: 'nws', alertId, event: p.event, headline: p.headline, description: p.description, instruction: p.instruction, effective: p.effective, expires: p.expires, lat: dashLat, lon: dashLon }); + recordAlertLog({ type: 'nws', alertId, ...nwsLogFields(p), lat: dashLat, lon: dashLon }); } } } @@ -1280,7 +1287,7 @@ async function alertWatcherCron() { const alertId = a.id || p.id; if (alertId && !loggedAlertIds.has(alertId)) { loggedAlertIds.add(alertId); - recordAlertLog({ type: 'nws', alertId, event: p.event, headline: p.headline, description: p.description, instruction: p.instruction, effective: p.effective, expires: p.expires, lat: sub.lat, lon: sub.lon }); + recordAlertLog({ type: 'nws', alertId, ...nwsLogFields(p), lat: sub.lat, lon: sub.lon }); } } sub.seenAlertIds = [...new Set([...sub.seenAlertIds, ...newAlerts.map(f => f.id || f.properties?.id)])].slice(-200); @@ -2056,6 +2063,51 @@ app.get('/api/station/trend', requireDashboardAuth, (_req, res) => { }); // Alert history log +// ── NWS alert details (looked up by alert id, cached on disk) ──────────────── +const ALERT_DETAIL_FILE = path.join(DATA_DIR, 'alert-details.json'); +let alertDetails = {}; +try { alertDetails = JSON.parse(fs.readFileSync(ALERT_DETAIL_FILE, 'utf8')); } catch {} +let alertDetailsDirty = false; +function saveAlertDetails() { + if (!alertDetailsDirty) return; + try { fs.writeFileSync(ALERT_DETAIL_FILE + '.tmp', JSON.stringify(alertDetails)); fs.renameSync(ALERT_DETAIL_FILE + '.tmp', ALERT_DETAIL_FILE); alertDetailsDirty = false; } + catch (e) { console.error('[alerts] detail cache save error:', e.message); } +} +setInterval(saveAlertDetails, 30 * 1000).unref(); + +const ALERT_ID_RE = /^https:\/\/api\.weather\.gov\/alerts\/[A-Za-z0-9:._-]+$/; +const alertLookupsInFlight = new Map(); +async function lookupAlertDetail(id) { + const hit = alertDetails[id]; + if (hit && !hit.unavailable) return hit; + if (hit && hit.unavailable && Date.now() - hit.at < 6 * 3600000) return hit; // don't hammer NWS for gone alerts + if (alertLookupsInFlight.has(id)) return alertLookupsInFlight.get(id); + const job = (async () => { + try { + const r = await fetchWithRetry(id, { attempts: 1, timeoutMs: 10000, fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/geo+json' } } }); + const f = await r.json(); + const p = f.properties || f; + if (!p.event) throw new Error('no event'); + alertDetails[id] = { ...nwsLogFields(p), fetchedAt: Date.now() }; + } catch (e) { + alertDetails[id] = { unavailable: true, at: Date.now() }; + } + alertDetailsDirty = true; + alertLookupsInFlight.delete(id); + return alertDetails[id]; + })(); + alertLookupsInFlight.set(id, job); + return job; +} + +app.get('/api/dashboard/alert-detail', requireDashboardAuth, async (req, res) => { + const id = String(req.query.id || ''); + if (!ALERT_ID_RE.test(id)) return res.status(400).json({ error: 'Invalid alert id' }); + const d = await lookupAlertDetail(id); + if (d.unavailable) return res.status(404).json({ error: 'NWS no longer provides this alert' }); + res.json(d); +}); + app.get('/api/dashboard/alert-history', requireDashboardAuth, (req, res) => { const days = Math.min(90, Math.max(1, parseInt(req.query.days) || 30)); const results = []; @@ -2071,6 +2123,12 @@ app.get('/api/dashboard/alert-history', requireDashboardAuth, (req, res) => { } } catch {} } + // Fill in text/area for older entries from the detail cache when we have it + for (const e of results) { + if (e.type !== 'nws' || e.description || !e.alertId) continue; + const d = alertDetails[e.alertId]; + if (d && !d.unavailable) Object.assign(e, Object.fromEntries(Object.entries(d).filter(([k, v]) => v != null && e[k] == null))); + } results.sort((a, b) => b.ts - a.ts); res.json(results); });