Add official NWS advisories as notifications, deduping forecast alerts
All checks were successful
Test, Build & Deploy / test-and-build (push) Successful in 25s
Test, Build & Deploy / deploy (push) Successful in 20s

- 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.
This commit is contained in:
hermes-explorigin 2026-09-08 02:33:37 +00:00
parent 97b676093a
commit 882fd69a18
9 changed files with 652 additions and 29 deletions

212
dist/index.html vendored
View File

@ -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<Array>} 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</script>

View File

@ -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)
}
})

View File

@ -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 @@
</select>
</div>
{#each ['storm', 'precip', 'heat', 'cold', 'uv', 'wind'] as key}
{#each ['storm', 'precip', 'heat', 'cold', 'uv', 'wind', 'advisory'] as key}
<div class="setting-row">
<span>{TYPE_LABELS[key]}</span>
<label class="toggle">

View File

@ -278,3 +278,121 @@ export function mergeFallbackData(nwsData, omData) {
if (Object.keys(fallback).length) nwsData._fallback = fallback
return nwsData
}
// ===========================================================================
// Official NWS advisories (api.weather.gov /alerts/active)
// ===========================================================================
/**
* 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}
*/
export 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. */
export 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.
*/
export function advisoryId(urn) {
if (!urn) return 'adv'
// urn:oid:2.49.0.1.840...last.two.segments.P1
const parts = String(urn).split('.')
// keep the trailing numeric segments = rightmost 3 for a reasonabably stable id
const tail = parts.slice(-3).join('.')
return tail.replace(/[^a-zA-Z0-9]/g, '') || 'adv'
}
/** Icon + a short tag for an advisory event (used in the alert entry). */
export 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 }>}
*/
export function normalizeAdvisories(geojson, tz) {
const features = geojson?.features || []
const out = []
const seen = 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)
// Prefer the headline; fall back to a compact summary.
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,
// Local calendar-days the advisory is in force (for dedup windows).
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<Array>} normalized advisory entries (see normalizeAdvisories)
*/
export 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)
}

View File

@ -134,6 +134,7 @@ const DEFAULT_SETTINGS = {
cold: true,
uv: true,
wind: true,
advisory: true, // official NWS advisories (watches/warnings)
},
alertThresholds: {
precip: 70,

View File

@ -43,6 +43,7 @@ export class AppStore {
cold: true,
uv: true,
wind: true,
advisory: true, // official NWS advisories (watches/warnings)
},
alertThresholds: {
precip: 70,

View File

@ -9,6 +9,7 @@ import { app } from './app.svelte.js'
import { saveSettings } from '../storage/db.js'
import { parseLocalDate } from '../dates.js'
import { notify } from '../pwa.js'
import { fetchActiveAdvisories } from '../api/nws.js'
const APP_TITLES = {
storm: '⛈️ Severe weather',
@ -17,6 +18,50 @@ const APP_TITLES = {
cold: '🥶 Cold alert',
uv: '☀️ High UV',
wind: '💨 Strong winds',
advisory: '⚠️ Weather advisory',
}
/**
* Merge forecast-derived alerts (%Chance, temps, storms from the forecast
* endpoints) with official NWS advisories, deduplicating:
*
* An advisory that is officially in force on a date *supersedes* the derived
* forecast alert of the same type for that same date the NWS warning is the
* authoritative signal, so we keep the advisory and drop the forecast twin.
* (e.g. an NWS "Excessive Heat Warning" for today replaces the derived heat
* alert that otherwise guessed "🔥 Today high of 105°").
*
* Advisories cannot be dismissed by the derived-alert ids and vice versa; ids
* already carry the source (`nws-…` vs `heat-…`), so dismissal stays correct.
*
* @param {Array} forecastAlerts alerts derived from forecast data (source 'forecast')
* @param {Array} advisories official NWS advisories (source 'nws')
* @returns {Array} merged alerts
*/
export function mergeAdvisories(forecastAlerts, advisories, enabledTypes = null) {
const adv = advisories || []
const out = []
const seenAdv = new Set()
for (const alert of forecastAlerts) {
// Does an active advisory supersede this derived alert?
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)
}
// Append advisories that pass the per-type toggle and aren't the user's.
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
}
export class NotificationStore {
@ -37,10 +82,17 @@ export class NotificationStore {
cold: true,
uv: true,
wind: true,
advisory: true,
}
/** Alert ids already surfaced as a native notification this forecast day,
* so an auto-refresh doesn't re-notify the same condition every interval. */
_notifiedIds = new Set()
/** Official NWS advisories (source 'nws'), cached from the alerts feed.
* Populated by refreshAdvisories(); read by analyze() for dedup + display. */
_nwsAdvisories = []
/** Cache control for the advisories feed (don't hammer api.weather.gov). */
_advisoriesFetchedAt = 0
_advisoriesIntervalMs = 5 * 60 * 1000 // refresh at most every 5 minutes
/**
* Check forecast data and generate appropriate alerts.
@ -78,16 +130,17 @@ export class NotificationStore {
cold: types.cold !== false,
uv: types.uv !== 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.settings.alertsEnabled) return
if (!typeEnabled[type]) return
newAlerts.push({ id, type, severity, message, icon })
newAlerts.push({ id, type, severity, message, icon, date, source: 'forecast' })
}
const current = data.current
@ -97,7 +150,7 @@ export class NotificationStore {
if (isSevereWeather(current.weather_code)) {
add('storm-now', 'storm', 'danger',
'⚡ Thunderstorm active — seek shelter if outdoors', '⛈️')
'⚡ Thunderstorm active — seek shelter if outdoors', '⛈️', current.time ? String(current.time).slice(0, 10) : null)
}
// --- Daily forecast alerts ---
@ -110,42 +163,47 @@ export class NotificationStore {
const precip = daily.precipitation_probability_max?.[i] || 0
if (precip >= t.precip) {
add(`rain-${i}`, 'precip', 'warning',
`🌧️ ${precip}% chance of rain ${dateLabel.toLowerCase()}`, '🌧️')
`🌧️ ${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()}`, '⛈️')
`⛈️ 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`, '🔥')
`🔥 ${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`, '❄️')
`🥶 ${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`, '🧴')
`☀️ 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.settings.units === 'imperial' ? 'mph' : 'km/h'}) ${dateLabel.toLowerCase()}`, '💨')
`💨 Strong winds (${Math.round(wind)} ${app.settings.units === 'imperial' ? 'mph' : 'km/h'}) ${dateLabel.toLowerCase()}`, '💨', day)
}
}
}
newAlerts = newAlerts.filter((a) => !this.dismissedIds.has(a.id))
// Merge official NWS advisories, deduping forecast twins that overlap.
newAlerts = mergeAdvisories(newAlerts, this._nwsAdvisories, this.typeEnabled)
.filter((a) => !this.dismissedIds.has(a.id))
this.alerts = newAlerts
// Lightweight native notifications: surface alerts that just appeared and
@ -164,6 +222,27 @@ export class NotificationStore {
}
}
/**
* 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.selectedLocation
if (!loc) { this._nwsAdvisories = []; return }
const now = Date.now()
if (now - this._advisoriesFetchedAt < this._advisoriesIntervalMs) return
this._advisoriesFetchedAt = now
try {
const tz = app.forecastData?.timezone
this._nwsAdvisories = await fetchActiveAdvisories(loc.lat, loc.lon, tz)
} catch (e) {
this._nwsAdvisories = [] // US-only; non-US points 404 -> no advisories
console.warn('NWS advisories unavailable:', e.message)
}
this.analyze()
}
/**
* Dismiss a specific alert. Persists to IndexedDB.
* @param {string} alertId

View File

@ -0,0 +1,170 @@
import { describe, it, expect } from 'vitest'
import {
classifyAdvisoryEvent,
nwsSeverity,
advisoryId,
normalizeAdvisories,
} from '../../../src/lib/api/nws.js'
import { mergeAdvisories } from '../../../src/lib/stores/notifications.svelte.js'
// A realistic raw feature from /alerts/active
const heat = {
properties: {
id: 'urn:oid:2.49.0.1.840.0.e6e2f1.001.2',
event: 'Excessive Heat Warning',
severity: 'Severe',
headline: 'Excessive Heat Warning issued September 7 at 7:02PM CDT until September 8 at 7:00PM CDT by NWS Tulsa OK',
areaDesc: 'Rogers; Tulsa; Creek',
effective: '2026-09-07T19:02:00-05:00',
expires: '2026-09-08T19:15:00-05:00',
},
}
const wind = {
properties: {
id: 'urn:oid:2.49.0.1.840.0.9ab.001.1',
event: 'Wind Advisory',
severity: 'Moderate',
headline: 'Wind Advisory issued',
areaDesc: 'Benton; Washington',
effective: '2026-09-07T12:00:00-05:00',
expires: '2026-09-07T20:00:00-05:00',
},
}
const flood = {
properties: {
id: 'urn:oid:2.49.0.1.840.0.7c.001.3',
event: 'Flash Flood Warning',
severity: 'Severe',
headline: 'Flash Flood Warning issued',
areaDesc: 'Rogers; Delaware',
effective: '2026-09-08T01:00:00-05:00',
expires: '2026-09-08T04:00:00-05:00',
},
}
const unknown = {
properties: {
id: 'urn:oid:2.49.0.1.840.0.xyz.001.9',
event: 'Special Weather Statement',
severity: 'Unknown',
headline: 'Special Weather Statement issued',
areaDesc: 'Rogers',
effective: '2026-09-07T10:00:00-05:00',
expires: '2026-09-07T13:00:00-05:00',
},
}
describe('classifyAdvisoryEvent', () => {
it('maps NWS events to app alert types', () => {
expect(classifyAdvisoryEvent('Excessive Heat Warning')).toBe('heat')
expect(classifyAdvisoryEvent('Heat Advisory')).toBe('heat')
expect(classifyAdvisoryEvent('Wind Advisory')).toBe('wind')
expect(classifyAdvisoryEvent('High Wind Warning')).toBe('wind')
expect(classifyAdvisoryEvent('Winter Storm Warning')).toBe('cold')
expect(classifyAdvisoryEvent('Blizzard Warning')).toBe('cold')
expect(classifyAdvisoryEvent('Severe Thunderstorm Warning')).toBe('storm')
expect(classifyAdvisoryEvent('Tornado Warning')).toBe('storm')
expect(classifyAdvisoryEvent('Flash Flood Warning')).toBe('precip')
})
it('falls back to advisory type for unrecognized events', () => {
expect(classifyAdvisoryEvent('Special Weather Statement')).toBe('advisory')
expect(classifyAdvisoryEvent('Air Quality Alert')).toBe('advisory')
})
})
describe('nwsSeverity', () => {
it('maps NWS severities', () => {
expect(nwsSeverity('Extreme')).toBe('danger')
expect(nwsSeverity('Severe')).toBe('danger')
expect(nwsSeverity('Moderate')).toBe('warning')
expect(nwsSeverity('Minor')).toBe('info')
expect(nwsSeverity('Unknown')).toBe('info')
})
})
describe('advisoryId', () => {
it('derives a stable short id from the URN', () => {
const a = advisoryId('urn:oid:2.49.0.1.840.0.e6e2f1.001.2')
const b = advisoryId('urn:oid:2.49.0.1.840.0.e6e2f1.001.2')
expect(a).toBe(b)
expect(a.length).toBeGreaterThan(0)
})
it('handles missing ids', () => {
expect(advisoryId('')).toBe('adv')
expect(advisoryId(null)).toBe('adv')
})
})
describe('normalizeAdvisories', () => {
it('produces app-shaped alert entries tagged source nws', () => {
const out = normalizeAdvisories({ features: [heat, wind, flood, unknown] }, 'America/Chicago')
expect(out).toHaveLength(4)
const h = out.find((a) => a.event === 'Excessive Heat Warning')
expect(h.type).toBe('heat')
expect(h.severity).toBe('danger')
expect(h.source).toBe('nws')
expect(h.id).toMatch(/^nws-/)
expect(h.effectiveDate).toBe('2026-09-07')
expect(h.expiresDate).toBe('2026-09-08')
expect(h.icon).toBe('🔥')
expect(h.message).toContain('Excessive Heat Warning issued')
})
it('handles empty feed', () => {
expect(normalizeAdvisories({ features: [] })).toEqual([])
expect(normalizeAdvisories(null)).toEqual([])
})
it('dedupes duplicate ids within the feed', () => {
const out = normalizeAdvisories({ features: [heat, heat] }, 'UTC')
expect(out).toHaveLength(1)
})
})
describe('mergeAdvisories (dedup)', () => {
const forecastAlerts = [
// derived heat for today and tomorrow (idx 0 and 1)
{ id: 'heat-0', type: 'heat', date: '2026-09-07', source: 'forecast' },
{ id: 'heat-1', type: 'heat', date: '2026-09-08', source: 'forecast' },
{ id: 'rain-0', type: 'precip', date: '2026-09-07', source: 'forecast' },
{ id: 'wind-0', type: 'wind', date: '2026-09-07', source: 'forecast' },
]
const advisories = [
// NWS heat warning covering 9/7 only (effective..expires that one day)
{ id: 'nws-h', type: 'heat', source: 'nws', effectiveDate: '2026-09-07', expiresDate: '2026-09-07' },
{ id: 'nws-flood', type: 'precip', source: 'nws', effectiveDate: '2026-09-08', expiresDate: '2026-09-08' },
]
it('drops the derived alert whose date sits under an advisory of the same type', () => {
const merged = mergeAdvisories(forecastAlerts, advisories)
// heat-0 (9/7) is under the 9/7 heat advisory -> gone
// heat-1 (9/8) is NOT under the heat advisory (ends 9/7) -> kept
const ids = merged.map((a) => a.id)
expect(ids).not.toContain('heat-0')
expect(ids).toContain('heat-1')
expect(ids).toContain('nws-h')
})
it('keeps derived alerts of types/flags without an overlapping advisory', () => {
const merged = mergeAdvisories(forecastAlerts, advisories)
// rain-0 is on 9/7 but flood advisory is 9/8 -> kept
// wind has no advisory -> kept
const ids = merged.map((a) => a.id)
expect(ids).toContain('rain-0')
expect(ids).toContain('wind-0')
// flood advisory itself appended
expect(ids).toContain('nws-flood')
})
it('does not drop derived alerts when no advisory overlaps', () => {
const merged = mergeAdvisories(forecastAlerts, [])
expect(merged.map((a) => a.id)).toEqual(forecastAlerts.map((a) => a.id))
})
it('respects per-type toggles (disabled advisories are not appended)', () => {
const merged = mergeAdvisories(forecastAlerts, advisories, { heat: false, precip: true, wind: true, cold: true, uv: true, storm: true, advisory: true })
const ids = merged.map((a) => a.id)
expect(ids).not.toContain('nws-h')
expect(ids).toContain('nws-flood')
})
})

View File

@ -243,3 +243,80 @@ describe('NotificationStore per-type toggles', () => {
expect(notifications.alerts).toHaveLength(0)
})
})
describe('NotificationStore with NWS advisories', () => {
let notifications
beforeEach(() => {
notifications = new NotificationStore()
notifications.alerts = []
notifications.dismissedIds.clear()
notifications._notifiedIds.clear()
notifications._nwsAdvisories = []
})
function seedAdvisoryForecast(overrides = {}) {
app.forecastData = {
current: { weather_code: 95, time: '2026-07-22T12:00:00' },
daily: {
time: ['2026-07-22', '2026-07-23'],
precipitation_probability_max: [85, 10],
weather_code: [95, 95],
temperature_2m_max: [38, 25],
temperature_2m_min: [20, 15],
uv_index_max: [3, 3],
wind_speed_10m_max: [10, 10],
...(overrides.daily || {}),
},
}
app.settings = {
alertsEnabled: true,
units: 'metric',
alertThresholds: { precip: 70, windGust: 40, uvIndex: 6, tempHigh: 35, tempLow: 0 },
notificationTypes: { storm: true, precip: true, heat: true, cold: true, uv: true, wind: true, advisory: true },
}
}
it('appends an advisory and does not duplicate a storm that overlaps', () => {
seedAdvisoryForecast()
notifications._nwsAdvisories = [
{ id: 'nws-storm', type: 'storm', source: 'nws', severity: 'danger', icon: '⛈️',
message: 'Severe Thunderstorm Warning issued', event: 'Severe Thunderstorm Warning',
effectiveDate: '2026-07-22', expiresDate: '2026-07-22' },
]
notifications.analyze()
notifications.analyze() // idempotent
const stormAlerts = notifications.alerts.filter((a) => a.type === 'storm')
// The derived storm-0 (7/22) is suppressed by the advisory; storm-1 stays.
expect(stormAlerts.some((a) => a.id === 'nws-storm')).toBe(true)
expect(stormAlerts.some((a) => a.id === 'storm-0')).toBe(false)
expect(stormAlerts.some((a) => a.id === 'storm-1')).toBe(true)
})
it('shows both a derived alert and an advisory when dates do not overlap', () => {
seedAdvisoryForecast()
// Heat advisory covers 7/23 only; derived heat is 7/22 -> both stay.
notifications._nwsAdvisories = [
{ id: 'nws-heat', type: 'heat', source: 'nws', severity: 'warning', icon: '🔥',
message: 'Heat Advisory issued', event: 'Heat Advisory',
effectiveDate: '2026-07-23', expiresDate: '2026-07-23' },
]
notifications.analyze()
const heatAlerts = notifications.alerts.filter((a) => a.type === 'heat')
expect(heatAlerts.some((a) => a.id === 'heat-0')).toBe(true)
expect(heatAlerts.some((a) => a.id === 'nws-heat')).toBe(true)
})
it('respects the advisory type toggle', () => {
seedAdvisoryForecast()
app.settings.notificationTypes.advisory = false
notifications._nwsAdvisories = [
{ id: 'nws-adv', type: 'advisory', source: 'nws', severity: 'info', icon: '⚠️',
message: 'Special Weather Statement issued', event: 'Special Weather Statement',
effectiveDate: '2026-07-22', expiresDate: '2026-07-23' },
]
notifications.analyze()
expect(notifications.alerts.some((a) => a.id === 'nws-adv')).toBe(false)
})
})