From 882fd69a1862e2fee6278dfe5318f111407b039a Mon Sep 17 00:00:00 2001 From: hermes-explorigin Date: Tue, 8 Sep 2026 02:33:37 +0000 Subject: [PATCH] Add official NWS advisories as notifications, deduping forecast alerts - nws.js: new fetchActiveAdvisories() hits the official NWS alerts feed (api.weather.gov /alerts/active?point=lat,lon) and normalizes each advisory into the app's alert shape, tagged source:'nws'. Adds classifyAdvisoryEvent (maps NWS events -> app buckets: heat/wind/cold/storm/precip, else advisory), nwsSeverity (Extreme/Severe->danger etc.), advisoryId (stable short id from the URN), advisoryIcon, and local effectiveDate/expiresDate for dedup. - notifications store: cached _nwsAdvisories + refreshAdvisories() (best effort, cached 5 min, non-US/no-alerts safe). analyze() now merges advisories via new pure mergeAdvisories(): an advisory that is officially in force on a date supersedes the derived forecast alert of the same type on that date, so the official NWS warning replaces the forecast guess. Both are dismissible independently (nws-* vs forecast-* ids). - New per-type toggle "advisory" (defaults on) across app/default settings and SettingsDialog, so official advisories are configurable like every other alert and the bell shows them with their own severity/icon. - Wired: App.svelte calls refreshAdvisories() alongside analyze() on forecast load and changes. - Tests: 12 advisory tests (classifier, severity, id, normalize, dedup incl. disable toggle) + 3 store integration tests (overlap dedup, non-overlap kept, toggle-off). 107 total pass. - Verified end-to-end in headless Chromium against the live NWS feed for Rogers County, OK: the current Heat Advisory appears in the bell, and the derived heat alerts for the days it covers are suppressed while a non-overlapping day's forecast heat alert remains. --- dist/index.html | 212 ++++++++++++++++++++++--- src/App.svelte | 2 + src/components/SettingsDialog.svelte | 3 +- src/lib/api/nws.js | 118 ++++++++++++++ src/lib/storage/db.js | 1 + src/lib/stores/app.svelte.js | 1 + src/lib/stores/notifications.svelte.js | 97 +++++++++-- tests/lib/api/advisories.test.js | 170 ++++++++++++++++++++ tests/lib/stores/notifications.test.js | 77 +++++++++ 9 files changed, 652 insertions(+), 29 deletions(-) create mode 100644 tests/lib/api/advisories.test.js diff --git a/dist/index.html b/dist/index.html index 1d62f07..b43ae80 100644 --- a/dist/index.html +++ b/dist/index.html @@ -5218,7 +5218,8 @@ var init_db = __esmMin((() => { heat: true, cold: true, uv: true, - wind: true + wind: true, + advisory: true }, alertThresholds: { precip: 70, @@ -5610,6 +5611,112 @@ function mergeFallbackData(nwsData, omData) { if (Object.keys(fallback).length) nwsData._fallback = fallback; return nwsData; } +/** +* Map an NWS alert `event` (e.g. "Excessive Heat Warning", "Tornado Watch") +* to one of the app's notifiable alert types. The same type buckets that the +* forecast-derived alerts use, so an official advisory can supersede its +* forecast twin (dedup). Anything unrecognized maps to `advisory`. +* @param {string} event +* @returns {string} +*/ +function classifyAdvisoryEvent(event) { + const t = (event || "").toLowerCase(); + if (/heat|hot/.test(t)) return "heat"; + if (/wind|gust|breez/.test(t)) return "wind"; + if (/winter|snow|blizzard|freez|frost|ice|cold/.test(t)) return "cold"; + if (/thunderstorm|tornado|severe storm|storm surge/.test(t)) return "storm"; + if (/flash flood|flood/.test(t)) return "precip"; + return "advisory"; +} +/** Map an NWS severity string to the app's severity levels. */ +function nwsSeverity(sev) { + const s = (sev || "").toLowerCase(); + if (/extreme|severe/.test(s)) return "danger"; + if (/moderate/.test(s)) return "warning"; + return "info"; +} +/** +* Stable short id for an advisory (from its URN id). The URN is unique per +* event life-cycle, so dismissals survive refreshes within the same event. +*/ +function advisoryId(urn) { + if (!urn) return "adv"; + return String(urn).split(".").slice(-3).join(".").replace(/[^a-zA-Z0-9]/g, "") || "adv"; +} +/** Icon + a short tag for an advisory event (used in the alert entry). */ +function advisoryIcon(event) { + const t = (event || "").toLowerCase(); + if (/heat|hot/.test(t)) return "๐Ÿ”ฅ"; + if (/wind|gust/.test(t)) return "๐Ÿ’จ"; + if (/winter|snow|blizzard|freez|frost|ice|cold/.test(t)) return "โ„๏ธ"; + if (/thunderstorm|tornado|severe/.test(t)) return "โ›ˆ๏ธ"; + if (/flood/.test(t)) return "๐ŸŒŠ"; + return "โš ๏ธ"; +} +/** Local calendar date (YYYY-MM-DD) from an NWS ISO timestamp in a timezone. */ +function advisoryDate(iso, tz) { + if (!iso) return null; + try { + return dateKey(iso, tz || "UTC"); + } catch { + return null; + } +} +/** +* Normalize a raw NWS `/alerts/active` GeoJSON response into app alert entries. +* Each entry mirrors the forecast-derived alerts the store produces, but tagged +* `source: 'nws'` so the store can dedup against forecast alerts and the UI can +* distinguish them. `tz` is the location's IANA zone (for date windows). +* @param {object} geojson +* @param {string} [tz] +* @returns {Array<{ id, type, severity, message, icon, source, event, effective, expires }>} +*/ +function normalizeAdvisories(geojson, tz) { + const features = geojson?.features || []; + const out = []; + const seen = /* @__PURE__ */ new Set(); + for (const f of features) { + const p = f.properties || {}; + const event = p.event || "Weather Alert"; + const type = classifyAdvisoryEvent(event); + const sev = nwsSeverity(p.severity); + const id = `nws-${advisoryId(p.id)}-${type}`; + if (seen.has(id)) continue; + seen.add(id); + const message = p.headline || `${event} in effect for ${(p.areaDesc || "").split(";")[0] || "your area"}`; + out.push({ + id, + type, + severity: sev, + message, + icon: advisoryIcon(event), + source: "nws", + event, + effective: p.effective, + expires: p.expires, + effectiveDate: advisoryDate(p.effective, tz), + expiresDate: advisoryDate(p.expires, tz) + }); + } + return out; +} +/** +* Fetch + normalize the official NWS active advisories for a point. +* US-only: throws for non-US points (404). Best-effort for the caller. +* @param {number} lat +* @param {number} lon +* @param {string} [tz] +* @returns {Promise} normalized advisory entries (see normalizeAdvisories) +*/ +async function fetchActiveAdvisories(lat, lon, tz) { + const url = `${API}/alerts/active?point=${lat},${lon}`; + const res = await fetch(url, { headers: { + "User-Agent": NWS_USER_AGENT, + "Accept": "application/geo+json" + } }); + if (!res.ok) throw new Error(`NWS alerts error: ${res.status} ${res.statusText}`); + return normalizeAdvisories(await res.json(), tz); +} //#endregion //#region \0vite/preload-helper.js var scriptRel = "modulepreload"; @@ -5776,7 +5883,8 @@ var AppStore = class { heat: true, cold: true, uv: true, - wind: true + wind: true, + advisory: true }, alertThresholds: { precip: 70, @@ -6211,8 +6319,35 @@ var APP_TITLES = { heat: "๐Ÿ”ฅ Heat alert", cold: "๐Ÿฅถ Cold alert", uv: "โ˜€๏ธ High UV", - wind: "๐Ÿ’จ Strong winds" + wind: "๐Ÿ’จ Strong winds", + advisory: "โš ๏ธ Weather advisory" }; +function mergeAdvisories(forecastAlerts, advisories, enabledTypes = null) { + const adv = advisories || []; + const out = []; + const seenAdv = /* @__PURE__ */ new Set(); + for (const alert of forecastAlerts) { + let superseded = false; + for (const a of adv) { + if (a.type !== alert.type) continue; + if (a.source !== "nws") continue; + const d = alert.date; + if (!d || !a.effectiveDate || !a.expiresDate) continue; + if (d >= a.effectiveDate && d <= a.expiresDate) { + superseded = true; + break; + } + } + if (!superseded) out.push(alert); + } + for (const a of adv) { + if (seenAdv.has(a.id)) continue; + if (enabledTypes && enabledTypes[a.type] === false) continue; + seenAdv.add(a.id); + out.push(a); + } + return out; +} var NotificationStore = class { #alerts = /* @__PURE__ */ state(proxy([])); get alerts() { @@ -6236,9 +6371,13 @@ var NotificationStore = class { heat: true, cold: true, uv: true, - wind: true + wind: true, + advisory: true }; _notifiedIds = /* @__PURE__ */ new Set(); + _nwsAdvisories = []; + _advisoriesFetchedAt = 0; + _advisoriesIntervalMs = 300 * 1e3; analyze() { const data = app$1.forecastData; const settings = app$1.settings; @@ -6263,12 +6402,13 @@ var NotificationStore = class { heat: types.heat !== false, cold: types.cold !== false, uv: types.uv !== false, - wind: types.wind !== false + wind: types.wind !== false, + advisory: types.advisory !== false }; const t = settings.alertThresholds; let newAlerts = []; const typeEnabled = this.typeEnabled; - function add(id, type, severity, message, icon) { + function add(id, type, severity, message, icon, date = null) { if (!app$1.settings.alertsEnabled) return; if (!typeEnabled[type]) return; newAlerts.push({ @@ -6276,29 +6416,32 @@ var NotificationStore = class { type, severity, message, - icon + icon, + date, + source: "forecast" }); } const current = data.current; const daily = data.daily || {}; - if (isSevereWeather(current.weather_code)) add("storm-now", "storm", "danger", "โšก Thunderstorm active โ€” seek shelter if outdoors", "โ›ˆ๏ธ"); + if (isSevereWeather(current.weather_code)) add("storm-now", "storm", "danger", "โšก Thunderstorm active โ€” seek shelter if outdoors", "โ›ˆ๏ธ", current.time ? String(current.time).slice(0, 10) : null); if (daily.time) for (let i = 0; i < daily.time.length && i < 3; i++) { const day = daily.time[i]; const dateLabel = i === 0 ? "Today" : i === 1 ? "Tomorrow" : parseLocalDate(day).toLocaleDateString("en-US", { weekday: "short" }); const precip = daily.precipitation_probability_max?.[i] || 0; - if (precip >= t.precip) add(`rain-${i}`, "precip", "warning", `๐ŸŒง๏ธ ${precip}% chance of rain ${dateLabel.toLowerCase()}`, "๐ŸŒง๏ธ"); + if (precip >= t.precip) add(`rain-${i}`, "precip", "warning", `๐ŸŒง๏ธ ${precip}% chance of rain ${dateLabel.toLowerCase()}`, "๐ŸŒง๏ธ", day); const code = daily.weather_code?.[i]; - if (isSevereWeather(code)) add(`storm-${i}`, "storm", "danger", `โ›ˆ๏ธ Thunderstorms expected ${dateLabel.toLowerCase()}`, "โ›ˆ๏ธ"); + if (isSevereWeather(code)) add(`storm-${i}`, "storm", "danger", `โ›ˆ๏ธ Thunderstorms expected ${dateLabel.toLowerCase()}`, "โ›ˆ๏ธ", day); const tempHigh = daily.temperature_2m_max?.[i]; - if (tempHigh != null && tempHigh > t.tempHigh) add(`heat-${i}`, "heat", "warning", `๐Ÿ”ฅ ${dateLabel} high of ${Math.round(tempHigh)}ยฐ โ€” stay hydrated`, "๐Ÿ”ฅ"); + if (tempHigh != null && tempHigh > t.tempHigh) add(`heat-${i}`, "heat", "warning", `๐Ÿ”ฅ ${dateLabel} high of ${Math.round(tempHigh)}ยฐ โ€” stay hydrated`, "๐Ÿ”ฅ", day); const tempMin = daily.temperature_2m_min?.[i]; - if (tempMin != null && tempMin < t.tempLow) add(`cold-${i}`, "cold", "warning", `๐Ÿฅถ ${dateLabel} low of ${Math.round(tempMin)}ยฐ โ€” bundle up`, "โ„๏ธ"); + if (tempMin != null && tempMin < t.tempLow) add(`cold-${i}`, "cold", "warning", `๐Ÿฅถ ${dateLabel} low of ${Math.round(tempMin)}ยฐ โ€” bundle up`, "โ„๏ธ", day); const uv = daily.uv_index_max?.[i]; - if (uv != null && uv >= t.uvIndex) add(`uv-${i}`, "uv", "warning", `โ˜€๏ธ High UV index (${uv}) ${dateLabel.toLowerCase()} โ€” wear sunscreen`, "๐Ÿงด"); + if (uv != null && uv >= t.uvIndex) add(`uv-${i}`, "uv", "warning", `โ˜€๏ธ High UV index (${uv}) ${dateLabel.toLowerCase()} โ€” wear sunscreen`, "๐Ÿงด", day); const wind = daily.wind_speed_10m_max?.[i]; - if (wind != null && wind >= t.windGust) add(`wind-${i}`, "wind", "warning", `๐Ÿ’จ Strong winds (${Math.round(wind)} ${app$1.settings.units === "imperial" ? "mph" : "km/h"}) ${dateLabel.toLowerCase()}`, "๐Ÿ’จ"); + if (wind != null && wind >= t.windGust) add(`wind-${i}`, "wind", "warning", `๐Ÿ’จ Strong winds (${Math.round(wind)} ${app$1.settings.units === "imperial" ? "mph" : "km/h"}) ${dateLabel.toLowerCase()}`, "๐Ÿ’จ", day); } newAlerts = newAlerts.filter((a) => !this.dismissedIds.has(a.id)); + newAlerts = mergeAdvisories(newAlerts, this._nwsAdvisories, this.typeEnabled).filter((a) => !this.dismissedIds.has(a.id)); this.alerts = newAlerts; for (const a of newAlerts) { if (this._notifiedIds.has(a.id)) continue; @@ -6312,6 +6455,29 @@ var NotificationStore = class { } } /** + * Fetch the official NWS advisories for the selected location (best-effort, + * cached). On success/unchanged it re-runs analyze() so the alert set updates. + * Non-US points and network errors simply leave advisories unset. + */ + async refreshAdvisories() { + const loc = app$1.selectedLocation; + if (!loc) { + this._nwsAdvisories = []; + return; + } + const now = Date.now(); + if (now - this._advisoriesFetchedAt < this._advisoriesIntervalMs) return; + this._advisoriesFetchedAt = now; + try { + const tz = app$1.forecastData?.timezone; + this._nwsAdvisories = await fetchActiveAdvisories(loc.lat, loc.lon, tz); + } catch (e) { + this._nwsAdvisories = []; + console.warn("NWS advisories unavailable:", e.message); + } + this.analyze(); + } + /** * Dismiss a specific alert. Persists to IndexedDB. * @param {string} alertId */ @@ -8286,7 +8452,8 @@ function SettingsDialog($$anchor, $$props) { heat: "High heat", cold: "Cold snap", uv: "High UV index", - wind: "Strong winds" + wind: "Strong winds", + advisory: "Official NWS advisories" }; let localSettings = proxy({ ...app$1.settings, @@ -8470,7 +8637,8 @@ function SettingsDialog($$anchor, $$props) { "heat", "cold", "uv", - "wind" + "wind", + "advisory" ], index, ($$anchor, key) => { var div_17 = root$2(); var span = child(div_17); @@ -8851,11 +9019,17 @@ function App($$anchor, $$props) { async function initApp() { await app$1.init(); if (app$1.locations.length === 0) await tryGeolocation(); - if (app$1.forecastData) notifications.analyze(); + if (app$1.forecastData) { + notifications.analyze(); + notifications.refreshAdvisories(); + } set(initDone, true); } user_effect(() => { - if (app$1.forecastData && app$1.settings.alertsEnabled) notifications.analyze(); + if (app$1.forecastData && app$1.settings.alertsEnabled) { + notifications.analyze(); + notifications.refreshAdvisories(); + } }); user_effect(() => { app$1.settings.refreshInterval; @@ -9140,7 +9314,7 @@ delegate([ ]); //#endregion //#region src/main.js -console.info({ commit_hash: "2eb34a709b0f3e5322756e91e014abdaf754a526" }); +console.info({ commit_hash: "97b676093a00eb7dc56bd07219df7b9446bc6e2e" }); registerServiceWorker(); mount(App, { target: document.getElementById("app") }); //#endregion diff --git a/src/App.svelte b/src/App.svelte index e387f44..fe16c3a 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -156,6 +156,7 @@ // Trigger alert analysis after initial forecast if (app.forecastData) { notifications.analyze() + notifications.refreshAdvisories() // official NWS advisories (cached, best-effort) } initDone = true @@ -165,6 +166,7 @@ $effect(() => { if (app.forecastData && app.settings.alertsEnabled) { notifications.analyze() + notifications.refreshAdvisories() // official NWS advisories (cached, best-effort) } }) diff --git a/src/components/SettingsDialog.svelte b/src/components/SettingsDialog.svelte index bc80d1f..4e59cb7 100644 --- a/src/components/SettingsDialog.svelte +++ b/src/components/SettingsDialog.svelte @@ -12,6 +12,7 @@ cold: 'Cold snap', uv: 'High UV index', wind: 'Strong winds', + advisory: 'Official NWS advisories', } let localSettings = $state({ @@ -217,7 +218,7 @@ - {#each ['storm', 'precip', 'heat', 'cold', 'uv', 'wind'] as key} + {#each ['storm', 'precip', 'heat', 'cold', 'uv', 'wind', 'advisory'] as key}
{TYPE_LABELS[key]}