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

+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);
});