Add persistent alert history log to data/alerts/
Publish Container / publish (push) Successful in 2m55s

NWS alerts and all threshold firings are now appended to daily .jsonl
files in data/alerts/ (same pattern as data/station/). NWS alerts are
deduplicated by alert ID across subscribers to avoid duplicate log lines.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nate EmmertandClaude Sonnet 4.6 committed 2026-09-02 07:59:58 -04:00
1 parent 3725dca8ee
commit 926f280a28
1 file changed
+32
+32
View File
@@ -79,6 +79,24 @@ function requireDashboardAuth(req, res, next) {
const STATION_DIR = path.join(DATA_DIR, 'station');
if (!fs.existsSync(STATION_DIR)) fs.mkdirSync(STATION_DIR, { recursive: true });
// ── Alert history log ─────────────────────────────────────────────────────────
const ALERTS_DIR = path.join(DATA_DIR, 'alerts');
if (!fs.existsSync(ALERTS_DIR)) fs.mkdirSync(ALERTS_DIR, { recursive: true });
function recordAlertLog(entry) {
try {
const day = new Date().toISOString().slice(0, 10);
const file = path.join(ALERTS_DIR, `${day}.jsonl`);
fs.appendFileSync(file, JSON.stringify({ ts: Date.now(), ...entry }) + '\n');
} catch (e) {
console.error('[alerts] log error:', e.message);
}
}
// 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();
function stationDateKey(ts) {
return new Date(ts).toISOString().slice(0, 10);
}
@@ -734,6 +752,11 @@ async function alertWatcherCron() {
const body = p.headline || p.description || 'Alert for your saved location.';
await doWebPush(sub, { title, body, tag: 'weather-alert', url: '/?tab=alerts' });
await doNtfy(sub.ntfyTopic, title, body, 'high');
const alertId = a.id || p.id;
if (alertId && !loggedAlertIds.has(alertId)) {
loggedAlertIds.add(alertId);
recordAlertLog({ type: 'nws', alertId, event: p.event, headline: p.headline, effective: p.effective, expires: p.expires, lat: sub.lat, lon: sub.lon });
}
}
sub.seenAlertIds = [...new Set([...sub.seenAlertIds, ...newAlerts.map(f => f.id || f.properties?.id)])].slice(-200);
saveSubs();
@@ -767,6 +790,7 @@ async function alertWatcherCron() {
const body = `Gusts at ${Math.round(gustVal)} mph — your threshold is ${sub.thresholds.wind} mph (${gustSrc})`;
await doWebPush(sub, { title: '💨 Wind Alert', body, tag: 'wind-threshold', url: '/' });
await doNtfy(sub.ntfyTopic, '💨 Wind Alert', body, 'high');
recordAlertLog({ type: 'threshold', tag: 'wind-threshold', value: gustVal, threshold: sub.thresholds.wind, body, lat: sub.lat, lon: sub.lon });
}
const tempVal = stationObs?.tempf ?? c.temperature_2m;
@@ -776,6 +800,7 @@ async function alertWatcherCron() {
const body = `Temperature at ${Math.round(tempVal)}°F — at or below your freeze threshold (${tempSrc})`;
await doWebPush(sub, { title: '🧊 Freeze Alert', body, tag: 'temp-threshold', url: '/' });
await doNtfy(sub.ntfyTopic, '🧊 Freeze Alert', body);
recordAlertLog({ type: 'threshold', tag: 'temp-threshold', value: tempVal, threshold: sub.thresholds.tempLow, body, lat: sub.lat, lon: sub.lon });
}
if (sub.thresholds.aqi) {
const aqData = await fetch(`${base}/api/air-quality?lat=${sub.lat}&lon=${sub.lon}`).then(r => r.json()).catch(() => null);
@@ -785,6 +810,7 @@ async function alertWatcherCron() {
const body = `AQI at ${aqi} — above your threshold of ${sub.thresholds.aqi}`;
await doWebPush(sub, { title: '😷 Air Quality Alert', body, tag: 'aqi-threshold', url: '/' });
await doNtfy(sub.ntfyTopic, '😷 Air Quality Alert', body);
recordAlertLog({ type: 'threshold', tag: 'aqi-threshold', value: aqi, threshold: sub.thresholds.aqi, body, lat: sub.lat, lon: sub.lon });
}
}
// ── Station-based threshold alerts ───────────────────────────────────
@@ -794,30 +820,35 @@ async function alertWatcherCron() {
const body = `Rain rate at ${stationObs.rainratein.toFixed(2)} in/hr — threshold is ${sub.thresholds.rainRate} in/hr`;
await doWebPush(sub, { title: '🌧 Heavy Rain Alert', body, tag: 'rain-threshold', url: '/' });
await doNtfy(sub.ntfyTopic, '🌧 Heavy Rain Alert', body, 'high');
recordAlertLog({ type: 'threshold', tag: 'rain-threshold', value: stationObs.rainratein, threshold: sub.thresholds.rainRate, body, lat: sub.lat, lon: sub.lon });
}
if (sub.thresholds.tempHigh && stationObs.tempf != null && stationObs.tempf > sub.thresholds.tempHigh && !alerted.tempHigh) {
alerted.tempHigh = true;
const body = `Station temp at ${stationObs.tempf.toFixed(1)}°F — threshold is ${sub.thresholds.tempHigh}°F`;
await doWebPush(sub, { title: '🌡 High Temp Alert', body, tag: 'temp-high', url: '/' });
await doNtfy(sub.ntfyTopic, '🌡 High Temp Alert', body, 'high');
recordAlertLog({ type: 'threshold', tag: 'temp-high', value: stationObs.tempf, threshold: sub.thresholds.tempHigh, body, lat: sub.lat, lon: sub.lon });
}
if (sub.thresholds.tempLow && stationObs.tempf != null && stationObs.tempf < sub.thresholds.tempLow && !alerted.stationTempLow) {
alerted.stationTempLow = true;
const body = `Station temp at ${stationObs.tempf.toFixed(1)}°F — threshold is ${sub.thresholds.tempLow}°F`;
await doWebPush(sub, { title: '🧊 Low Temp Alert', body, tag: 'temp-low', url: '/' });
await doNtfy(sub.ntfyTopic, '🧊 Low Temp Alert', body, 'default');
recordAlertLog({ type: 'threshold', tag: 'temp-low', value: stationObs.tempf, threshold: sub.thresholds.tempLow, body, lat: sub.lat, lon: sub.lon });
}
if (stationObs.tempf != null && stationObs.tempf <= 34 && !alerted.freeze) {
alerted.freeze = true;
const body = `Station reading ${stationObs.tempf.toFixed(1)}°F — freezing conditions`;
await doWebPush(sub, { title: '❄️ Freeze Warning', body, tag: 'freeze', url: '/' });
await doNtfy(sub.ntfyTopic, '❄️ Freeze Warning', body, 'urgent');
recordAlertLog({ type: 'threshold', tag: 'freeze', value: stationObs.tempf, threshold: 34, body, lat: sub.lat, lon: sub.lon });
}
if (sub.thresholds.wind && stationObs.windgustmph != null && stationObs.windgustmph > sub.thresholds.wind && !alerted.stationWind) {
alerted.stationWind = true;
const body = `Station gust at ${stationObs.windgustmph.toFixed(0)} mph — threshold is ${sub.thresholds.wind} mph`;
await doWebPush(sub, { title: '💨 Wind Gust Alert', body, tag: 'wind-station', url: '/' });
await doNtfy(sub.ntfyTopic, '💨 Wind Gust Alert', body, 'high');
recordAlertLog({ type: 'threshold', tag: 'wind-station', value: stationObs.windgustmph, threshold: sub.thresholds.wind, body, lat: sub.lat, lon: sub.lon });
}
}
// ── Station offline alert ────────────────────────────────────────────
@@ -829,6 +860,7 @@ async function alertWatcherCron() {
const body = `No station data for ${mins} minutes`;
await doWebPush(sub, { title: '📡 Station Offline', body, tag: 'station-offline', url: '/' });
await doNtfy(sub.ntfyTopic, '📡 Station Offline', body, 'default');
recordAlertLog({ type: 'threshold', tag: 'station-offline', body, lat: sub.lat, lon: sub.lon });
}
} else if (stationObs) {
alerted.stationOffline = false;