diff --git a/app/server.js b/app/server.js index f2998b5..d414e58 100644 --- a/app/server.js +++ b/app/server.js @@ -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;