Alert history: say what each alert is for, where, and when
Publish Container / publish (push) Successful in 3m33s

- 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 <noreply@anthropic.com>
This commit is contained in:
Nate EmmertandClaude Sonnet 5.5 committed 2026-10-15 13:28:28 -04:00
1 parent 72d813bd61
commit 114386a1b2
3 files changed
+231 -33

No files matched your search

+14
View File
@@ -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); }
+156 -30
View File
@@ -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 = `<span class="ah-badge ${isNws ? 'nws' : 'threshold'}">${isNws ? 'NWS' : 'Threshold'}</span>`;
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(`<span class="ah-sev ${SEV_ORDER[it.severity] || ''}">${esc(it.severity)}</span>`);
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 = `<div class="ah-full">
${meaning ? `<p class="ah-meaning"><b>What this means:</b> ${esc(meaning)}</p>` : ''}
${it.headline ? `<p><b>Headline:</b> ${esc(it.headline)}</p>` : ''}
${it.areaDesc ? `<p><b>Area:</b> ${esc(ahArea(it.areaDesc))}</p>` : ''}
${it.description ? `<p class="ah-desc"><b>Details:</b>\n${esc(it.description)}</p>` : loading ? '<p class="ah-note">Fetching the full text from the National Weather Service…</p>' : ahState.gone.has(it.alertId) ? '<p class="ah-note">The National Weather Service no longer provides the full text for this older alert.</p>' : ''}
${it.instruction ? `<p class="ah-desc"><b>What to do:</b>\n${esc(it.instruction)}</p>` : ''}
<p class="ah-note">${[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(' · ')}</p>
</div>`;
} else full = `<div class="ah-full"><p>${esc(it.body || '')}</p></div>`;
}
return `<tr class="ah-row${open ? ' open' : ''}" data-i="${i}" title="Click for the full alert">
<td style="white-space:nowrap;color:var(--ink-light);font-size:0.78rem">${time}</td>
<td>${badge}</td>
<td style="font-weight:600">${esc(label)}</td>
<td><div class="ah-what">${esc(what)}</div>${meta.length ? `<div class="ah-meta">${meta.join(' · ')}</div>` : ''}${full}</td>
</tr>`;
}
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 = `
<div class="alert-history-bar">
<span style="font-size:0.82rem;color:var(--ink-light)">Range:</span>
@@ -1621,35 +1766,16 @@ async function loadAlertHistory(days) {
${items.length === 0
? `<div class="ah-empty">No alerts logged in the last ${days} days.</div>`
: `<div class="alert-history-table-wrap"><table class="alert-history-table">
<thead><tr><th>Time</th><th>Type</th><th>Event</th><th>Details</th></tr></thead>
<tbody>${items.map(it => {
const isNws = it.type === 'nws';
const badge = `<span class="ah-badge ${isNws ? 'nws' : 'threshold'}">${isNws ? 'NWS' : 'Threshold'}</span>`;
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 `<tr class="ah-row" data-full="${esc(full)}" title="Click to expand">
<td style="white-space:nowrap;color:var(--ink-light);font-size:0.78rem">${time}</td>
<td>${badge}</td>
<td style="font-weight:600">${label}</td>
<td><div class="ah-body">${esc(detail)}</div></td>
</tr>`;
}).join('')}</tbody>
</table></div>`
}`;
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)));
});
<thead><tr><th>Time</th><th>Type</th><th>Alert</th><th>What it's for · where · when</th></tr></thead>
<tbody id="ahBody"></tbody>
</table></div>`}`;
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 = '<div class="status error">Failed to load alert history.</div>';
}
+61 -3
View File
@@ -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);
});