Add persistent alert history log to data/alerts/
Publish Container / publish (push) Successful in 2m55s
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:
1 parent
3725dca8ee
commit
926f280a28
1 file changed
+32
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user