/** * 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} */ 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()