weather_app/src/lib/stores/notifications.svelte.js
hermes-explorigin 882fd69a18
All checks were successful
Test, Build & Deploy / test-and-build (push) Successful in 25s
Test, Build & Deploy / deploy (push) Successful in 20s
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.
2026-09-08 02:33:37 +00:00

287 lines
10 KiB
JavaScript

/**
* Weather alert / notification store using Svelte 5 runes.
* Monitors forecast data and generates alert banners.
* Dismissed alerts persist in IndexedDB and only reset when the forecast date changes.
*/
import { isSevereWeather } from '../api/weather-codes.js'
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',
precip: '🌧️ Rain expected',
heat: '🔥 Heat alert',
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 {
/** @type {Array<{ id: string, type: string, severity: 'info'|'warning'|'danger', message: string, icon: string }>} */
alerts = $state([])
/** @type {Set<string>} */
dismissedIds = $state(new Set())
/** Track which forecast day last generated alerts — reset dismissals when the day changes */
lastForecastDate = null
/** Whether dismissals have been loaded from DB */
_dismissalsLoaded = false
/** Per-type alert toggles. Defaults to all-on so existing saved settings
* (which predate this field) still alert for every kind. */
typeEnabled = {
storm: true,
precip: true,
heat: true,
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.
*/
analyze() {
const data = app.forecastData
const settings = app.settings
if (!data || !settings.alertsEnabled) {
this.alerts = []
return
}
// Load persisted dismissals from DB on first analysis
if (!this._dismissalsLoaded) {
this._dismissalsLoaded = true
this._loadDismissed()
}
// Track the forecast date. If it changed, reset dismissals.
const forecastDate = data.daily?.time?.[0] || null
if (forecastDate && this.lastForecastDate && forecastDate !== this.lastForecastDate) {
this.resetDismissed()
this._notifiedIds.clear() // new forecast day -> allow re-notifying
}
this.lastForecastDate = forecastDate
// Apply per-type toggles from settings, falling back to all-on when the
// preference is absent (so the change is backward compatible).
const types = settings.notificationTypes || {}
this.typeEnabled = {
storm: types.storm !== false,
precip: types.precip !== false,
heat: types.heat !== false,
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, date = null) {
if (!app.settings.alertsEnabled) return
if (!typeEnabled[type]) return
newAlerts.push({ id, type, severity, message, icon, date, source: 'forecast' })
}
const current = data.current
const daily = data.daily || {}
// --- Current weather alerts ---
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)
}
// --- Daily forecast alerts ---
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()}`, '🌧️', day)
}
const code = daily.weather_code?.[i]
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`, '🔥', 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`, '❄️', 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`, '🧴', 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()}`, '💨', 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
// haven't been notified this forecast day. Best-effort — never blocks.
// NOTE: iterate the local `newAlerts` (not reactive `this.alerts`) — a
// read-after-write on $state inside this $effect would loop forever.
for (const a of newAlerts) {
if (this._notifiedIds.has(a.id)) continue
this._notifiedIds.add(a.id)
notify({
title: APP_TITLES[a.type] || 'Weather alert',
body: a.message,
icon: './icons/icon-192.png',
tag: `weather-${a.type}`,
})
}
}
/**
* 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
*/
dismiss(alertId) {
this.dismissedIds.add(alertId)
this.alerts = this.alerts.filter((a) => a.id !== alertId)
this._persistDismissed()
}
/**
* Reset all dismissed alerts (e.g., when forecast date changes).
*/
resetDismissed() {
this.dismissedIds.clear()
this._persistDismissed()
}
/**
* Load dismissed alert IDs from the persisted settings.
*/
_loadDismissed() {
const saved = app.settings.dismissedAlerts || []
this.dismissedIds = new Set(saved)
}
/**
* Save dismissed alert IDs to settings in IndexedDB.
*/
async _persistDismissed() {
const arr = Array.from(this.dismissedIds)
app.settings.dismissedAlerts = arr
try {
await saveSettings(app.settings)
} catch (e) {
console.error('Failed to persist dismissed alerts:', e)
}
}
}
export const notifications = new NotificationStore()