- 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.
287 lines
10 KiB
JavaScript
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()
|