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.
This commit is contained in:
parent
97b676093a
commit
882fd69a18
212
dist/index.html
vendored
212
dist/index.html
vendored
@ -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>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -134,6 +134,7 @@ const DEFAULT_SETTINGS = {
|
||||
cold: true,
|
||||
uv: true,
|
||||
wind: true,
|
||||
advisory: true, // official NWS advisories (watches/warnings)
|
||||
},
|
||||
alertThresholds: {
|
||||
precip: 70,
|
||||
|
||||
@ -43,6 +43,7 @@ export class AppStore {
|
||||
cold: true,
|
||||
uv: true,
|
||||
wind: true,
|
||||
advisory: true, // official NWS advisories (watches/warnings)
|
||||
},
|
||||
alertThresholds: {
|
||||
precip: 70,
|
||||
|
||||
@ -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
|
||||
|
||||
170
tests/lib/api/advisories.test.js
Normal file
170
tests/lib/api/advisories.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user