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]}