diff --git a/AGENTS.md b/AGENTS.md index ed3773f..6d0d622 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,16 +18,21 @@ src/ │ ├── DailyForecast.svelte # 7-day forecast cards with icons + detail │ ├── WeatherDetail.svelte # Expanded metrics: wind, atmosphere, sun/UV, temps │ ├── NotificationBell.svelte # Bell icon with badge; dropdown lists/dismisses alerts +│ ├── StormWatch.svelte # Storms tab: active alerts, SPC outlook, recent reports │ └── SettingsDialog.svelte # Units (metric/imperial), alert thresholds, data reset ├── lib/ │ ├── autofocus.js # Svelte action for autofocus on mount │ ├── api/ │ │ ├── weather.js # Open-Meteo fetch functions (forecast + geocoding) +│ │ ├── nws.js # NWS forecast + official advisories normalization +│ │ ├── storm.js # NWS alerts, SPC outlooks, IEM storm reports (+ geo helpers) +│ │ ├── moon.js # Moon phase calculation │ │ └── weather-codes.js # WMO code → icon/description/severity mapping │ ├── storage/ │ │ └── db.js # IndexedDB layer (idb): locations, settings │ └── stores/ │ ├── app.svelte.js # AppStore: $state for locations, forecast, UI, settings +│ ├── storm.svelte.js # StormStore: alerts/outlook/reports for the selected point │ └── notifications.svelte.js # Alert generation from forecast data ``` @@ -67,6 +72,33 @@ The notification store checks each forecast refresh for: All thresholds are configurable in Settings. +## Storm Watch (SPC / NWS / IEM) + +The **Storms** tab (and the red banner above the content when a tornado, severe +thunderstorm or other Extreme alert is in force) is driven by three keyless, +CORS-enabled feeds. All of them are fetched directly from the browser. + +| Source | Endpoint | Refresh | +|---|---|---| +| NWS active alerts | `api.weather.gov/alerts/active?point={lat},{lon}` | 2 min HTTP cache | +| SPC Day 1–3 outlook | `spc.noaa.gov/products/outlook/day{1,2,3}otlk_{cat,torn,wind,hail}.nolyr.geojson` | 20 min HTTP cache | +| NWS Local Storm Reports | `mesonet.agron.iastate.edu/geojson/lsr.geojson?west=…&sts=…&ets=…` | 5 min HTTP cache | + +- **SPC risk at a point** comes from point-in-polygon on the GeoJSON features + (`LABEL` of the *highest* containing category wins: TSTM < MRGL < SLGT < ENH + < MDT < HIGH). Day-1 tornado/hail/wind probabilities use the same test, with + the "Less Than 2% All Areas" background polygon ignored. +- **There is no Skywarn API** — Skywarn is a per-WFO volunteer program. The + equivalent machine-readable ground truth is NWS Local Storm Reports (LSRs), + which include trained-spotter and Spotter Network submissions. +- The IEM LSR query **must include `sts`/`ets`**; a bbox alone (or `hours=`) + returns national reports. `normalizeStormReports` then applies the exact + radius cut (160 km by default) and sorts nearest-first. +- Non-US points skip the SPC/LSR requests (`isUsPoint`) and the UI explains the + US-only coverage. +- The store de-dupes concurrent loads, ignores stale responses after a + location switch, and polls every 5 min only while the document is visible. + ## Scripts | Command | Description | @@ -86,6 +118,9 @@ All thresholds are configurable in Settings. - `tests/lib/storage/db.test.js` — Locations CRUD (add, update, delete, setCurrent, clear), settings CRUD with defaults - `tests/lib/stores/app.test.js` — AppStore initialization and derived selectedLocation - `tests/lib/stores/notifications.test.js` — Alert generation for all alert types, dismissal, reset, day filtering + - `tests/lib/api/storm.test.js` — Point-in-polygon, risk ranking, SPC probabilities, LSR radius filter, alert sorting + - `tests/lib/stores/storm.test.js` — StormStore loading, caching, location switches, partial failures, non-US points + - `tests/components/storm-watch.test.js` — StormWatch renders populated and empty states (Svelte `mount` in jsdom) - Run with `npm run test` or `npm run test:run` ## API Notes @@ -95,3 +130,7 @@ All thresholds are configurable in Settings. - Geocoding API returns up to 8 results per query with name, coordinates, country, admin1, timezone. - Rate limits are generous (~10,000 requests/day for the free tier). - Used only via `fetch()` in browser — no server-side API calls needed. +- SPC outlook files are regenerated at each issuance (roughly 12Z/13Z/16:30Z/01Z); + never hardcode a timestamp, just re-fetch the stable `.nolyr.geojson` names. +- NWS/SPC/IEM all send `Access-Control-Allow-Origin: *`, so the static single-file + build can call them without a proxy. diff --git a/dist/favicon.svg b/dist/favicon.svg deleted file mode 100644 index c561ec9..0000000 --- a/dist/favicon.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/dist/icons.svg b/dist/icons.svg deleted file mode 100644 index 72899a1..0000000 --- a/dist/icons.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/dist/index.html b/dist/index.html index 1158421..b3c00e3 100644 --- a/dist/index.html +++ b/dist/index.html @@ -8,7 +8,7 @@ - + WeatherLens @@ -98,6 +98,26 @@ function deferred() { reject }; } +/** +* When encountering a situation like `let [a, b, c] = $derived(blah())`, +* we need to stash an intermediate value that `a`, `b`, and `c` derive +* from, in case it's an iterable +* @template T +* @param {ArrayLike | Iterable} value +* @param {number} [n] +* @returns {Array} +*/ +function to_array(value, n) { + if (Array.isArray(value)) return value; + if (n === void 0 || !(Symbol.iterator in value)) return Array.from(value); + /** @type {T[]} */ + const array = []; + for (const element of value) { + array.push(element); + if (array.length === n) break; + } + return array; +} var CLEAN = 1024; var DIRTY = 2048; var MAYBE_DIRTY = 4096; @@ -5321,7 +5341,7 @@ var init_weather = __esmMin((() => { //#region src/lib/config.js init_db(); init_weather(); -var NWS_USER_AGENT = `WeatherLens/0.1 (explorigin@gmail.com)`; +var NWS_USER_AGENT$1 = `WeatherLens/0.1 (explorigin@gmail.com)`; //#endregion //#region src/lib/api/nws.js /** @@ -5336,7 +5356,7 @@ var API = "https://api.weather.gov"; async function jsonGet(url) { if (!url) throw new Error("Missing NWS forecast URL"); const res = await fetch(url, { headers: { - "User-Agent": NWS_USER_AGENT, + "User-Agent": NWS_USER_AGENT$1, "Accept": "application/geo+json" } }); if (!res.ok) throw new Error(`NWS API error: ${res.status} ${res.statusText}`); @@ -5710,12 +5730,32 @@ function normalizeAdvisories(geojson, tz) { 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, + "User-Agent": NWS_USER_AGENT$1, "Accept": "application/geo+json" } }); if (!res.ok) throw new Error(`NWS alerts error: ${res.status} ${res.statusText}`); return normalizeAdvisories(await res.json(), tz); } +var store = /* @__PURE__ */ new Map(); +/** Key includes units/source so a unit or source change cannot serve wrong data. */ +function cacheKey(location, source, units) { + return `${location.lat},${location.lon}|${source}|${units}`; +} +function getCachedForecast(key, now = Date.now()) { + const hit = store.get(key); + if (!hit) return null; + if (now - hit.ts >= 3e5) { + store.delete(key); + return null; + } + return hit.data; +} +function setCachedForecast(key, data, now = Date.now()) { + store.set(key, { + data, + ts: now + }); +} //#endregion //#region \0vite/preload-helper.js var scriptRel = "modulepreload"; @@ -5939,16 +5979,26 @@ var AppStore = class { /** * Fetch forecast data for the currently selected location. */ - async refreshForecast(locationId = null) { + async refreshForecast(locationId = null, { force = false } = {}) { const locId = locationId || this.selectedLocationId; if (!locId) return; const location = this.locations.find((l) => l.id === locId); if (!location) return; + const units = this.settings.units; + const source = this.settings.source || "open-meteo"; + const key = cacheKey(location, source, units); + if (!force) { + const cached = getCachedForecast(key); + if (cached) { + this.forecastData = cached; + this.error = ""; + this.loading = false; + return; + } + } this.loading = true; this.error = ""; try { - const units = this.settings.units; - const source = this.settings.source || "open-meteo"; let data; if (source === "nws") try { data = await fetchForecastNWS(location.lat, location.lon, units); @@ -5964,7 +6014,7 @@ var AppStore = class { } else data = await fetchForecast(location.lat, location.lon, units); this.forecastData = data; - if (data.timezone_abbreviation && !location.timezone) {} + setCachedForecast(key, data); } catch (e) { this.error = e.message || "Failed to fetch weather data"; console.error("Forecast fetch error:", e); @@ -5981,7 +6031,7 @@ var AppStore = class { const minutes = this.settings.refreshInterval; if (!minutes || minutes <= 0) return; this._refreshTimer = setInterval(() => { - this.refreshForecast(); + this.refreshForecast(null, { force: true }); }, minutes * 60 * 1e3); } /** @@ -6513,6 +6563,589 @@ var NotificationStore = class { }; var notifications = new NotificationStore(); //#endregion +//#region src/lib/api/storm.js +/** +* Storm-forecasting data sources. +* +* Three independent, keyless, CORS-enabled feeds: +* 1. NWS active alerts — api.weather.gov/alerts/active?point=… (watches/warnings) +* 2. SPC convective outlooks — spc.noaa.gov/products/outlook/*.nolyr.geojson +* (Day 1–3 categorical risk + Day 1 tornado/hail/wind probabilities) +* 3. IEM Local Storm Reports — mesonet.agron.iastate.edu/geojson/lsr.geojson +* (ground-truth reports relayed from NWS WFOs, incl. trained spotters) +* +* Everything here is plain browser fetch + pure normalization, so the helpers +* are unit-testable without a network (see tests/lib/api/storm.test.js). +*/ +var SPC_BASE = "https://www.spc.noaa.gov/products/outlook"; +var IEM_LSR = "https://mesonet.agron.iastate.edu/geojson/lsr.geojson"; +var NWS_API = "https://api.weather.gov"; +var NWS_USER_AGENT = "WeatherLens/0.1 (weather app)"; +/** SPC categorical outlook levels, weakest → strongest. */ +var RISK_RANK = { + NONE: 0, + TSTM: 1, + MRGL: 2, + SLGT: 3, + ENH: 4, + MDT: 5, + HIGH: 6 +}; +/** Display metadata per SPC category (colors follow the official SPC palette). */ +var RISK_META = { + NONE: { + label: "No outlook", + short: "—", + color: "#7b8eb5", + rank: 0 + }, + TSTM: { + label: "General thunderstorms", + short: "TSTM", + color: "#55bb55", + rank: 1 + }, + MRGL: { + label: "Marginal risk", + short: "MRGL", + color: "#66ff66", + rank: 2 + }, + SLGT: { + label: "Slight risk", + short: "SLGT", + color: "#ffff00", + rank: 3 + }, + ENH: { + label: "Enhanced risk", + short: "ENH", + color: "#ffa500", + rank: 4 + }, + MDT: { + label: "Moderate risk", + short: "MDT", + color: "#ff0000", + rank: 5 + }, + HIGH: { + label: "High risk", + short: "HIGH", + color: "#ff00ff", + rank: 6 + } +}; +/** Normalize an SPC `LABEL` ("TSTM", "SLGT", "ENH"…) into a RISK_RANK key. */ +function riskKey(label) { + const k = String(label || "").trim().toUpperCase(); + return Object.prototype.hasOwnProperty.call(RISK_RANK, k) ? k : "NONE"; +} +/** +* Ray-casting point-in-ring test. Ring is [[lon, lat], …] and may be open or +* closed; both are accepted. Longitude/latitude (GeoJSON order) throughout. +* @param {number} lon +* @param {number} lat +* @param {number[][]} ring +* @returns {boolean} +*/ +function pointInRing(lon, lat, ring) { + if (!Array.isArray(ring) || ring.length < 3) return false; + let inside = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const xi = ring[i][0]; + const yi = ring[i][1]; + const xj = ring[j][0]; + const yj = ring[j][1]; + if (xi == null || yi == null || xj == null || yj == null) continue; + if (yi > lat !== yj > lat && lon < (xj - xi) * (lat - yi) / (yj - yi) + xi) inside = !inside; + } + return inside; +} +/** +* Point-in-polygon for a GeoJSON Polygon coordinate array: first ring is the +* outer boundary, any further rings are holes. +* @param {number} lon +* @param {number} lat +* @param {number[][][]} coords +* @returns {boolean} +*/ +function pointInPolygon(lon, lat, coords) { + if (!Array.isArray(coords) || !coords.length) return false; + if (!pointInRing(lon, lat, coords[0])) return false; + for (let i = 1; i < coords.length; i++) if (pointInRing(lon, lat, coords[i])) return false; + return true; +} +/** Point-in-geometry for Polygon / MultiPolygon (other types are false). */ +function pointInGeometry(lon, lat, geometry) { + if (!geometry) return false; + if (geometry.type === "Polygon") return pointInPolygon(lon, lat, geometry.coordinates); + if (geometry.type === "MultiPolygon") return (geometry.coordinates || []).some((poly) => pointInPolygon(lon, lat, poly)); + return false; +} +/** Great-circle distance in kilometres. */ +function haversineKm(lat1, lon1, lat2, lon2) { + const R = 6371; + const toRad = (d) => d * Math.PI / 180; + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.sqrt(a)); +} +/** +* URL for one SPC outlook product. `nolyr` = plain polygon overlay (no labels), +* the lightest machine-readable variant (~1–2 KB per day). +* @param {number} day 1..3 +* @param {'cat'|'torn'|'wind'|'hail'} kind +*/ +function spcOutlookUrl(day, kind = "cat") { + return `${SPC_BASE}/day${day}otlk_${kind}.nolyr.geojson`; +} +/** +* Highest SPC categorical risk category whose polygon contains the point. +* @param {number} lon +* @param {number} lat +* @param {object} geojson FeatureCollection from spc.noaa.gov +* @returns {{ key: string, label: string, short: string, color: string, props: object|null }} +*/ +function spcRiskAtPoint(lon, lat, geojson) { + let best = null; + for (const f of geojson?.features || []) { + if (!pointInGeometry(lon, lat, f.geometry)) continue; + const key = riskKey(f.properties?.LABEL); + if (!best || RISK_RANK[key] > RISK_RANK[best.key]) best = { + key, + props: f.properties || {} + }; + } + if (!best) return { + key: "NONE", + ...RISK_META.NONE, + props: null + }; + const meta = RISK_META[best.key]; + return { + key: best.key, + label: meta.label, + short: meta.short, + color: meta.color, + rank: meta.rank, + props: best.props + }; +} +/** +* Highest probability (%) whose polygon contains the point, for one SPC +* probabilistic product. The "Less Than 2% All Areas" background polygon is +* ignored so an empty region reads as 0 rather than "all areas". +* @param {number} lon +* @param {number} lat +* @param {object} geojson +* @returns {number} +*/ +function spcProbabilityAtPoint(lon, lat, geojson) { + let max = 0; + for (const f of geojson?.features || []) { + const label = String(f.properties?.LABEL || ""); + if (/less than/i.test(label)) continue; + const m = label.match(/\d+/); + if (!m) continue; + if (!pointInGeometry(lon, lat, f.geometry)) continue; + max = Math.max(max, Number(m[0])); + } + return max; +} +/** +* Collapse the six SPC responses into the shape the UI consumes. +* Any input may be null (a product that isn't issued yet). +* @param {{ cat1?: object, cat2?: object, cat3?: object, torn?: object, wind?: object, hail?: object }} src +* @param {number} lon +* @param {number} lat +*/ +function normalizeOutlook(src, lon, lat) { + const day = (cat) => cat ? spcRiskAtPoint(lon, lat, cat) : { + key: "NONE", + ...RISK_META.NONE, + props: null + }; + const issued = src.cat1?.features?.[0]?.properties?.ISSUE_ISO || null; + const valid = src.cat1?.features?.[0]?.properties?.VALID_ISO || null; + return { + day1: day(src.cat1), + day2: day(src.cat2), + day3: day(src.cat3), + probabilities: { + tornado: src.torn ? spcProbabilityAtPoint(lon, lat, src.torn) : 0, + wind: src.wind ? spcProbabilityAtPoint(lon, lat, src.wind) : 0, + hail: src.hail ? spcProbabilityAtPoint(lon, lat, src.hail) : 0 + }, + issued, + valid + }; +} +/** +* Rough US coverage check (CONUS + AK + HI) — SPC products are US-only. +* The boxes intentionally err wide (southern Canada / northern Mexico fall +* inside the CONUS band); actual outlook coverage comes from the SPC polygons, +* this only avoids pointless requests far outside the US. +*/ +function isUsPoint(lat, lon) { + if (lat == null || lon == null) return false; + return lat >= 24 && lat <= 50 && lon >= -125 && lon <= -66 || lat >= 50 && lat <= 72 && lon >= -170 && lon <= -129 || lat >= 18.5 && lat <= 22.5 && lon >= -161 && lon <= -154; +} +/** Emoji for an LSR `typetext`, e.g. "TSTM WND GST", "FLASH FLOOD", "HAIL". */ +function reportIcon(typetext) { + const t = String(typetext || "").toLowerCase(); + if (/tornado|funnel/.test(t)) return "🌪️"; + if (/hail/.test(t)) return "🧊"; + if (/wind|gust|wnd/.test(t)) return "💨"; + if (/flood|rain|precip/.test(t)) return "🌊"; + if (/snow|ice|freez|winter/.test(t)) return "❄️"; + if (/fog|visibility/.test(t)) return "🌫️"; + if (/heat|fire/.test(t)) return "🔥"; + if (/lightning|tstm/.test(t)) return "⛈️"; + return "📍"; +} +/** Human label for an LSR type code, keeping NWS shorthand readable. */ +function reportLabel(typetext) { + const t = String(typetext || "").trim(); + if (!t) return "Storm report"; + if (/^TORNADO$/i.test(t)) return "Tornado"; + if (/^FUNNEL CLOUD$/i.test(t)) return "Funnel cloud"; + if (/^HAIL$/i.test(t)) return "Hail"; + if (/^TSTM WND GST$/i.test(t)) return "Thunderstorm wind gust"; + if (/^TSTM WND DMG$/i.test(t)) return "Thunderstorm wind damage"; + if (/^NON-TSTM WND GST$/i.test(t)) return "Non-thunderstorm wind gust"; + if (/^NON-TSTM WND DMG$/i.test(t)) return "Non-thunderstorm wind damage"; + if (/FLASH FLOOD/i.test(t)) return "Flash flood"; + if (/^FLOOD$/i.test(t)) return "Flood"; + return t.charAt(0) + t.slice(1).toLowerCase(); +} +/** +* Normalize an IEM LSR GeoJSON response into entries sorted nearest-first, +* dropping anything outside `radiusKm`. +* @param {object} geojson +* @param {number} lat +* @param {number} lon +* @param {number} [radiusKm=160] +*/ +function normalizeStormReports(geojson, lat, lon, radiusKm = 160) { + const out = []; + for (const f of geojson?.features || []) { + const p = f.properties || {}; + const coords = f.geometry?.coordinates; + const rLat = p.lat ?? coords?.[1]; + const rLon = p.lon ?? coords?.[0]; + if (rLat == null || rLon == null) continue; + const distanceKm = haversineKm(lat, lon, rLat, rLon); + if (distanceKm > radiusKm) continue; + const magnitude = p.magnitude != null && p.magnitude !== "" ? p.magnitude : null; + out.push({ + id: p.product_id ? `${p.product_id}-${p.valid}-${p.city}` : `${rLat},${rLon}-${p.valid}-${p.typetext}`, + type: p.typetext || "", + label: reportLabel(p.typetext), + icon: reportIcon(p.typetext), + magnitude, + magnitudeUnit: p.unit || null, + city: p.city || "", + county: p.county || "", + state: p.state || p.st || "", + source: p.source || "", + remark: p.remark || "", + valid: p.valid || null, + lat: rLat, + lon: rLon, + distanceKm: Math.round(distanceKm * 10) / 10 + }); + } + return out.sort((a, b) => a.distanceKm - b.distanceKm); +} +var SEVERITY_RANK = { + Extreme: 3, + Severe: 2, + Moderate: 1, + Minor: 0, + Unknown: 0 +}; +/** Map an NWS alert event to the app's alert buckets (mirrors nws.js). */ +function alertType(event) { + const t = String(event || "").toLowerCase(); + if (/heat/.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 surge/.test(t)) return "storm"; + if (/flood/.test(t)) return "flood"; + return "advisory"; +} +/** Is this alert one that deserves a page-level banner? */ +function isUrgentWarning(event, severity) { + const t = String(event || "").toLowerCase(); + if (/tornado|severe thunderstorm|flash flood emergency|extreme wind/.test(t) && /warning|emergency/.test(t)) return true; + return String(severity || "") === "Extreme"; +} +/** +* Normalize an `/alerts/active` GeoJSON response into display entries, most +* severe first (then soonest ending). +* @param {object} geojson +*/ +function normalizeActiveAlerts(geojson) { + const out = []; + const seen = /* @__PURE__ */ new Set(); + for (const f of geojson?.features || []) { + const p = f.properties || {}; + if (p.status && p.status !== "Actual") continue; + if (p.messageType && p.messageType === "Cancel") continue; + const event = p.event || "Weather Alert"; + const id = p.id || `${event}-${p.onset || p.effective || ""}`; + if (seen.has(id)) continue; + seen.add(id); + out.push({ + id, + event, + type: alertType(event), + severity: p.severity || "Unknown", + severityRank: SEVERITY_RANK[p.severity] ?? 0, + urgency: p.urgency || "", + certainty: p.certainty || "", + headline: p.headline || "", + description: p.description || "", + instruction: p.instruction || "", + areaDesc: p.areaDesc || "", + senderName: p.senderName || "", + onset: p.onset || p.effective || null, + ends: p.ends || p.expires || null, + expires: p.expires || p.ends || null, + geometry: f.geometry || null + }); + } + return out.sort((a, b) => b.severityRank - a.severityRank || (Date.parse(a.ends || 0) || Infinity) - (Date.parse(b.ends || 0) || Infinity)); +} +var TTL = { + outlook: 1200 * 1e3, + reports: 300 * 1e3, + alerts: 120 * 1e3 +}; +var _cache = /* @__PURE__ */ new Map(); +var _inflight = /* @__PURE__ */ new Map(); +async function cachedJson(url, ttl, headers = {}) { + const hit = _cache.get(url); + if (hit && Date.now() - hit.at < ttl) return hit.data; + if (_inflight.has(url)) return _inflight.get(url); + const p = (async () => { + const res = await fetch(url, { headers: { + Accept: "application/geo+json, application/json", + ...headers + } }); + if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); + const data = await res.json(); + _cache.set(url, { + at: Date.now(), + data + }); + return data; + })(); + _inflight.set(url, p); + try { + return await p; + } finally { + _inflight.delete(url); + } +} +/** +* Fetch Day 1–3 categorical outlooks plus Day 1 probabilities and resolve them +* against a point. Missing products degrade to `null` instead of throwing. +* @param {number} lat +* @param {number} lon +*/ +async function fetchSpcOutlook(lat, lon) { + const urls = { + cat1: spcOutlookUrl(1, "cat"), + cat2: spcOutlookUrl(2, "cat"), + cat3: spcOutlookUrl(3, "cat"), + torn: spcOutlookUrl(1, "torn"), + wind: spcOutlookUrl(1, "wind"), + hail: spcOutlookUrl(1, "hail") + }; + const keys = Object.keys(urls); + const settled = await Promise.allSettled(keys.map((k) => cachedJson(urls[k], TTL.outlook))); + const src = {}; + settled.forEach((r, i) => { + src[keys[i]] = r.status === "fulfilled" ? r.value : null; + }); + if (keys.every((k) => src[k] == null)) throw new Error("SPC outlook unavailable"); + return normalizeOutlook(src, lon, lat); +} +/** +* Fetch NWS Local Storm Reports inside a bounding box around the point, then +* filter to a radius. +* @param {number} lat +* @param {number} lon +* @param {{ hours?: number, radiusKm?: number }} [opts] +*/ +async function fetchStormReports(lat, lon, { hours = 24, radiusKm = 160 } = {}) { + const pad = Math.max(radiusKm / 111 * 1.4, 1.5); + const lonPad = pad / Math.max(Math.cos(lat * Math.PI / 180), .2); + const ets = /* @__PURE__ */ new Date(); + const sts = /* @__PURE__ */ new Date(ets.getTime() - hours * 3600 * 1e3); + const iso = (d) => d.toISOString().replace(/\.\d{3}Z$/, "Z"); + return normalizeStormReports(await cachedJson(`${IEM_LSR}?${new URLSearchParams({ + west: String(lon - lonPad), + east: String(lon + lonPad), + north: String(lat + pad), + south: String(lat - pad), + sts: iso(sts), + ets: iso(ets) + })}`, TTL.reports), lat, lon, radiusKm); +} +/** +* Fetch active NWS alerts for a point. Throws outside the NWS domain. +* @param {number} lat +* @param {number} lon +*/ +async function fetchWeatherAlerts(lat, lon) { + return normalizeActiveAlerts(await cachedJson(`${NWS_API}/alerts/active?point=${lat},${lon}`, TTL.alerts, { "User-Agent": NWS_USER_AGENT })); +} +//#endregion +//#region src/lib/stores/storm.svelte.js +var STORM_REFRESH_MS = 300 * 1e3; +var StormStore = class { + #alerts = /* @__PURE__ */ state(proxy([])); + get alerts() { + return get(this.#alerts); + } + set alerts(value) { + set(this.#alerts, value, true); + } + #outlook = /* @__PURE__ */ state(null); + get outlook() { + return get(this.#outlook); + } + set outlook(value) { + set(this.#outlook, value, true); + } + #reports = /* @__PURE__ */ state(proxy([])); + get reports() { + return get(this.#reports); + } + set reports(value) { + set(this.#reports, value, true); + } + #loading = /* @__PURE__ */ state(false); + get loading() { + return get(this.#loading); + } + set loading(value) { + set(this.#loading, value, true); + } + #error = /* @__PURE__ */ state(""); + get error() { + return get(this.#error); + } + set error(value) { + set(this.#error, value, true); + } + #updatedAt = /* @__PURE__ */ state(0); + get updatedAt() { + return get(this.#updatedAt); + } + set updatedAt(value) { + set(this.#updatedAt, value, true); + } + _key = null; + _timer = null; + _promise = null; + get hasUsCoverage() { + const loc = app$1.selectedLocation; + return !!loc && isUsPoint(loc.lat, loc.lon); + } + /** The one alert that deserves a banner above the content (if any). */ + get banner() { + for (const a of this.alerts) if (isUrgentWarning(a.event, a.severity)) return a; + return null; + } + /** Number of active alerts, for nav badges. */ + get alertCount() { + return this.alerts.length; + } + /** + * Load the storm feeds for the selected location, unless the data for this + * location is still fresh (pass `force` to bypass). + * @param {{ force?: boolean }} [opts] + */ + async watch({ force = false } = {}) { + const loc = app$1.selectedLocation; + if (!loc) { + this.reset(); + return; + } + const key = `${loc.lat},${loc.lon}`; + if (!force && key === this._key && this.updatedAt && Date.now() - this.updatedAt < 3e5) return; + if (this._promise) return this._promise; + this._key = key; + this.loading = true; + this._promise = this._load(loc, key).finally(() => { + this.loading = false; + this._promise = null; + }); + return this._promise; + } + /** + * @param {{ lat: number, lon: number }} loc + * @param {string} key + */ + async _load(loc, key) { + const us = isUsPoint(loc.lat, loc.lon); + const [alertsRes, outlookRes, reportsRes] = await Promise.allSettled([ + fetchWeatherAlerts(loc.lat, loc.lon), + us ? fetchSpcOutlook(loc.lat, loc.lon) : Promise.resolve(null), + us ? fetchStormReports(loc.lat, loc.lon) : Promise.resolve([]) + ]); + if (key !== this._key) return; + const failed = []; + if (alertsRes.status === "fulfilled") this.alerts = alertsRes.value; + else { + this.alerts = []; + failed.push("alerts"); + } + if (outlookRes.status === "fulfilled") this.outlook = outlookRes.value; + else { + this.outlook = null; + failed.push("outlook"); + } + if (reportsRes.status === "fulfilled") this.reports = reportsRes.value; + else { + this.reports = []; + failed.push("reports"); + } + this.error = failed.length && failed.length < 3 ? `Some storm data unavailable (${failed.join(", ")})` : ""; + this.updatedAt = Date.now(); + } + /** Start the refresh timer (idempotent). */ + start() { + if (this._timer) return; + this._timer = setInterval(() => { + if (typeof document !== "undefined" && document.hidden) return; + this.watch({ force: true }); + }, STORM_REFRESH_MS); + } + /** Stop the refresh timer. */ + stop() { + if (this._timer) { + clearInterval(this._timer); + this._timer = null; + } + } + /** Clear all data (no location selected). */ + reset() { + this._key = null; + this.alerts = []; + this.outlook = null; + this.reports = []; + this.error = ""; + this.updatedAt = 0; + } +}; +var storm = new StormStore(); +//#endregion //#region src/lib/autofocus.js /** * Svelte action: autofocus an element on mount. @@ -6527,13 +7160,13 @@ function autofocus(node) { //#endregion //#region src/components/LocationSidebar.svelte init_db(); -var root$11 = /* @__PURE__ */ from_html(`⏳`); -var root_1$10 = /* @__PURE__ */ from_html(``); -var root_2$10 = /* @__PURE__ */ from_html(`
`); -var root_3$8 = /* @__PURE__ */ from_html(`
`); -var root_4$5 = /* @__PURE__ */ from_html(`
`); -var root_5$5 = /* @__PURE__ */ from_html(`

No saved locations.

Enable location access or add one below.

`); -var root_6$3 = /* @__PURE__ */ from_html(``); +var root$12 = /* @__PURE__ */ from_html(`⏳`); +var root_1$11 = /* @__PURE__ */ from_html(``); +var root_2$11 = /* @__PURE__ */ from_html(`
`); +var root_3$9 = /* @__PURE__ */ from_html(`
`); +var root_4$6 = /* @__PURE__ */ from_html(`
`); +var root_5$6 = /* @__PURE__ */ from_html(`

No saved locations.

Enable location access or add one below.

`); +var root_6$4 = /* @__PURE__ */ from_html(``); function LocationSidebar($$anchor, $$props) { push($$props, true); let searchQuery = /* @__PURE__ */ state(""); @@ -6586,22 +7219,22 @@ function LocationSidebar($$anchor, $$props) { const newest = locations[locations.length - 1]; if (newest) app$1.selectLocation(newest.id); } - var div = root_6$3(); + var div = root_6$4(); var div_1 = sibling(child(div), 2); var input = child(div_1); remove_input_defaults(input); var node = sibling(input, 2); var consequent = ($$anchor) => { - append($$anchor, root$11()); + append($$anchor, root$12()); }; if_block(node, ($$render) => { if (app$1.geocodingLoading) $$render(consequent); }); var node_1 = sibling(node, 2); var consequent_1 = ($$anchor) => { - var div_2 = root_2$10(); + var div_2 = root_2$11(); each(div_2, 21, () => app$1.geocodingResults, index, ($$anchor, result) => { - var button = root_1$10(); + var button = root_1$11(); var span_1 = child(button); var text = child(span_1, true); reset(span_1); @@ -6629,7 +7262,7 @@ function LocationSidebar($$anchor, $$props) { var fragment = comment(); var node_3 = first_child(fragment); var consequent_2 = ($$anchor) => { - var div_3 = root_3$8(); + var div_3 = root_3$9(); var button_1 = child(div_3); let classes; var span_3 = sibling(child(button_1), 2); @@ -6654,7 +7287,7 @@ function LocationSidebar($$anchor, $$props) { var fragment_1 = comment(); var node_5 = first_child(fragment_1); var consequent_3 = ($$anchor) => { - var div_4 = root_4$5(); + var div_4 = root_4$6(); var button_2 = child(div_4); let classes_1; var span_4 = sibling(child(button_2), 2); @@ -6682,7 +7315,7 @@ function LocationSidebar($$anchor, $$props) { }); var node_6 = sibling(node_4, 2); var consequent_4 = ($$anchor) => { - append($$anchor, root_5$5()); + append($$anchor, root_5$6()); }; if_block(node_6, ($$render) => { if (app$1.locations.length === 0) $$render(consequent_4); @@ -6703,12 +7336,12 @@ function LocationSidebar($$anchor, $$props) { delegate(["input", "click"]); //#endregion //#region src/components/SourceTooltip.svelte -var root$10 = /* @__PURE__ */ from_html(` `); +var root$11 = /* @__PURE__ */ from_html(` `); function SourceTooltip($$anchor, $$props) { /** Small info badge whose tooltip explains the value came from the * non-selected source (e.g. Open-Meteo while NWS is selected). */ let label = prop($$props, "label", 3, ""); - var span = root$10(); + var span = root$11(); var span_1 = sibling(child(span), 2); var text = child(span_1, true); reset(span_1); @@ -6721,12 +7354,12 @@ function SourceTooltip($$anchor, $$props) { } //#endregion //#region src/components/CurrentWeather.svelte -var root$9 = /* @__PURE__ */ from_html(` `); -var root_1$9 = /* @__PURE__ */ from_html(`
`); -var root_2$9 = /* @__PURE__ */ from_html(` `); -var root_3$7 = /* @__PURE__ */ from_html(`
🌪️ Gusts
`); -var root_4$4 = /* @__PURE__ */ from_html(`
🌅
🌇
`); -var root_5$4 = /* @__PURE__ */ from_html(`
💧 Humidity
💨 Wind
🌡️ Pressure
☀️ UV Index
`); +var root$10 = /* @__PURE__ */ from_html(` `); +var root_1$10 = /* @__PURE__ */ from_html(`
`); +var root_2$10 = /* @__PURE__ */ from_html(` `); +var root_3$8 = /* @__PURE__ */ from_html(`
🌪️ Gusts
`); +var root_4$5 = /* @__PURE__ */ from_html(`
🌅
🌇
`); +var root_5$5 = /* @__PURE__ */ from_html(`
💧 Humidity
💨 Wind
🌡️ Pressure
☀️ UV Index
`); function CurrentWeather($$anchor, $$props) { push($$props, true); const data = /* @__PURE__ */ user_derived(() => app$1.forecastData); @@ -6772,7 +7405,7 @@ function CurrentWeather($$anchor, $$props) { var fragment = comment(); var node = first_child(fragment); var consequent_7 = ($$anchor) => { - var div = root_5$4(); + var div = root_5$5(); var div_1 = child(div); var div_2 = child(div_1); var span = child(div_2); @@ -6790,7 +7423,7 @@ function CurrentWeather($$anchor, $$props) { var text_3 = child(div_4); var node_1 = sibling(text_3); var consequent = ($$anchor) => { - var span_2 = root$9(); + var span_2 = root$10(); var text_4 = child(span_2); reset(span_2); template_effect(($0) => set_text(text_4, `· Feels like ${$0 ?? ""}${get(unit) ?? ""}`), [() => Math.round(get(current).apparent_temperature)]); @@ -6803,7 +7436,7 @@ function CurrentWeather($$anchor, $$props) { reset(div_4); var node_2 = sibling(div_4, 2); var consequent_1 = ($$anchor) => { - var div_5 = root_1$9(); + var div_5 = root_1$10(); var span_3 = child(div_5); var text_5 = child(span_3); reset(span_3); @@ -6859,7 +7492,7 @@ function CurrentWeather($$anchor, $$props) { reset(span_9); var node_5 = sibling(span_9, 2); var consequent_4 = ($$anchor) => { - var span_10 = root_2$9(); + var span_10 = root_2$10(); var text_12 = child(span_10, true); reset(span_10); template_effect(() => set_text(text_12, get(current).uv_index <= 2 ? "Low" : get(current).uv_index <= 5 ? "Moderate" : get(current).uv_index <= 7 ? "High" : "Very High")); @@ -6871,7 +7504,7 @@ function CurrentWeather($$anchor, $$props) { reset(div_10); var node_6 = sibling(div_10, 2); var consequent_5 = ($$anchor) => { - var div_11 = root_3$7(); + var div_11 = root_3$8(); var span_11 = sibling(child(div_11), 4); var text_13 = child(span_11); reset(span_11); @@ -6885,7 +7518,7 @@ function CurrentWeather($$anchor, $$props) { reset(div_6); var node_7 = sibling(div_6, 2); var consequent_6 = ($$anchor) => { - var div_12 = root_4$4(); + var div_12 = root_4$5(); var div_13 = child(div_12); var span_12 = sibling(child(div_13), 2); var text_14 = child(span_12); @@ -6935,10 +7568,10 @@ function CurrentWeather($$anchor, $$props) { } //#endregion //#region src/components/HourlyForecast.svelte -var root$8 = /* @__PURE__ */ from_html(`
`, 1); -var root_1$8 = /* @__PURE__ */ from_html(`
--`, 1); -var root_2$8 = /* @__PURE__ */ from_html(`
`); -var root_3$6 = /* @__PURE__ */ from_html(`

Hourly Forecast

`); +var root$9 = /* @__PURE__ */ from_html(`
`, 1); +var root_1$9 = /* @__PURE__ */ from_html(`
--`, 1); +var root_2$9 = /* @__PURE__ */ from_html(`
`); +var root_3$7 = /* @__PURE__ */ from_html(`

Hourly Forecast

`); function HourlyForecast($$anchor, $$props) { push($$props, true); const data = /* @__PURE__ */ user_derived(() => app$1.forecastData); @@ -6973,11 +7606,11 @@ function HourlyForecast($$anchor, $$props) { var fragment = comment(); var node = first_child(fragment); var consequent_1 = ($$anchor) => { - var div = root_3$6(); + var div = root_3$7(); var div_1 = sibling(child(div), 2); each(div_1, 21, () => get(next24Hours), index, ($$anchor, hour, i) => { const info = /* @__PURE__ */ user_derived(() => getWeatherInfo(get(hour).code)); - var div_2 = root_2$8(); + var div_2 = root_2$9(); let classes; var span = child(div_2); var text = child(span, true); @@ -6990,7 +7623,7 @@ function HourlyForecast($$anchor, $$props) { reset(span_2); var node_1 = sibling(span_2, 2); var consequent = ($$anchor) => { - var fragment_1 = root$8(); + var fragment_1 = root$9(); var div_3 = first_child(fragment_1); var div_4 = child(div_3); reset(div_3); @@ -7004,7 +7637,7 @@ function HourlyForecast($$anchor, $$props) { append($$anchor, fragment_1); }; var alternate = ($$anchor) => { - var fragment_2 = root_1$8(); + var fragment_2 = root_1$9(); next(2); append($$anchor, fragment_2); }; @@ -7042,10 +7675,10 @@ function HourlyForecast($$anchor, $$props) { } //#endregion //#region src/components/DailyForecast.svelte -var root$7 = /* @__PURE__ */ from_html(`
💧
`); -var root_1$7 = /* @__PURE__ */ from_html(` `); -var root_2$7 = /* @__PURE__ */ from_html(`
`); -var root_3$5 = /* @__PURE__ */ from_html(`

7-Day Forecast

`); +var root$8 = /* @__PURE__ */ from_html(`
💧
`); +var root_1$8 = /* @__PURE__ */ from_html(` `); +var root_2$8 = /* @__PURE__ */ from_html(`
`); +var root_3$6 = /* @__PURE__ */ from_html(`

7-Day Forecast

`); function DailyForecast($$anchor, $$props) { push($$props, true); const data = /* @__PURE__ */ user_derived(() => app$1.forecastData); @@ -7069,7 +7702,7 @@ function DailyForecast($$anchor, $$props) { var fragment = comment(); var node = first_child(fragment); var consequent_3 = ($$anchor) => { - var div = root_3$5(); + var div = root_3$6(); var div_1 = sibling(child(div), 2); each(div_1, 21, () => get(daily).time, index, ($$anchor, day, i) => { var fragment_1 = comment(); @@ -7080,7 +7713,7 @@ function DailyForecast($$anchor, $$props) { const tempMax = /* @__PURE__ */ user_derived(() => Math.round(get(daily).temperature_2m_max[i])); const tempMin = /* @__PURE__ */ user_derived(() => Math.round(get(daily).temperature_2m_min[i])); const wind = /* @__PURE__ */ user_derived(() => get(daily).wind_speed_10m_max?.[i]); - var div_2 = root_2$7(); + var div_2 = root_2$8(); set_class(div_2, 1, "day-card card-glass svelte-ss3yj8", null, {}, { today: i === 0 }); var div_3 = child(div_2); var span = child(div_3); @@ -7109,7 +7742,7 @@ function DailyForecast($$anchor, $$props) { reset(div_6); var node_2 = sibling(div_6, 2); var consequent = ($$anchor) => { - var div_7 = root$7(); + var div_7 = root$8(); var div_8 = sibling(child(div_7), 2); var div_9 = child(div_8); reset(div_8); @@ -7128,7 +7761,7 @@ function DailyForecast($$anchor, $$props) { }); var node_3 = sibling(node_2, 2); var consequent_1 = ($$anchor) => { - var span_7 = root_1$7(); + var span_7 = root_1$8(); var text_7 = child(span_7); reset(span_7); template_effect(($0) => set_text(text_7, `💨 ${$0 ?? ""} ${get(windUnit) ?? ""}`), [() => Math.round(get(wind))]); @@ -7289,10 +7922,10 @@ function diffDays(a, b) { } //#endregion //#region src/components/MoonPhaseCard.svelte -var root$6 = /* @__PURE__ */ from_html(`
🌙Moon
Loading moon phase…
`); -var root_1$6 = /* @__PURE__ */ from_html(`
🕓
`); -var root_2$6 = /* @__PURE__ */ from_html(`
🌙Moon
`); -var root_3$4 = /* @__PURE__ */ from_html(`
🌙Moon
Moon data unavailable
`); +var root$7 = /* @__PURE__ */ from_html(`
🌙Moon
Loading moon phase…
`); +var root_1$7 = /* @__PURE__ */ from_html(`
🕓
`); +var root_2$7 = /* @__PURE__ */ from_html(`
🌙Moon
`); +var root_3$5 = /* @__PURE__ */ from_html(`
🌙Moon
Moon data unavailable
`); function MoonPhaseCard($$anchor, $$props) { push($$props, true); let _clipId = `moonclip-${Math.random().toString(36).slice(2, 7)}`; @@ -7354,17 +7987,17 @@ function MoonPhaseCard($$anchor, $$props) { var fragment = comment(); var node = first_child(fragment); var consequent = ($$anchor) => { - append($$anchor, root$6()); + append($$anchor, root$7()); }; var consequent_4 = ($$anchor) => { - var div_1 = root_2$6(); + var div_1 = root_2$7(); var div_2 = sibling(child(div_1), 2); var div_3 = child(div_2); html(div_3, () => moonSVG(get(today).phase), true); reset(div_3); var div_4 = sibling(div_3, 2); var div_5 = child(div_4); - var text$1 = child(div_5, true); + var text$2 = child(div_5, true); reset(div_5); var div_6 = sibling(div_5, 2); var text_1 = child(div_6); @@ -7373,7 +8006,7 @@ function MoonPhaseCard($$anchor, $$props) { reset(div_2); var node_1 = sibling(div_2, 2); var consequent_3 = ($$anchor) => { - var div_7 = root_1$6(); + var div_7 = root_1$7(); var div_8 = sibling(child(div_7), 2); var span = child(div_8); var strong = child(span); @@ -7413,13 +8046,13 @@ function MoonPhaseCard($$anchor, $$props) { }); reset(div_1); template_effect(($0) => { - set_text(text$1, get(phaseName)); + set_text(text$2, get(phaseName)); set_text(text_1, `${$0 ?? ""}% illuminated`); }, [() => Math.round(get(illum) * 100)]); append($$anchor, div_1); }; var alternate_1 = ($$anchor) => { - append($$anchor, root_3$4()); + append($$anchor, root_3$5()); }; if_block(node, ($$render) => { if (get(status) === "loading") $$render(consequent); @@ -7431,9 +8064,9 @@ function MoonPhaseCard($$anchor, $$props) { } //#endregion //#region src/components/WeatherDetail.svelte -var root$5 = /* @__PURE__ */ from_html(` `); -var root_1$5 = /* @__PURE__ */ from_html(`
24h Average
`); -var root_2$5 = /* @__PURE__ */ from_html(`

Weather Details

💨 Wind
Speed
Direction
Gusts
Condition
🌡️ Atmosphere
Humidity
Pressure
Feels Like
Is Day
☀️ Sun & UV
UV Index
Sunrise
Sunset
Max UV Today
📊 Temperature Range
Today's High
Today's Low
Precip Chance
Data source
Local time
Data updated
Elevation
`); +var root$6 = /* @__PURE__ */ from_html(` `); +var root_1$6 = /* @__PURE__ */ from_html(`
24h Average
`); +var root_2$6 = /* @__PURE__ */ from_html(`

Weather Details

💨 Wind
Speed
Direction
Gusts
Condition
🌡️ Atmosphere
Humidity
Pressure
Feels Like
Is Day
☀️ Sun & UV
UV Index
Sunrise
Sunset
Max UV Today
📊 Temperature Range
Today's High
Today's Low
Precip Chance
Data source
Local time
Data updated
Elevation
`); function WeatherDetail($$anchor, $$props) { push($$props, true); const data = /* @__PURE__ */ user_derived(() => app$1.forecastData); @@ -7496,7 +8129,7 @@ function WeatherDetail($$anchor, $$props) { var fragment = comment(); var node = first_child(fragment); var consequent_6 = ($$anchor) => { - var div = root_2$5(); + var div = root_2$6(); var div_1 = sibling(child(div), 2); var div_2 = child(div_1); var div_3 = sibling(child(div_2), 2); @@ -7560,7 +8193,7 @@ function WeatherDetail($$anchor, $$props) { var text_8 = child(span_8); var node_2 = sibling(text_8); var consequent_1 = ($$anchor) => { - var span_9 = root$5(); + var span_9 = root$6(); var text_9 = child(span_9, true); reset(span_9); template_effect(($0, $1) => { @@ -7627,7 +8260,7 @@ function WeatherDetail($$anchor, $$props) { var fragment_4 = comment(); var node_7 = first_child(fragment_4); var consequent_4 = ($$anchor) => { - var div_25 = root_1$5(); + var div_25 = root_1$6(); var span_15 = sibling(child(div_25), 2); var text_15 = child(span_15); reset(span_15); @@ -7720,16 +8353,16 @@ function WeatherDetail($$anchor, $$props) { } //#endregion //#region src/components/PrecipitationRadar.svelte -var root$4 = /* @__PURE__ */ from_html(`
`); -var root_1$4 = /* @__PURE__ */ from_html(``); -var root_2$4 = /* @__PURE__ */ from_html(` `, 1); -var root_3$3 = /* @__PURE__ */ from_html(`
`, 1); -var root_4$3 = /* @__PURE__ */ from_html(`
⏳ Loading radar...
`); -var root_5$3 = /* @__PURE__ */ from_html(`
`); -var root_6$2 = /* @__PURE__ */ from_html(`
`); -var root_7$1 = /* @__PURE__ */ from_html(`

No radar data available.

`); -var root_8 = /* @__PURE__ */ from_html(`
`, 1); -var root_9 = /* @__PURE__ */ from_html(`

Precipitation Radar

`); +var root$5 = /* @__PURE__ */ from_html(`
`); +var root_1$5 = /* @__PURE__ */ from_html(``); +var root_2$5 = /* @__PURE__ */ from_html(` `, 1); +var root_3$4 = /* @__PURE__ */ from_html(`
`, 1); +var root_4$4 = /* @__PURE__ */ from_html(`
⏳ Loading radar...
`); +var root_5$4 = /* @__PURE__ */ from_html(`
`); +var root_6$3 = /* @__PURE__ */ from_html(`
`); +var root_7$2 = /* @__PURE__ */ from_html(`

No radar data available.

`); +var root_8$2 = /* @__PURE__ */ from_html(`
`, 1); +var root_9$2 = /* @__PURE__ */ from_html(`

Precipitation Radar

`); function PrecipitationRadar($$anchor, $$props) { push($$props, true); let radarData = /* @__PURE__ */ state(null); @@ -7904,10 +8537,10 @@ function PrecipitationRadar($$anchor, $$props) { } const isPast = /* @__PURE__ */ user_derived(() => get(currentIdx) < get(pastFrames).length); const frameLabel = /* @__PURE__ */ user_derived(() => get(isPast) ? "Past" : "Forecast"); - var div = root_9(); + var div = root_9$2(); var node = sibling(child(div), 2); var consequent = ($$anchor) => { - var div_1 = root$4(); + var div_1 = root$5(); var span = child(div_1); var text = child(span); reset(span); @@ -7918,19 +8551,19 @@ function PrecipitationRadar($$anchor, $$props) { append($$anchor, div_1); }; var alternate = ($$anchor) => { - var fragment = root_8(); + var fragment = root_8$2(); var div_2 = first_child(fragment); let classes; var node_1 = child(div_2); var consequent_2 = ($$anchor) => { - var fragment_1 = root_3$3(); + var fragment_1 = root_3$4(); var div_3 = first_child(fragment_1); each(div_3, 21, () => get(tiles), (t) => t.col + "_" + t.row, ($$anchor, t) => { - var fragment_2 = root_2$4(); + var fragment_2 = root_2$5(); var img = first_child(fragment_2); var node_2 = sibling(img, 2); var consequent_1 = ($$anchor) => { - var img_1 = root_1$4(); + var img_1 = root_1$5(); template_effect(($0) => { set_attribute(img_1, "src", $0); set_style(img_1, `grid-column: ${get(t).col + 3}; grid-row: ${get(t).row + 3}`); @@ -7960,14 +8593,14 @@ function PrecipitationRadar($$anchor, $$props) { }); var node_3 = sibling(node_1, 2); var consequent_3 = ($$anchor) => { - append($$anchor, root_4$3()); + append($$anchor, root_4$4()); }; if_block(node_3, ($$render) => { if (get(loading)) $$render(consequent_3); }); var node_4 = sibling(node_3, 2); var consequent_4 = ($$anchor) => { - var div_6 = root_5$3(); + var div_6 = root_5$4(); var span_1 = child(div_6); var text_1 = child(span_1); reset(span_1); @@ -7990,7 +8623,7 @@ function PrecipitationRadar($$anchor, $$props) { reset(div_2); var node_5 = sibling(div_2, 2); var consequent_5 = ($$anchor) => { - var div_8 = root_6$2(); + var div_8 = root_6$3(); var div_9 = child(div_8); var button_4 = child(div_9); var button_5 = sibling(button_4, 2); @@ -8046,7 +8679,7 @@ function PrecipitationRadar($$anchor, $$props) { append($$anchor, div_8); }; var consequent_6 = ($$anchor) => { - var div_12 = root_7$1(); + var div_12 = root_7$2(); var button_7 = sibling(child(div_12), 2); reset(div_12); delegated("click", button_7, loadRadar); @@ -8086,6 +8719,513 @@ delegate([ "input" ]); //#endregion +//#region src/components/StormWatch.svelte +var root$4 = /* @__PURE__ */ from_html(`
`); +var root_1$4 = /* @__PURE__ */ from_html(` `); +var root_2$4 = /* @__PURE__ */ from_html(`
`); +var root_3$3 = /* @__PURE__ */ from_html(`
`); +var root_4$3 = /* @__PURE__ */ from_html(`

`); +var root_5$3 = /* @__PURE__ */ from_html(`

`); +var root_6$2 = /* @__PURE__ */ from_html(`
  • Details
  • `); +var root_7$1 = /* @__PURE__ */ from_html(`
      `); +var root_8$1 = /* @__PURE__ */ from_html(`
      Checking for active alerts…
      `); +var root_9$1 = /* @__PURE__ */ from_html(`
      ✅ No active watches, warnings or advisories
      `); +var root_10 = /* @__PURE__ */ from_html(`
      `); +var root_11 = /* @__PURE__ */ from_html(`
      🌪️ tornado
      💨 wind
      🧊 hail
      Day 1 probability of severe weather within 25 miles of this point.
      `, 1); +var root_12 = /* @__PURE__ */ from_html(`
      No severe-weather area covers this location right now.
      `); +var root_13 = /* @__PURE__ */ from_html(`
      `); +var root_14 = /* @__PURE__ */ from_html(`
      `, 1); +var root_15 = /* @__PURE__ */ from_html(`
      SPC outlooks cover the United States only.
      `); +var root_16 = /* @__PURE__ */ from_html(`
      Loading outlook…
      `); +var root_17 = /* @__PURE__ */ from_html(`
      Outlook unavailable right now.
      `); +var root_18 = /* @__PURE__ */ from_html(` `); +var root_19 = /* @__PURE__ */ from_html(`
      `); +var root_20 = /* @__PURE__ */ from_html(`
    • `); +var root_21 = /* @__PURE__ */ from_html(`
        `, 1); +var root_22 = /* @__PURE__ */ from_html(`
        Local Storm Reports cover the United States only.
        `); +var root_23 = /* @__PURE__ */ from_html(`
        Loading recent reports…
        `); +var root_24 = /* @__PURE__ */ from_html(`
        `); +var root_25 = /* @__PURE__ */ from_html(`

        ⛈️ Storm Watch

        ⚠️ Active alerts
        🌪️ Severe-weather outlook
        📍 Recent reports
        Reports: NWS Local Storm Reports via Iowa Environmental Mesonet · Outlook: NOAA Storm Prediction Center
        `); +function StormWatch($$anchor, $$props) { + push($$props, true); + const tz = /* @__PURE__ */ user_derived(() => app$1.forecastData?.timezone || void 0); + const alerts = /* @__PURE__ */ user_derived(() => storm.alerts); + const outlook = /* @__PURE__ */ user_derived(() => storm.outlook); + const reports = /* @__PURE__ */ user_derived(() => storm.reports); + const maxReports = 8; + const SEVERITY_CLASS = { + Extreme: "sev-extreme", + Severe: "sev-severe", + Moderate: "sev-moderate", + Minor: "sev-minor", + Unknown: "sev-minor" + }; + /** Local calendar day key (YYYY-MM-DD) for an ISO timestamp. */ + function localDay(iso) { + if (!iso) return null; + try { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: get(tz), + year: "numeric", + month: "2-digit", + day: "2-digit" + }).formatToParts(new Date(iso)); + const P = Object.fromEntries(parts.map((p) => [p.type, p.value])); + return `${P.year}-${P.month}-${P.day}`; + } catch { + return null; + } + } + /** "5:30 PM" in the location's timezone. */ + function clock(iso) { + if (!iso) return ""; + try { + return new Intl.DateTimeFormat("en-US", { + timeZone: get(tz), + hour: "numeric", + minute: "2-digit" + }).format(new Date(iso)); + } catch { + return ""; + } + } + /** "Today 5:30 PM" / "Sat 2:00 AM" for an alert's end time. */ + function untilLabel(iso) { + if (!iso) return ""; + const day = localDay(iso); + const today = localDay((/* @__PURE__ */ new Date()).toISOString()); + const d = new Date(iso); + const tomorrow = localDay(new Date(Date.now() + 864e5).toISOString()); + const time = clock(iso); + if (day === today) return `until ${time}`; + if (day === tomorrow) return `until tomorrow ${time}`; + return `until ${new Intl.DateTimeFormat("en-US", { + timeZone: get(tz), + weekday: "short" + }).format(d)} ${time}`; + } + /** Coarse "2h ago" label for storm reports. */ + function ago(iso) { + if (!iso) return ""; + const ms = Date.now() - Date.parse(iso); + if (!Number.isFinite(ms) || ms < 0) return ""; + const mins = Math.round(ms / 6e4); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.round(mins / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.round(hours / 24)}d ago`; + } + function distance(km) { + if (km == null) return ""; + if (app$1.settings.units === "imperial") { + const mi = km * .621371; + return mi < 10 ? `${mi.toFixed(1)} mi` : `${Math.round(mi)} mi`; + } + return km < 10 ? `${km.toFixed(1)} km` : `${Math.round(km)} km`; + } + function magnitudeLabel(r) { + if (r.magnitude == null) return ""; + const type = String(r.type || "").toUpperCase(); + if (/HAIL/.test(type)) { + const v = Number(r.magnitude); + return Number.isFinite(v) ? `${v}" hail` : ""; + } + if (/WND|WIND/.test(type)) { + const v = Number(r.magnitude); + const mph = v; + return Number.isFinite(v) ? app$1.settings.units === "imperial" ? `${mph} mph` : `${Math.round(mph * 1.609)} km/h` : ""; + } + return String(r.magnitude); + } + function outlookDay(dayKey) { + return get(outlook)?.[dayKey] || null; + } + function refresh() { + storm.watch({ force: true }); + } + const issuedLabel = /* @__PURE__ */ user_derived(() => get(outlook)?.issued ? clock(get(outlook).issued) : ""); + var div = root_25(); + var div_1 = child(div); + var div_2 = child(div_1); + var p_1 = sibling(child(div_2), 2); + var node = child(p_1); + var consequent = ($$anchor) => { + var text$1 = text(); + template_effect(($0) => set_text(text$1, `Updated ${$0 ?? ""}`), [() => clock(new Date(storm.updatedAt).toISOString())]); + append($$anchor, text$1); + }; + var consequent_1 = ($$anchor) => { + append($$anchor, text("Checking watches, outlook and reports…")); + }; + var alternate = ($$anchor) => { + append($$anchor, text("Watches, severe-weather outlook and recent reports")); + }; + if_block(node, ($$render) => { + if (storm.updatedAt) $$render(consequent); + else if (storm.loading) $$render(consequent_1, 1); + else $$render(alternate, -1); + }); + reset(p_1); + reset(div_2); + var button = sibling(div_2, 2); + var text_3 = child(button, true); + reset(button); + reset(div_1); + var node_1 = sibling(div_1, 2); + var consequent_2 = ($$anchor) => { + var div_3 = root$4(); + var text_4 = child(div_3, true); + reset(div_3); + template_effect(() => set_text(text_4, storm.error)); + append($$anchor, div_3); + }; + if_block(node_1, ($$render) => { + if (storm.error) $$render(consequent_2); + }); + var section = sibling(node_1, 2); + var div_4 = child(section); + var node_2 = sibling(child(div_4), 4); + var consequent_3 = ($$anchor) => { + var span = root_1$4(); + var text_5 = child(span, true); + reset(span); + template_effect(() => set_text(text_5, get(alerts).length)); + append($$anchor, span); + }; + if_block(node_2, ($$render) => { + if (get(alerts).length) $$render(consequent_3); + }); + reset(div_4); + var node_3 = sibling(div_4, 2); + var consequent_8 = ($$anchor) => { + var ul = root_7$1(); + each(ul, 21, () => get(alerts), (a) => a.id, ($$anchor, a) => { + var li = root_6$2(); + var div_5 = child(li); + var span_1 = child(div_5); + var text_6 = child(span_1, true); + reset(span_1); + var span_2 = sibling(span_1, 2); + var text_7 = child(span_2, true); + reset(span_2); + reset(div_5); + var node_4 = sibling(div_5, 2); + var consequent_4 = ($$anchor) => { + var div_6 = root_2$4(); + var text_8 = child(div_6, true); + reset(div_6); + template_effect(() => set_text(text_8, get(a).headline)); + append($$anchor, div_6); + }; + if_block(node_4, ($$render) => { + if (get(a).headline) $$render(consequent_4); + }); + var details = sibling(node_4, 2); + var node_5 = sibling(child(details), 2); + var consequent_5 = ($$anchor) => { + var div_7 = root_3$3(); + var text_9 = child(div_7, true); + reset(div_7); + template_effect(() => set_text(text_9, get(a).areaDesc)); + append($$anchor, div_7); + }; + if_block(node_5, ($$render) => { + if (get(a).areaDesc) $$render(consequent_5); + }); + var node_6 = sibling(node_5, 2); + var consequent_6 = ($$anchor) => { + var p_2 = root_4$3(); + var text_10 = child(p_2, true); + reset(p_2); + template_effect(() => set_text(text_10, get(a).description)); + append($$anchor, p_2); + }; + if_block(node_6, ($$render) => { + if (get(a).description) $$render(consequent_6); + }); + var node_7 = sibling(node_6, 2); + var consequent_7 = ($$anchor) => { + var p_3 = root_5$3(); + var text_11 = child(p_3, true); + reset(p_3); + template_effect(() => set_text(text_11, get(a).instruction)); + append($$anchor, p_3); + }; + if_block(node_7, ($$render) => { + if (get(a).instruction) $$render(consequent_7); + }); + var div_8 = sibling(node_7, 2); + var text_12 = child(div_8); + reset(div_8); + reset(details); + reset(li); + template_effect(($0, $1) => { + set_class(li, 1, `alert-item ${(SEVERITY_CLASS[get(a).severity] || "sev-minor") ?? ""}`, "svelte-19ej3vo"); + set_text(text_6, get(a).event); + set_text(text_7, $0); + set_text(text_12, `${(get(a).senderName || "National Weather Service") ?? ""} · expires ${$1 ?? ""}`); + }, [() => untilLabel(get(a).ends), () => clock(get(a).expires)]); + append($$anchor, li); + }); + reset(ul); + append($$anchor, ul); + }; + var consequent_9 = ($$anchor) => { + append($$anchor, root_8$1()); + }; + var alternate_1 = ($$anchor) => { + append($$anchor, root_9$1()); + }; + if_block(node_3, ($$render) => { + if (get(alerts).length) $$render(consequent_8); + else if (storm.loading && !storm.updatedAt) $$render(consequent_9, 1); + else $$render(alternate_1, -1); + }); + reset(section); + var section_1 = sibling(section, 2); + var node_8 = sibling(child(section_1), 2); + var consequent_14 = ($$anchor) => { + var fragment_1 = root_14(); + var div_11 = first_child(fragment_1); + each(div_11, 20, () => [ + ["day1", "Today"], + ["day2", "Tomorrow"], + ["day3", "Day 3"] + ], ([key, label]) => key, ($$anchor, $$item) => { + var $$array = /* @__PURE__ */ user_derived(() => to_array($$item, 2)); + let key = () => get($$array)[0]; + let label = () => get($$array)[1]; + const r = /* @__PURE__ */ user_derived(() => outlookDay(key())); + var fragment_2 = comment(); + var node_9 = first_child(fragment_2); + var consequent_10 = ($$anchor) => { + var div_12 = root_10(); + let classes; + var span_3 = child(div_12); + var text_13 = child(span_3, true); + reset(span_3); + var span_4 = sibling(span_3, 2); + var text_14 = child(span_4, true); + reset(span_4); + var span_5 = sibling(span_4, 2); + var text_15 = child(span_5, true); + reset(span_5); + reset(div_12); + template_effect(() => { + classes = set_class(div_12, 1, "outlook-day svelte-19ej3vo", null, classes, { risky: get(r).key !== "NONE" && get(r).key !== "TSTM" }); + set_style(div_12, `--risk-color: ${get(r).color}`); + set_text(text_13, label()); + set_text(text_14, get(r).short); + set_text(text_15, get(r).label); + }); + append($$anchor, div_12); + }; + if_block(node_9, ($$render) => { + if (get(r)) $$render(consequent_10); + }); + append($$anchor, fragment_2); + }); + reset(div_11); + var node_10 = sibling(div_11, 2); + var consequent_11 = ($$anchor) => { + var fragment_3 = root_11(); + var div_13 = first_child(fragment_3); + var div_14 = child(div_13); + var span_6 = sibling(child(div_14), 2); + var text_16 = child(span_6); + reset(span_6); + next(2); + reset(div_14); + var div_15 = sibling(div_14, 2); + var span_7 = sibling(child(div_15), 2); + var text_17 = child(span_7); + reset(span_7); + next(2); + reset(div_15); + var div_16 = sibling(div_15, 2); + var span_8 = sibling(child(div_16), 2); + var text_18 = child(span_8); + reset(span_8); + next(2); + reset(div_16); + reset(div_13); + next(2); + template_effect(() => { + set_text(text_16, `${get(outlook).probabilities.tornado ?? ""}%`); + set_text(text_17, `${get(outlook).probabilities.wind ?? ""}%`); + set_text(text_18, `${get(outlook).probabilities.hail ?? ""}%`); + }); + append($$anchor, fragment_3); + }; + var consequent_12 = ($$anchor) => { + append($$anchor, root_12()); + }; + if_block(node_10, ($$render) => { + if (get(outlook).probabilities?.tornado || get(outlook).probabilities?.wind || get(outlook).probabilities?.hail) $$render(consequent_11); + else if (get(outlook).day1.key === "NONE") $$render(consequent_12, 1); + }); + var node_11 = sibling(node_10, 2); + var consequent_13 = ($$anchor) => { + var div_18 = root_13(); + var text_19 = child(div_18); + reset(div_18); + template_effect(() => set_text(text_19, `SPC Day 1 outlook issued ${get(issuedLabel) ?? ""} · updates ~4× daily`)); + append($$anchor, div_18); + }; + if_block(node_11, ($$render) => { + if (get(issuedLabel)) $$render(consequent_13); + }); + append($$anchor, fragment_1); + }; + var consequent_15 = ($$anchor) => { + append($$anchor, root_15()); + }; + var consequent_16 = ($$anchor) => { + append($$anchor, root_16()); + }; + var alternate_2 = ($$anchor) => { + append($$anchor, root_17()); + }; + if_block(node_8, ($$render) => { + if (get(outlook)) $$render(consequent_14); + else if (!storm.hasUsCoverage) $$render(consequent_15, 1); + else if (storm.loading) $$render(consequent_16, 2); + else $$render(alternate_2, -1); + }); + reset(section_1); + var section_2 = sibling(section_1, 2); + var div_22 = child(section_2); + var node_12 = sibling(child(div_22), 4); + var consequent_17 = ($$anchor) => { + var span_9 = root_1$4(); + var text_20 = child(span_9, true); + reset(span_9); + template_effect(() => set_text(text_20, get(reports).length)); + append($$anchor, span_9); + }; + if_block(node_12, ($$render) => { + if (get(reports).length) $$render(consequent_17); + }); + reset(div_22); + var node_13 = sibling(div_22, 2); + var consequent_21 = ($$anchor) => { + var fragment_4 = root_21(); + var div_23 = first_child(fragment_4); + var text_21 = child(div_23); + reset(div_23); + var ul_1 = sibling(div_23, 2); + each(ul_1, 21, () => get(reports).slice(0, maxReports), (r) => r.id, ($$anchor, r) => { + var li_1 = root_20(); + var span_10 = child(li_1); + var text_22 = child(span_10, true); + reset(span_10); + var div_24 = sibling(span_10, 2); + var div_25 = child(div_24); + var span_11 = child(div_25); + var text_23 = child(span_11, true); + reset(span_11); + var node_14 = sibling(span_11, 2); + var consequent_18 = ($$anchor) => { + var span_12 = root_18(); + var text_24 = child(span_12, true); + reset(span_12); + template_effect(($0) => set_text(text_24, $0), [() => magnitudeLabel(get(r))]); + append($$anchor, span_12); + }; + var d_1 = /* @__PURE__ */ user_derived(() => magnitudeLabel(get(r))); + if_block(node_14, ($$render) => { + if (get(d_1)) $$render(consequent_18); + }); + reset(div_25); + var div_26 = sibling(div_25, 2); + var text_25 = child(div_26, true); + reset(div_26); + var node_15 = sibling(div_26, 2); + var consequent_19 = ($$anchor) => { + var div_27 = root_19(); + var text_26 = child(div_27, true); + reset(div_27); + template_effect(() => set_text(text_26, get(r).remark)); + append($$anchor, div_27); + }; + if_block(node_15, ($$render) => { + if (get(r).remark) $$render(consequent_19); + }); + reset(div_24); + var div_28 = sibling(div_24, 2); + var span_13 = child(div_28); + var text_27 = child(span_13, true); + reset(span_13); + var span_14 = sibling(span_13, 2); + var text_28 = child(span_14, true); + reset(span_14); + reset(div_28); + reset(li_1); + template_effect(($0, $1, $2) => { + set_text(text_22, get(r).icon); + set_text(text_23, get(r).label); + set_text(text_25, $0); + set_text(text_27, $1); + set_text(text_28, $2); + }, [ + () => [ + get(r).city, + get(r).county && `${get(r).county} Co.`, + get(r).state + ].filter(Boolean).join(", "), + () => distance(get(r).distanceKm), + () => ago(get(r).valid) + ]); + append($$anchor, li_1); + }); + reset(ul_1); + var node_16 = sibling(ul_1, 2); + var consequent_20 = ($$anchor) => { + var div_29 = root_13(); + var text_29 = child(div_29); + reset(div_29); + template_effect(() => set_text(text_29, `+${get(reports).length - maxReports} more reports nearby`)); + append($$anchor, div_29); + }; + if_block(node_16, ($$render) => { + if (get(reports).length > maxReports) $$render(consequent_20); + }); + template_effect(($0) => set_text(text_21, `Past 24 hours within ${$0 ?? ""} · nearest first`), [() => distance(160)]); + append($$anchor, fragment_4); + }; + var consequent_22 = ($$anchor) => { + append($$anchor, root_22()); + }; + var consequent_23 = ($$anchor) => { + append($$anchor, root_23()); + }; + var alternate_3 = ($$anchor) => { + var div_32 = root_24(); + var text_30 = child(div_32); + reset(div_32); + template_effect(($0) => set_text(text_30, `✅ No storm reports within ${$0 ?? ""} in the last 24 hours`), [() => distance(160)]); + append($$anchor, div_32); + }; + if_block(node_13, ($$render) => { + if (get(reports).length) $$render(consequent_21); + else if (!storm.hasUsCoverage) $$render(consequent_22, 1); + else if (storm.loading && !storm.updatedAt) $$render(consequent_23, 2); + else $$render(alternate_3, -1); + }); + next(2); + reset(section_2); + reset(div); + template_effect(() => { + button.disabled = storm.loading; + set_text(text_3, storm.loading ? "⏳" : "↻"); + }); + delegated("click", button, refresh); + append($$anchor, div); + pop(); +} +delegate(["click"]); +//#endregion //#region src/components/NotificationBell.svelte var root$3 = /* @__PURE__ */ from_html(`🔔 `, 1); var root_1$3 = /* @__PURE__ */ from_html(`🔕`); @@ -8660,7 +9800,8 @@ var VALID_VIEWS = [ "hourly", "daily", "detail", - "radar" + "radar", + "storms" ]; var DEFAULT_VIEW = "current"; /** @@ -8689,9 +9830,11 @@ var root_1 = /* @__PURE__ */ from_html(` `); var root_3 = /* @__PURE__ */ from_html(`
        🌍

        Welcome to WeatherLens

        `); var root_4 = /* @__PURE__ */ from_html(`
        ⏳

        Loading weather data...

        `); -var root_5 = /* @__PURE__ */ from_html(`
        `); -var root_6 = /* @__PURE__ */ from_html(`
        `, 1); -var root_7 = /* @__PURE__ */ from_html(`

        `); +var root_5 = /* @__PURE__ */ from_html(`
        ⏳ Updating…
        `); +var root_6 = /* @__PURE__ */ from_html(``); +var root_7 = /* @__PURE__ */ from_html(`
        `); +var root_8 = /* @__PURE__ */ from_html(`
        `, 1); +var root_9 = /* @__PURE__ */ from_html(`

        `); function App($$anchor, $$props) { push($$props, true); let viewMode = /* @__PURE__ */ state(proxy(parseHash(typeof window !== "undefined" ? window.location.hash : ""))); @@ -8801,6 +9944,8 @@ function App($$anchor, $$props) { notifications.analyze(); notifications.refreshAdvisories(); } + storm.watch(); + storm.start(); set(initDone, true); } user_effect(() => { @@ -8809,6 +9954,11 @@ function App($$anchor, $$props) { notifications.refreshAdvisories(); } }); + user_effect(() => { + if (!get(initDone)) return; + if (app$1.selectedLocation) storm.watch(); + else storm.reset(); + }); user_effect(() => { app$1.settings.refreshInterval; app$1.startAutoRefresh(); @@ -8866,7 +10016,7 @@ function App($$anchor, $$props) { const newest = updated[updated.length - 1]; if (newest) app$1.selectLocation(newest.id); } - var div = root_7(); + var div = root_9(); var div_1 = child(div); var button = child(div_1); var text = child(button, true); @@ -8946,121 +10096,167 @@ function App($$anchor, $$props) { if (app$1.loading && !app$1.forecastData) $$render(consequent_4); }); var node_8 = sibling(node_7, 2); - var consequent_10 = ($$anchor) => { - var fragment = root_6(); - var div_8 = first_child(fragment); - var button_5 = child(div_8); + var consequent_5 = ($$anchor) => { + append($$anchor, root_5()); + }; + if_block(node_8, ($$render) => { + if (app$1.loading && app$1.forecastData) $$render(consequent_5); + }); + var node_9 = sibling(node_8, 2); + var consequent_13 = ($$anchor) => { + var fragment = root_8(); + var node_10 = first_child(fragment); + var consequent_6 = ($$anchor) => { + var button_5 = root_6(); + var span_3 = sibling(child(button_5), 2); + var strong = child(span_3); + var text_5 = child(strong, true); + reset(strong); + var span_4 = sibling(strong, 2); + var text_6 = child(span_4, true); + reset(span_4); + reset(span_3); + next(2); + reset(button_5); + template_effect(($0) => { + set_attribute(button_5, "aria-label", `${storm.banner.event ?? ""} — open storm details`); + set_text(text_5, storm.banner.event); + set_text(text_6, $0); + }, [() => storm.banner.areaDesc.split(";").slice(0, 2).join(" · ")]); + delegated("click", button_5, () => setView("storms")); + append($$anchor, button_5); + }; + if_block(node_10, ($$render) => { + if (storm.banner) $$render(consequent_6); + }); + var div_9 = sibling(node_10, 2); + var button_6 = child(div_9); let classes_1; - var button_6 = sibling(button_5, 2); - let classes_2; var button_7 = sibling(button_6, 2); - let classes_3; + let classes_2; var button_8 = sibling(button_7, 2); - let classes_4; + let classes_3; var button_9 = sibling(button_8, 2); + let classes_4; + var button_10 = sibling(button_9, 2); let classes_5; - reset(div_8); - var div_9 = sibling(div_8, 2); - var button_10 = child(div_9); - let classes_6; var button_11 = sibling(button_10, 2); - let classes_7; - var button_12 = sibling(button_11, 2); - let classes_8; - var button_13 = sibling(button_12, 2); - let classes_9; - var button_14 = sibling(button_13, 2); - let classes_10; + let classes_6; reset(div_9); var div_10 = sibling(div_9, 2); + var button_12 = child(div_10); + let classes_7; + var button_13 = sibling(button_12, 2); + let classes_8; + var button_14 = sibling(button_13, 2); + let classes_9; + var button_15 = sibling(button_14, 2); + let classes_10; + var button_16 = sibling(button_15, 2); let classes_11; - var div_11 = child(div_10); + var button_17 = sibling(button_16, 2); let classes_12; - var node_9 = child(div_11); - var consequent_5 = ($$anchor) => { - var div_12 = root_5(); - CurrentWeather(child(div_12), {}); - reset(div_12); - append($$anchor, div_12); - }; - var consequent_6 = ($$anchor) => { - var div_13 = root_5(); - HourlyForecast(child(div_13), {}); + reset(div_10); + var div_11 = sibling(div_10, 2); + let classes_13; + var div_12 = child(div_11); + let classes_14; + var node_11 = child(div_12); + var consequent_7 = ($$anchor) => { + var div_13 = root_7(); + CurrentWeather(child(div_13), {}); reset(div_13); append($$anchor, div_13); }; - var consequent_7 = ($$anchor) => { - var div_14 = root_5(); - DailyForecast(child(div_14), {}); + var consequent_8 = ($$anchor) => { + var div_14 = root_7(); + HourlyForecast(child(div_14), {}); reset(div_14); append($$anchor, div_14); }; - var consequent_8 = ($$anchor) => { - var div_15 = root_5(); - WeatherDetail(child(div_15), {}); + var consequent_9 = ($$anchor) => { + var div_15 = root_7(); + DailyForecast(child(div_15), {}); reset(div_15); append($$anchor, div_15); }; - var consequent_9 = ($$anchor) => { - var div_16 = root_5(); - PrecipitationRadar(child(div_16), {}); + var consequent_10 = ($$anchor) => { + var div_16 = root_7(); + WeatherDetail(child(div_16), {}); reset(div_16); append($$anchor, div_16); }; - if_block(node_9, ($$render) => { - if (get(viewMode) === "current") $$render(consequent_5); - else if (get(viewMode) === "hourly") $$render(consequent_6, 1); - else if (get(viewMode) === "daily") $$render(consequent_7, 2); - else if (get(viewMode) === "detail") $$render(consequent_8, 3); - else if (get(viewMode) === "radar") $$render(consequent_9, 4); + var consequent_11 = ($$anchor) => { + var div_17 = root_7(); + PrecipitationRadar(child(div_17), {}); + reset(div_17); + append($$anchor, div_17); + }; + var consequent_12 = ($$anchor) => { + var div_18 = root_7(); + StormWatch(child(div_18), {}); + reset(div_18); + append($$anchor, div_18); + }; + if_block(node_11, ($$render) => { + if (get(viewMode) === "current") $$render(consequent_7); + else if (get(viewMode) === "hourly") $$render(consequent_8, 1); + else if (get(viewMode) === "daily") $$render(consequent_9, 2); + else if (get(viewMode) === "detail") $$render(consequent_10, 3); + else if (get(viewMode) === "radar") $$render(consequent_11, 4); + else if (get(viewMode) === "storms") $$render(consequent_12, 5); }); + reset(div_12); reset(div_11); - reset(div_10); - bind_this(div_10, ($$value) => set(contentEl, $$value), () => get(contentEl)); + bind_this(div_11, ($$value) => set(contentEl, $$value), () => get(contentEl)); template_effect(() => { - classes_1 = set_class(button_5, 1, "nav-btn svelte-1n46o8q", null, classes_1, { active: get(viewMode) === "current" }); - classes_2 = set_class(button_6, 1, "nav-btn svelte-1n46o8q", null, classes_2, { active: get(viewMode) === "hourly" }); - classes_3 = set_class(button_7, 1, "nav-btn svelte-1n46o8q", null, classes_3, { active: get(viewMode) === "daily" }); - classes_4 = set_class(button_8, 1, "nav-btn svelte-1n46o8q", null, classes_4, { active: get(viewMode) === "detail" }); - classes_5 = set_class(button_9, 1, "nav-btn svelte-1n46o8q", null, classes_5, { active: get(viewMode) === "radar" }); - classes_6 = set_class(button_10, 1, "tab-btn svelte-1n46o8q", null, classes_6, { active: get(viewMode) === "current" }); - classes_7 = set_class(button_11, 1, "tab-btn svelte-1n46o8q", null, classes_7, { active: get(viewMode) === "hourly" }); - classes_8 = set_class(button_12, 1, "tab-btn svelte-1n46o8q", null, classes_8, { active: get(viewMode) === "daily" }); - classes_9 = set_class(button_13, 1, "tab-btn svelte-1n46o8q", null, classes_9, { active: get(viewMode) === "detail" }); - classes_10 = set_class(button_14, 1, "tab-btn svelte-1n46o8q", null, classes_10, { active: get(viewMode) === "radar" }); - classes_11 = set_class(div_10, 1, "content-area svelte-1n46o8q", null, classes_11, { swiping: get(swiping) }); - classes_12 = set_class(div_11, 1, "swipe-layer svelte-1n46o8q", null, classes_12, { settle: get(settling) }); - set_style(div_11, get(slideStyle)); + classes_1 = set_class(button_6, 1, "nav-btn svelte-1n46o8q", null, classes_1, { active: get(viewMode) === "current" }); + classes_2 = set_class(button_7, 1, "nav-btn svelte-1n46o8q", null, classes_2, { active: get(viewMode) === "hourly" }); + classes_3 = set_class(button_8, 1, "nav-btn svelte-1n46o8q", null, classes_3, { active: get(viewMode) === "daily" }); + classes_4 = set_class(button_9, 1, "nav-btn svelte-1n46o8q", null, classes_4, { active: get(viewMode) === "detail" }); + classes_5 = set_class(button_10, 1, "nav-btn svelte-1n46o8q", null, classes_5, { active: get(viewMode) === "radar" }); + classes_6 = set_class(button_11, 1, "nav-btn svelte-1n46o8q", null, classes_6, { active: get(viewMode) === "storms" }); + classes_7 = set_class(button_12, 1, "tab-btn svelte-1n46o8q", null, classes_7, { active: get(viewMode) === "current" }); + classes_8 = set_class(button_13, 1, "tab-btn svelte-1n46o8q", null, classes_8, { active: get(viewMode) === "hourly" }); + classes_9 = set_class(button_14, 1, "tab-btn svelte-1n46o8q", null, classes_9, { active: get(viewMode) === "daily" }); + classes_10 = set_class(button_15, 1, "tab-btn svelte-1n46o8q", null, classes_10, { active: get(viewMode) === "detail" }); + classes_11 = set_class(button_16, 1, "tab-btn svelte-1n46o8q", null, classes_11, { active: get(viewMode) === "radar" }); + classes_12 = set_class(button_17, 1, "tab-btn svelte-1n46o8q", null, classes_12, { active: get(viewMode) === "storms" }); + classes_13 = set_class(div_11, 1, "content-area svelte-1n46o8q", null, classes_13, { swiping: get(swiping) }); + classes_14 = set_class(div_12, 1, "swipe-layer svelte-1n46o8q", null, classes_14, { settle: get(settling) }); + set_style(div_12, get(slideStyle)); }); - delegated("click", button_5, () => setView("current")); - delegated("click", button_6, () => setView("hourly")); - delegated("click", button_7, () => setView("daily")); - delegated("click", button_8, () => setView("detail")); - delegated("click", button_9, () => setView("radar")); - delegated("click", button_10, () => setView("current")); - delegated("click", button_11, () => setView("hourly")); - delegated("click", button_12, () => setView("daily")); - delegated("click", button_13, () => setView("detail")); - delegated("click", button_14, () => setView("radar")); - delegated("touchstart", div_10, onSwipeStart, void 0, true); - delegated("touchmove", div_10, onSwipeMove, void 0, true); - delegated("touchend", div_10, finishSwipe); - event("touchcancel", div_10, cancelSwipe); + delegated("click", button_6, () => setView("current")); + delegated("click", button_7, () => setView("hourly")); + delegated("click", button_8, () => setView("daily")); + delegated("click", button_9, () => setView("detail")); + delegated("click", button_10, () => setView("radar")); + delegated("click", button_11, () => setView("storms")); + delegated("click", button_12, () => setView("current")); + delegated("click", button_13, () => setView("hourly")); + delegated("click", button_14, () => setView("daily")); + delegated("click", button_15, () => setView("detail")); + delegated("click", button_16, () => setView("radar")); + delegated("click", button_17, () => setView("storms")); + delegated("touchstart", div_11, onSwipeStart, void 0, true); + delegated("touchmove", div_11, onSwipeMove, void 0, true); + delegated("touchend", div_11, finishSwipe); + event("touchcancel", div_11, cancelSwipe); append($$anchor, fragment); }; - if_block(node_8, ($$render) => { - if (app$1.forecastData && app$1.selectedLocation) $$render(consequent_10); + if_block(node_9, ($$render) => { + if (app$1.forecastData && app$1.selectedLocation) $$render(consequent_13); }); reset(main); - var node_15 = sibling(main, 2); - var consequent_11 = ($$anchor) => { + var node_18 = sibling(main, 2); + var consequent_14 = ($$anchor) => { SettingsDialog($$anchor, { onClose: () => app$1.showSettings = false }); }; - if_block(node_15, ($$render) => { - if (app$1.showSettings) $$render(consequent_11); + if_block(node_18, ($$render) => { + if (app$1.showSettings) $$render(consequent_14); }); - var node_16 = sibling(node_15, 2); - var consequent_12 = ($$anchor) => { + var node_19 = sibling(node_18, 2); + var consequent_15 = ($$anchor) => { AddLocationDialog($$anchor, { onClose: () => { app$1.showAddLocation = false; @@ -9069,8 +10265,8 @@ function App($$anchor, $$props) { onAdd: handleAddLocation }); }; - if_block(node_16, ($$render) => { - if (app$1.showAddLocation) $$render(consequent_12); + if_block(node_19, ($$render) => { + if (app$1.showAddLocation) $$render(consequent_15); }); reset(div); template_effect(() => { @@ -9092,7 +10288,7 @@ delegate([ ]); //#endregion //#region src/main.js -console.info({ commit_hash: "f09fc5b1a3caa6e8a93a84ea3cad9048926a9470" }); +console.info({ commit_hash: "ae71c36acc0c6bbfc9fb000f1f8d1b4083da2a82" }); registerServiceWorker(); mount(App, { target: document.getElementById("app") }); //#endregion @@ -10669,6 +11865,338 @@ input::placeholder { } } + .storm-page.svelte-19ej3vo { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 900px; + } + + .storm-head.svelte-19ej3vo { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 2px 2px 0; + } + + .storm-title.svelte-19ej3vo { + font-size: 1.05rem; + font-weight: 700; + } + + .storm-sub.svelte-19ej3vo { + font-size: 0.78rem; + color: var(--color-text-muted); + margin-top: 2px; + } + + .refresh-btn.svelte-19ej3vo { + font-size: 1.15rem; + } + + .storm-error.svelte-19ej3vo { + font-size: 0.8rem; + color: #fca5a5; + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.25); + border-radius: var(--radius-md); + padding: 8px 12px; + } + + .storm-card.svelte-19ej3vo { + padding: 14px 16px; + } + + .section-head.svelte-19ej3vo { + display: flex; + align-items: center; + gap: 8px; + padding-bottom: 10px; + margin-bottom: 10px; + border-bottom: 1px solid var(--color-border); + } + + .section-icon.svelte-19ej3vo { font-size: 1rem; } + + .section-title.svelte-19ej3vo { + font-size: 0.85rem; + font-weight: 600; + color: var(--color-text-secondary); + flex: 1; + } + + .count-badge.svelte-19ej3vo { + font-size: 0.7rem; + font-weight: 700; + min-width: 20px; + text-align: center; + padding: 2px 7px; + border-radius: 999px; + background: rgba(56, 189, 248, 0.15); + color: var(--color-primary); + } + + .empty-line.svelte-19ej3vo { + font-size: 0.82rem; + color: var(--color-text-muted); + padding: 4px 0; + } + + .empty-line.ok.svelte-19ej3vo { color: var(--color-success); } + + /* --- alerts --- */ + .alert-list.svelte-19ej3vo { + list-style: none; + display: flex; + flex-direction: column; + gap: 8px; + } + + .alert-item.svelte-19ej3vo { + border-left: 3px solid var(--sev-color, var(--color-warning)); + background: var(--sev-bg, rgba(245, 158, 11, 0.08)); + border-radius: var(--radius-sm); + padding: 8px 10px; + } + + .sev-extreme.svelte-19ej3vo { --sev-color: #ff2d55; --sev-bg: rgba(255, 45, 85, 0.12); } + .sev-severe.svelte-19ej3vo { --sev-color: #ef4444; --sev-bg: rgba(239, 68, 68, 0.1); } + .sev-moderate.svelte-19ej3vo { --sev-color: #f59e0b; --sev-bg: rgba(245, 158, 11, 0.1); } + .sev-minor.svelte-19ej3vo { --sev-color: #38bdf8; --sev-bg: rgba(56, 189, 248, 0.07); } + + .alert-row.svelte-19ej3vo { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + } + + .alert-event.svelte-19ej3vo { + font-size: 0.88rem; + font-weight: 700; + color: var(--color-text); + } + + .alert-until.svelte-19ej3vo { + font-size: 0.72rem; + color: var(--color-text-secondary); + white-space: nowrap; + } + + .alert-headline.svelte-19ej3vo { + font-size: 0.78rem; + color: var(--color-text-secondary); + margin-top: 3px; + } + + .alert-details.svelte-19ej3vo { + margin-top: 6px; + } + + .alert-details.svelte-19ej3vo summary:where(.svelte-19ej3vo) { + font-size: 0.72rem; + color: var(--color-primary); + cursor: pointer; + width: fit-content; + list-style: none; + } + + .alert-details.svelte-19ej3vo summary:where(.svelte-19ej3vo)::-webkit-details-marker { display: none; } + + .alert-details.svelte-19ej3vo summary:where(.svelte-19ej3vo)::before { content: '▸ '; } + .alert-details[open].svelte-19ej3vo summary:where(.svelte-19ej3vo)::before { content: '▾ '; } + + .alert-area.svelte-19ej3vo, + .alert-desc.svelte-19ej3vo, + .alert-instruction.svelte-19ej3vo { + font-size: 0.76rem; + color: var(--color-text-secondary); + margin-top: 6px; + white-space: pre-wrap; + line-height: 1.45; + } + + .alert-instruction.svelte-19ej3vo { + color: var(--color-text); + border-left: 2px solid var(--sev-color, var(--color-border)); + padding-left: 8px; + } + + .alert-source.svelte-19ej3vo { + font-size: 0.7rem; + color: var(--color-text-muted); + margin-top: 6px; + } + + /* --- outlook --- */ + .outlook-days.svelte-19ej3vo { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + } + + .outlook-day.svelte-19ej3vo { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 10px 6px; + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--color-border); + } + + .outlook-day.risky.svelte-19ej3vo { + border-color: var(--risk-color); + background: color-mix(in srgb, var(--risk-color) 14%, transparent); + } + + .outlook-label.svelte-19ej3vo { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted); + } + + .outlook-chip.svelte-19ej3vo { + font-size: 0.95rem; + font-weight: 800; + letter-spacing: 0.02em; + color: var(--risk-color, var(--color-text)); + } + + .outlook-desc.svelte-19ej3vo { + font-size: 0.68rem; + color: var(--color-text-secondary); + text-align: center; + line-height: 1.25; + } + + .prob-row.svelte-19ej3vo { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin-top: 12px; + } + + .prob.svelte-19ej3vo { + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + padding: 8px 4px; + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.03); + } + + .prob-icon.svelte-19ej3vo { font-size: 0.95rem; } + + .prob-value.svelte-19ej3vo { + font-size: 0.95rem; + font-weight: 700; + } + + .prob-label.svelte-19ej3vo { + font-size: 0.68rem; + color: var(--color-text-muted); + } + + .outlook-note.svelte-19ej3vo, + .reports-sub.svelte-19ej3vo, + .outlook-foot.svelte-19ej3vo { + font-size: 0.7rem; + color: var(--color-text-muted); + margin-top: 8px; + } + + .outlook-foot.svelte-19ej3vo { + margin-top: 10px; + padding-top: 8px; + border-top: 1px solid var(--color-border); + } + + /* --- reports --- */ + .report-list.svelte-19ej3vo { + list-style: none; + display: flex; + flex-direction: column; + gap: 2px; + } + + .report-item.svelte-19ej3vo { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid rgba(30, 58, 102, 0.6); + } + + .report-item.svelte-19ej3vo:last-child { border-bottom: none; } + + .report-icon.svelte-19ej3vo { + font-size: 1rem; + line-height: 1.3; + } + + .report-body.svelte-19ej3vo { + flex: 1; + min-width: 0; + } + + .report-main.svelte-19ej3vo { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; + } + + .report-type.svelte-19ej3vo { + font-size: 0.85rem; + font-weight: 600; + } + + .report-mag.svelte-19ej3vo { + font-size: 0.75rem; + color: var(--color-warning); + font-weight: 600; + } + + .report-place.svelte-19ej3vo { + font-size: 0.75rem; + color: var(--color-text-secondary); + margin-top: 1px; + } + + .report-remark.svelte-19ej3vo { + font-size: 0.72rem; + color: var(--color-text-muted); + margin-top: 2px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .report-meta.svelte-19ej3vo { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + font-size: 0.7rem; + color: var(--color-text-secondary); + white-space: nowrap; + } + + .report-ago.svelte-19ej3vo { + color: var(--color-text-muted); + } + + @media (max-width: 480px) { + .outlook-desc.svelte-19ej3vo { display: none; } + .storm-card.svelte-19ej3vo { padding: 12px; } + } + .bell-wrapper.svelte-hpnwii { position: relative; display: inline-flex; @@ -11376,6 +12904,82 @@ input::placeholder { animation: svelte-1n46o8q-spin 1.5s linear infinite; } + /* Non-blocking "Updating…" chip shown while a location change refetches. */ + .refreshing-overlay.svelte-1n46o8q { + position: sticky; + top: 8px; + z-index: 45; + display: flex; + justify-content: center; + pointer-events: none; + } + + .refreshing-chip.svelte-1n46o8q { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 999px; + background: var(--color-surface); + border: 1px solid var(--color-border); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); + font-size: 0.8rem; + color: var(--color-text-secondary); + } + + .loading-spinner.small.svelte-1n46o8q { + font-size: 1rem; + } + + /* Active severe-alert banner */ + .storm-banner.svelte-1n46o8q { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + text-align: left; + margin: 8px 0 0; + padding: 10px 14px; + background: rgba(239, 68, 68, 0.12); + border: 1px solid rgba(239, 68, 68, 0.4); + border-radius: var(--radius-md); + color: var(--color-text); + font-family: inherit; + cursor: pointer; + } + + .storm-banner-icon.svelte-1n46o8q { font-size: 1.1rem; } + + .storm-banner-text.svelte-1n46o8q { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + font-size: 0.82rem; + } + + .storm-banner-area.svelte-1n46o8q { + font-size: 0.72rem; + color: var(--color-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .storm-banner-cta.svelte-1n46o8q { + font-size: 0.75rem; + font-weight: 600; + color: #fca5a5; + white-space: nowrap; + } + + @media (max-width: 1023px) { + .storm-banner.svelte-1n46o8q { + margin: 6px 8px 0; + } + } + @keyframes svelte-1n46o8q-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } diff --git a/dist/pwa-icon.svg b/dist/pwa-icon.svg deleted file mode 100644 index adf0c76..0000000 --- a/dist/pwa-icon.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/src/App.svelte b/src/App.svelte index 0fe1a59..315a49a 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,12 +1,14 @@ + +
        +
        +
        +

        ⛈️ Storm Watch

        +

        + {#if storm.updatedAt} + Updated {clock(new Date(storm.updatedAt).toISOString())} + {:else if storm.loading} + Checking watches, outlook and reports… + {:else} + Watches, severe-weather outlook and recent reports + {/if} +

        +
        + +
        + + {#if storm.error} +
        {storm.error}
        + {/if} + + +
        +
        + ⚠️ + Active alerts + {#if alerts.length}{alerts.length}{/if} +
        + + {#if alerts.length} +
          + {#each alerts as a (a.id)} +
        • +
          + {a.event} + {untilLabel(a.ends)} +
          + {#if a.headline} +
          {a.headline}
          + {/if} +
          + Details + {#if a.areaDesc}
          {a.areaDesc}
          {/if} + {#if a.description}

          {a.description}

          {/if} + {#if a.instruction}

          {a.instruction}

          {/if} +
          + {a.senderName || 'National Weather Service'} · expires {clock(a.expires)} +
          +
          +
        • + {/each} +
        + {:else if storm.loading && !storm.updatedAt} +
        Checking for active alerts…
        + {:else} +
        ✅ No active watches, warnings or advisories
        + {/if} +
        + + +
        +
        + 🌪️ + Severe-weather outlook +
        + + {#if outlook} +
        + {#each [['day1', 'Today'], ['day2', 'Tomorrow'], ['day3', 'Day 3']] as [key, label] (key)} + {@const r = outlookDay(key)} + {#if r} +
        + {label} + {r.short} + {r.label} +
        + {/if} + {/each} +
        + + {#if (outlook.probabilities?.tornado || outlook.probabilities?.wind || outlook.probabilities?.hail)} +
        +
        + 🌪️ + {outlook.probabilities.tornado}% + tornado +
        +
        + 💨 + {outlook.probabilities.wind}% + wind +
        +
        + 🧊 + {outlook.probabilities.hail}% + hail +
        +
        +
        + Day 1 probability of severe weather within 25 miles of this point. +
        + {:else if outlook.day1.key === 'NONE'} +
        No severe-weather area covers this location right now.
        + {/if} + + {#if issuedLabel} +
        SPC Day 1 outlook issued {issuedLabel} · updates ~4× daily
        + {/if} + {:else if !storm.hasUsCoverage} +
        SPC outlooks cover the United States only.
        + {:else if storm.loading} +
        Loading outlook…
        + {:else} +
        Outlook unavailable right now.
        + {/if} +
        + + +
        +
        + 📍 + Recent reports + {#if reports.length}{reports.length}{/if} +
        + + {#if reports.length} +
        + Past 24 hours within {distance(160)} · nearest first +
        +
          + {#each reports.slice(0, maxReports) as r (r.id)} +
        • + {r.icon} +
          +
          + {r.label} + {#if magnitudeLabel(r)}{magnitudeLabel(r)}{/if} +
          +
          + {[r.city, r.county && `${r.county} Co.`, r.state].filter(Boolean).join(', ')} +
          + {#if r.remark} +
          {r.remark}
          + {/if} +
          +
          + {distance(r.distanceKm)} + {ago(r.valid)} +
          +
        • + {/each} +
        + {#if reports.length > maxReports} +
        +{reports.length - maxReports} more reports nearby
        + {/if} + {:else if !storm.hasUsCoverage} +
        Local Storm Reports cover the United States only.
        + {:else if storm.loading && !storm.updatedAt} +
        Loading recent reports…
        + {:else} +
        ✅ No storm reports within {distance(160)} in the last 24 hours
        + {/if} + +
        + Reports: NWS Local Storm Reports via Iowa Environmental Mesonet · Outlook: NOAA Storm Prediction Center +
        +
        +
        + + diff --git a/src/lib/api/storm.js b/src/lib/api/storm.js new file mode 100644 index 0000000..db1e78f --- /dev/null +++ b/src/lib/api/storm.js @@ -0,0 +1,448 @@ +/** + * Storm-forecasting data sources. + * + * Three independent, keyless, CORS-enabled feeds: + * 1. NWS active alerts — api.weather.gov/alerts/active?point=… (watches/warnings) + * 2. SPC convective outlooks — spc.noaa.gov/products/outlook/*.nolyr.geojson + * (Day 1–3 categorical risk + Day 1 tornado/hail/wind probabilities) + * 3. IEM Local Storm Reports — mesonet.agron.iastate.edu/geojson/lsr.geojson + * (ground-truth reports relayed from NWS WFOs, incl. trained spotters) + * + * Everything here is plain browser fetch + pure normalization, so the helpers + * are unit-testable without a network (see tests/lib/api/storm.test.js). + */ + +const SPC_BASE = 'https://www.spc.noaa.gov/products/outlook' +const IEM_LSR = 'https://mesonet.agron.iastate.edu/geojson/lsr.geojson' +const NWS_API = 'https://api.weather.gov' +const NWS_USER_AGENT = 'WeatherLens/0.1 (weather app)' + +// =========================================================================== +// Risk levels +// =========================================================================== + +/** SPC categorical outlook levels, weakest → strongest. */ +export const RISK_RANK = { NONE: 0, TSTM: 1, MRGL: 2, SLGT: 3, ENH: 4, MDT: 5, HIGH: 6 } + +/** Display metadata per SPC category (colors follow the official SPC palette). */ +export const RISK_META = { + NONE: { label: 'No outlook', short: '—', color: '#7b8eb5', rank: 0 }, + TSTM: { label: 'General thunderstorms', short: 'TSTM', color: '#55bb55', rank: 1 }, + MRGL: { label: 'Marginal risk', short: 'MRGL', color: '#66ff66', rank: 2 }, + SLGT: { label: 'Slight risk', short: 'SLGT', color: '#ffff00', rank: 3 }, + ENH: { label: 'Enhanced risk', short: 'ENH', color: '#ffa500', rank: 4 }, + MDT: { label: 'Moderate risk', short: 'MDT', color: '#ff0000', rank: 5 }, + HIGH: { label: 'High risk', short: 'HIGH', color: '#ff00ff', rank: 6 }, +} + +/** Normalize an SPC `LABEL` ("TSTM", "SLGT", "ENH"…) into a RISK_RANK key. */ +export function riskKey(label) { + const k = String(label || '').trim().toUpperCase() + return Object.prototype.hasOwnProperty.call(RISK_RANK, k) ? k : 'NONE' +} + +// =========================================================================== +// Geometry helpers +// =========================================================================== + +/** + * Ray-casting point-in-ring test. Ring is [[lon, lat], …] and may be open or + * closed; both are accepted. Longitude/latitude (GeoJSON order) throughout. + * @param {number} lon + * @param {number} lat + * @param {number[][]} ring + * @returns {boolean} + */ +export function pointInRing(lon, lat, ring) { + if (!Array.isArray(ring) || ring.length < 3) return false + let inside = false + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const xi = ring[i][0] + const yi = ring[i][1] + const xj = ring[j][0] + const yj = ring[j][1] + if (xi == null || yi == null || xj == null || yj == null) continue + const intersects = (yi > lat) !== (yj > lat) && + lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi + if (intersects) inside = !inside + } + return inside +} + +/** + * Point-in-polygon for a GeoJSON Polygon coordinate array: first ring is the + * outer boundary, any further rings are holes. + * @param {number} lon + * @param {number} lat + * @param {number[][][]} coords + * @returns {boolean} + */ +export function pointInPolygon(lon, lat, coords) { + if (!Array.isArray(coords) || !coords.length) return false + if (!pointInRing(lon, lat, coords[0])) return false + for (let i = 1; i < coords.length; i++) { + if (pointInRing(lon, lat, coords[i])) return false // inside a hole + } + return true +} + +/** Point-in-geometry for Polygon / MultiPolygon (other types are false). */ +export function pointInGeometry(lon, lat, geometry) { + if (!geometry) return false + if (geometry.type === 'Polygon') return pointInPolygon(lon, lat, geometry.coordinates) + if (geometry.type === 'MultiPolygon') { + return (geometry.coordinates || []).some((poly) => pointInPolygon(lon, lat, poly)) + } + return false +} + +/** Great-circle distance in kilometres. */ +export function haversineKm(lat1, lon1, lat2, lon2) { + const R = 6371 + const toRad = (d) => (d * Math.PI) / 180 + const dLat = toRad(lat2 - lat1) + const dLon = toRad(lon2 - lon1) + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2 + return 2 * R * Math.asin(Math.sqrt(a)) +} + +// =========================================================================== +// SPC outlooks +// =========================================================================== + +/** + * URL for one SPC outlook product. `nolyr` = plain polygon overlay (no labels), + * the lightest machine-readable variant (~1–2 KB per day). + * @param {number} day 1..3 + * @param {'cat'|'torn'|'wind'|'hail'} kind + */ +export function spcOutlookUrl(day, kind = 'cat') { + return `${SPC_BASE}/day${day}otlk_${kind}.nolyr.geojson` +} + +/** + * Highest SPC categorical risk category whose polygon contains the point. + * @param {number} lon + * @param {number} lat + * @param {object} geojson FeatureCollection from spc.noaa.gov + * @returns {{ key: string, label: string, short: string, color: string, props: object|null }} + */ +export function spcRiskAtPoint(lon, lat, geojson) { + let best = null + for (const f of geojson?.features || []) { + if (!pointInGeometry(lon, lat, f.geometry)) continue + const key = riskKey(f.properties?.LABEL) + if (!best || RISK_RANK[key] > RISK_RANK[best.key]) { + best = { key, props: f.properties || {} } + } + } + if (!best) return { key: 'NONE', ...RISK_META.NONE, props: null } + const meta = RISK_META[best.key] + return { key: best.key, label: meta.label, short: meta.short, color: meta.color, rank: meta.rank, props: best.props } +} + +/** + * Highest probability (%) whose polygon contains the point, for one SPC + * probabilistic product. The "Less Than 2% All Areas" background polygon is + * ignored so an empty region reads as 0 rather than "all areas". + * @param {number} lon + * @param {number} lat + * @param {object} geojson + * @returns {number} + */ +export function spcProbabilityAtPoint(lon, lat, geojson) { + let max = 0 + for (const f of geojson?.features || []) { + const label = String(f.properties?.LABEL || '') + if (/less than/i.test(label)) continue + const m = label.match(/\d+/) + if (!m) continue + if (!pointInGeometry(lon, lat, f.geometry)) continue + max = Math.max(max, Number(m[0])) + } + return max +} + +/** + * Collapse the six SPC responses into the shape the UI consumes. + * Any input may be null (a product that isn't issued yet). + * @param {{ cat1?: object, cat2?: object, cat3?: object, torn?: object, wind?: object, hail?: object }} src + * @param {number} lon + * @param {number} lat + */ +export function normalizeOutlook(src, lon, lat) { + const day = (cat) => (cat ? spcRiskAtPoint(lon, lat, cat) : { key: 'NONE', ...RISK_META.NONE, props: null }) + const issued = src.cat1?.features?.[0]?.properties?.ISSUE_ISO || null + const valid = src.cat1?.features?.[0]?.properties?.VALID_ISO || null + return { + day1: day(src.cat1), + day2: day(src.cat2), + day3: day(src.cat3), + probabilities: { + tornado: src.torn ? spcProbabilityAtPoint(lon, lat, src.torn) : 0, + wind: src.wind ? spcProbabilityAtPoint(lon, lat, src.wind) : 0, + hail: src.hail ? spcProbabilityAtPoint(lon, lat, src.hail) : 0, + }, + issued, + valid, + } +} + +/** + * Rough US coverage check (CONUS + AK + HI) — SPC products are US-only. + * The boxes intentionally err wide (southern Canada / northern Mexico fall + * inside the CONUS band); actual outlook coverage comes from the SPC polygons, + * this only avoids pointless requests far outside the US. + */ +export function isUsPoint(lat, lon) { + if (lat == null || lon == null) return false + const conus = lat >= 24 && lat <= 50 && lon >= -125 && lon <= -66 + const alaska = lat >= 50 && lat <= 72 && lon >= -170 && lon <= -129 + const hawaii = lat >= 18.5 && lat <= 22.5 && lon >= -161 && lon <= -154 + return conus || alaska || hawaii +} + +// =========================================================================== +// Local storm reports (IEM / NWS LSRs) +// =========================================================================== + +/** Emoji for an LSR `typetext`, e.g. "TSTM WND GST", "FLASH FLOOD", "HAIL". */ +export function reportIcon(typetext) { + const t = String(typetext || '').toLowerCase() + if (/tornado|funnel/.test(t)) return '🌪️' + if (/hail/.test(t)) return '🧊' + if (/wind|gust|wnd/.test(t)) return '💨' + if (/flood|rain|precip/.test(t)) return '🌊' + if (/snow|ice|freez|winter/.test(t)) return '❄️' + if (/fog|visibility/.test(t)) return '🌫️' + if (/heat|fire/.test(t)) return '🔥' + if (/lightning|tstm/.test(t)) return '⛈️' + return '📍' +} + +/** Human label for an LSR type code, keeping NWS shorthand readable. */ +export function reportLabel(typetext) { + const t = String(typetext || '').trim() + if (!t) return 'Storm report' + if (/^TORNADO$/i.test(t)) return 'Tornado' + if (/^FUNNEL CLOUD$/i.test(t)) return 'Funnel cloud' + if (/^HAIL$/i.test(t)) return 'Hail' + if (/^TSTM WND GST$/i.test(t)) return 'Thunderstorm wind gust' + if (/^TSTM WND DMG$/i.test(t)) return 'Thunderstorm wind damage' + if (/^NON-TSTM WND GST$/i.test(t)) return 'Non-thunderstorm wind gust' + if (/^NON-TSTM WND DMG$/i.test(t)) return 'Non-thunderstorm wind damage' + if (/FLASH FLOOD/i.test(t)) return 'Flash flood' + if (/^FLOOD$/i.test(t)) return 'Flood' + return t.charAt(0) + t.slice(1).toLowerCase() +} + +/** + * Normalize an IEM LSR GeoJSON response into entries sorted nearest-first, + * dropping anything outside `radiusKm`. + * @param {object} geojson + * @param {number} lat + * @param {number} lon + * @param {number} [radiusKm=160] + */ +export function normalizeStormReports(geojson, lat, lon, radiusKm = 160) { + const out = [] + for (const f of geojson?.features || []) { + const p = f.properties || {} + const coords = f.geometry?.coordinates + const rLat = p.lat ?? coords?.[1] + const rLon = p.lon ?? coords?.[0] + if (rLat == null || rLon == null) continue + const distanceKm = haversineKm(lat, lon, rLat, rLon) + if (distanceKm > radiusKm) continue + const magnitude = p.magnitude != null && p.magnitude !== '' ? p.magnitude : null + out.push({ + id: p.product_id + ? `${p.product_id}-${p.valid}-${p.city}` + : `${rLat},${rLon}-${p.valid}-${p.typetext}`, + type: p.typetext || '', + label: reportLabel(p.typetext), + icon: reportIcon(p.typetext), + magnitude, + magnitudeUnit: p.unit || null, + city: p.city || '', + county: p.county || '', + state: p.state || p.st || '', + source: p.source || '', + remark: p.remark || '', + valid: p.valid || null, + lat: rLat, + lon: rLon, + distanceKm: Math.round(distanceKm * 10) / 10, + }) + } + return out.sort((a, b) => a.distanceKm - b.distanceKm) +} + +// =========================================================================== +// NWS active alerts (watches, warnings, advisories) +// =========================================================================== + +const SEVERITY_RANK = { Extreme: 3, Severe: 2, Moderate: 1, Minor: 0, Unknown: 0 } + +/** Map an NWS alert event to the app's alert buckets (mirrors nws.js). */ +export function alertType(event) { + const t = String(event || '').toLowerCase() + if (/heat/.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 surge/.test(t)) return 'storm' + if (/flood/.test(t)) return 'flood' + return 'advisory' +} + +/** Is this alert one that deserves a page-level banner? */ +export function isUrgentWarning(event, severity) { + const t = String(event || '').toLowerCase() + if (/tornado|severe thunderstorm|flash flood emergency|extreme wind/.test(t) && /warning|emergency/.test(t)) return true + return String(severity || '') === 'Extreme' +} + +/** + * Normalize an `/alerts/active` GeoJSON response into display entries, most + * severe first (then soonest ending). + * @param {object} geojson + */ +export function normalizeActiveAlerts(geojson) { + const out = [] + const seen = new Set() + for (const f of geojson?.features || []) { + const p = f.properties || {} + if (p.status && p.status !== 'Actual') continue + if (p.messageType && p.messageType === 'Cancel') continue + const event = p.event || 'Weather Alert' + const id = p.id || `${event}-${p.onset || p.effective || ''}` + if (seen.has(id)) continue + seen.add(id) + out.push({ + id, + event, + type: alertType(event), + severity: p.severity || 'Unknown', + severityRank: SEVERITY_RANK[p.severity] ?? 0, + urgency: p.urgency || '', + certainty: p.certainty || '', + headline: p.headline || '', + description: p.description || '', + instruction: p.instruction || '', + areaDesc: p.areaDesc || '', + senderName: p.senderName || '', + onset: p.onset || p.effective || null, + ends: p.ends || p.expires || null, + expires: p.expires || p.ends || null, + geometry: f.geometry || null, + }) + } + return out.sort((a, b) => + b.severityRank - a.severityRank || + (Date.parse(a.ends || 0) || Infinity) - (Date.parse(b.ends || 0) || Infinity) + ) +} + +// =========================================================================== +// Fetching (in-memory cache + in-flight dedupe) +// =========================================================================== + +const TTL = { + outlook: 20 * 60 * 1000, // SPC issues ~4×/day; 20 min is plenty + reports: 5 * 60 * 1000, + alerts: 2 * 60 * 1000, +} + +const _cache = new Map() // url -> { at, data } +const _inflight = new Map() // url -> Promise + +async function cachedJson(url, ttl, headers = {}) { + const hit = _cache.get(url) + if (hit && Date.now() - hit.at < ttl) return hit.data + if (_inflight.has(url)) return _inflight.get(url) + const p = (async () => { + const res = await fetch(url, { + headers: { Accept: 'application/geo+json, application/json', ...headers }, + }) + if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`) + const data = await res.json() + _cache.set(url, { at: Date.now(), data }) + return data + })() + _inflight.set(url, p) + try { + return await p + } finally { + _inflight.delete(url) + } +} + +/** Drop all cached storm data (used by tests / manual refresh). */ +export function clearStormCache() { + _cache.clear() + _inflight.clear() +} + +/** + * Fetch Day 1–3 categorical outlooks plus Day 1 probabilities and resolve them + * against a point. Missing products degrade to `null` instead of throwing. + * @param {number} lat + * @param {number} lon + */ +export async function fetchSpcOutlook(lat, lon) { + const urls = { + cat1: spcOutlookUrl(1, 'cat'), + cat2: spcOutlookUrl(2, 'cat'), + cat3: spcOutlookUrl(3, 'cat'), + torn: spcOutlookUrl(1, 'torn'), + wind: spcOutlookUrl(1, 'wind'), + hail: spcOutlookUrl(1, 'hail'), + } + const keys = Object.keys(urls) + const settled = await Promise.allSettled(keys.map((k) => cachedJson(urls[k], TTL.outlook))) + const src = {} + settled.forEach((r, i) => { + src[keys[i]] = r.status === 'fulfilled' ? r.value : null + }) + if (keys.every((k) => src[k] == null)) throw new Error('SPC outlook unavailable') + return normalizeOutlook(src, lon, lat) +} + +/** + * Fetch NWS Local Storm Reports inside a bounding box around the point, then + * filter to a radius. + * @param {number} lat + * @param {number} lon + * @param {{ hours?: number, radiusKm?: number }} [opts] + */ +export async function fetchStormReports(lat, lon, { hours = 24, radiusKm = 160 } = {}) { + // ~1.4° of latitude ≈ 155 km; the box is slightly wider than the radius so + // the exact cut can happen in normalizeStormReports without clipping edges. + const pad = Math.max((radiusKm / 111) * 1.4, 1.5) + const lonPad = pad / Math.max(Math.cos((lat * Math.PI) / 180), 0.2) + const ets = new Date() + const sts = new Date(ets.getTime() - hours * 3600 * 1000) + const iso = (d) => d.toISOString().replace(/\.\d{3}Z$/, 'Z') + const params = new URLSearchParams({ + west: String(lon - lonPad), + east: String(lon + lonPad), + north: String(lat + pad), + south: String(lat - pad), + sts: iso(sts), + ets: iso(ets), + }) + const data = await cachedJson(`${IEM_LSR}?${params}`, TTL.reports) + return normalizeStormReports(data, lat, lon, radiusKm) +} + +/** + * Fetch active NWS alerts for a point. Throws outside the NWS domain. + * @param {number} lat + * @param {number} lon + */ +export async function fetchWeatherAlerts(lat, lon) { + const url = `${NWS_API}/alerts/active?point=${lat},${lon}` + const data = await cachedJson(url, TTL.alerts, { 'User-Agent': NWS_USER_AGENT }) + return normalizeActiveAlerts(data) +} diff --git a/src/lib/router.js b/src/lib/router.js index 4191ccd..d2c16e7 100644 --- a/src/lib/router.js +++ b/src/lib/router.js @@ -6,7 +6,7 @@ * There is no nested route structure — just a single top-level view segment. */ -export const VALID_VIEWS = ['current', 'hourly', 'daily', 'detail', 'radar'] +export const VALID_VIEWS = ['current', 'hourly', 'daily', 'detail', 'radar', 'storms'] export const DEFAULT_VIEW = 'current' /** diff --git a/src/lib/stores/storm.svelte.js b/src/lib/stores/storm.svelte.js new file mode 100644 index 0000000..b27287f --- /dev/null +++ b/src/lib/stores/storm.svelte.js @@ -0,0 +1,154 @@ +/** + * Storm-watch store: holds the three storm feeds for the selected location and + * keeps them fresh. + * + * The app is a static single-page build, so this is deliberately polling with a + * modest cadence (SPC outlooks change a few times a day, LSRs arrive in bursts, + * alerts can change any minute) plus an in-module HTTP cache that dedupes + * requests across tabs/remounts. + */ + +import { app } from './app.svelte.js' +import { + fetchSpcOutlook, + fetchStormReports, + fetchWeatherAlerts, + isUsPoint, + isUrgentWarning, +} from '../api/storm.js' + +/** How often the visible storm data is refreshed. */ +export const STORM_REFRESH_MS = 5 * 60 * 1000 + +export class StormStore { + /** @type {Array} active NWS alerts, most severe first */ + alerts = $state([]) + /** SPC outlook for the point, or null outside the US / before first load */ + outlook = $state(null) + /** @type {Array} nearby Local Storm Reports, nearest first */ + reports = $state([]) + loading = $state(false) + /** Non-fatal message when one or more feeds failed. */ + error = $state('') + /** Epoch ms of the last successful load. */ + updatedAt = $state(0) + + /** Location key the data belongs to ("lat,lon"), guards stale responses. */ + _key = null + _timer = null + _promise = null + + /** Does this location have SPC/LSR coverage (US only)? */ + get hasUsCoverage() { + const loc = app.selectedLocation + return !!loc && isUsPoint(loc.lat, loc.lon) + } + + /** The one alert that deserves a banner above the content (if any). */ + get banner() { + for (const a of this.alerts) { + if (isUrgentWarning(a.event, a.severity)) return a + } + return null + } + + /** Number of active alerts, for nav badges. */ + get alertCount() { + return this.alerts.length + } + + /** + * Load the storm feeds for the selected location, unless the data for this + * location is still fresh (pass `force` to bypass). + * @param {{ force?: boolean }} [opts] + */ + async watch({ force = false } = {}) { + const loc = app.selectedLocation + if (!loc) { + this.reset() + return + } + const key = `${loc.lat},${loc.lon}` + if (!force && key === this._key && this.updatedAt && Date.now() - this.updatedAt < STORM_REFRESH_MS) { + return + } + if (this._promise) return this._promise + + this._key = key + this.loading = true + this._promise = this._load(loc, key).finally(() => { + this.loading = false + this._promise = null + }) + return this._promise + } + + /** + * @param {{ lat: number, lon: number }} loc + * @param {string} key + */ + async _load(loc, key) { + const us = isUsPoint(loc.lat, loc.lon) + const [alertsRes, outlookRes, reportsRes] = await Promise.allSettled([ + fetchWeatherAlerts(loc.lat, loc.lon), + us ? fetchSpcOutlook(loc.lat, loc.lon) : Promise.resolve(null), + us ? fetchStormReports(loc.lat, loc.lon) : Promise.resolve([]), + ]) + + // A location switch happened while we were in flight — drop this result. + if (key !== this._key) return + + const failed = [] + if (alertsRes.status === 'fulfilled') { + this.alerts = alertsRes.value + } else { + this.alerts = [] + failed.push('alerts') + } + if (outlookRes.status === 'fulfilled') { + this.outlook = outlookRes.value + } else { + this.outlook = null + failed.push('outlook') + } + if (reportsRes.status === 'fulfilled') { + this.reports = reportsRes.value + } else { + this.reports = [] + failed.push('reports') + } + this.error = failed.length && failed.length < 3 + ? `Some storm data unavailable (${failed.join(', ')})` + : '' + this.updatedAt = Date.now() + } + + /** Start the refresh timer (idempotent). */ + start() { + if (this._timer) return + this._timer = setInterval(() => { + if (typeof document !== 'undefined' && document.hidden) return + this.watch({ force: true }) + }, STORM_REFRESH_MS) + } + + /** Stop the refresh timer. */ + stop() { + if (this._timer) { + clearInterval(this._timer) + this._timer = null + } + } + + /** Clear all data (no location selected). */ + reset() { + this._key = null + this.alerts = [] + this.outlook = null + this.reports = [] + this.error = '' + this.updatedAt = 0 + } +} + +export const storm = new StormStore() diff --git a/tests/components/storm-watch.test.js b/tests/components/storm-watch.test.js new file mode 100644 index 0000000..3449aa1 --- /dev/null +++ b/tests/components/storm-watch.test.js @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mount, unmount, tick } from 'svelte' +import StormWatch from '../../src/components/StormWatch.svelte' +import { storm } from '../../src/lib/stores/storm.svelte.js' +import { app } from '../../src/lib/stores/app.svelte.js' + +const risk = (key, label, short, color) => ({ key, label, short, color, props: null }) + +describe('StormWatch component', () => { + let target + let instance + + beforeEach(() => { + app.locations = [{ id: 'x', name: 'Home', lat: 36.3567, lon: -95.8146, isCurrent: true }] + app.selectedLocationId = 'x' + app.settings = { ...app.settings, units: 'imperial' } + app.forecastData = { timezone: 'America/Chicago' } + storm.updatedAt = Date.now() + target = document.createElement('div') + document.body.appendChild(target) + }) + + afterEach(async () => { + if (instance) await unmount(instance) + target?.remove() + instance = null + document.body.innerHTML = '' + }) + + it('renders alerts, the outlook and nearby reports', async () => { + storm.alerts = [{ + id: 'a1', event: 'Tornado Warning', type: 'storm', severity: 'Extreme', + severityRank: 3, headline: 'Tornado Warning until 5:30 PM', + description: 'Take shelter now.', instruction: 'Go to an interior room.', + areaDesc: 'Rogers, OK', senderName: 'NWS Tulsa OK', + ends: '2026-10-17T22:30:00Z', expires: '2026-10-17T22:45:00Z', + }] + storm.outlook = { + day1: risk('SLGT', 'Slight risk', 'SLGT', '#ffff00'), + day2: risk('TSTM', 'General thunderstorms', 'TSTM', '#55bb55'), + day3: risk('NONE', 'No outlook', '—', '#7b8eb5'), + probabilities: { tornado: 5, wind: 15, hail: 30 }, + issued: '2026-10-17T16:14:00Z', + } + storm.reports = [{ + id: 'r1', type: 'HAIL', label: 'Hail', icon: '🧊', magnitude: '1.75', + city: 'Claremore', county: 'Rogers', state: 'OK', source: 'Trained Spotter', + remark: 'Quarter size hail on Highway 20', valid: '2026-10-17T21:00:00Z', + lat: 36.3, lon: -95.6, distanceKm: 12.4, + }] + + instance = mount(StormWatch, { target }) + await tick() + + const text = target.textContent + expect(text).toContain('Storm Watch') + expect(text).toContain('Tornado Warning') + expect(text).toContain('Active alerts') + expect(text).toContain('Slight risk') + expect(text).toContain('SLGT') + expect(text).toContain('15%') // wind probability + expect(text).toContain('Claremore') + expect(text).toContain('Hail') + expect(text).toContain('Rogers Co.') + expect(text).toContain('Quarter size hail') + expect(target.querySelectorAll('.alert-item')).toHaveLength(1) + expect(target.querySelectorAll('.report-item')).toHaveLength(1) + expect(target.querySelectorAll('.outlook-day')).toHaveLength(3) + }) + + it('shows all-clear states when nothing is active', async () => { + storm.alerts = [] + storm.reports = [] + storm.outlook = { + day1: risk('NONE', 'No outlook', '—', '#7b8eb5'), + day2: risk('NONE', 'No outlook', '—', '#7b8eb5'), + day3: risk('NONE', 'No outlook', '—', '#7b8eb5'), + probabilities: { tornado: 0, wind: 0, hail: 0 }, + issued: null, + } + + instance = mount(StormWatch, { target }) + await tick() + + const text = target.textContent + expect(text).toContain('No active watches, warnings or advisories') + expect(text).toContain('No severe-weather area covers this location') + expect(text).toContain('No storm reports within') + }) + + it('renders the non-US note instead of an outlook', async () => { + app.locations = [{ id: 'y', name: 'London', lat: 51.5, lon: -0.12, isCurrent: true }] + app.selectedLocationId = 'y' + storm.alerts = [] + storm.reports = [] + storm.outlook = null + + instance = mount(StormWatch, { target }) + await tick() + + expect(target.textContent).toContain('SPC outlooks cover the United States only') + expect(target.textContent).toContain('Local Storm Reports cover the United States only') + }) +}) diff --git a/tests/lib/api/storm.test.js b/tests/lib/api/storm.test.js new file mode 100644 index 0000000..ce060c3 --- /dev/null +++ b/tests/lib/api/storm.test.js @@ -0,0 +1,237 @@ +import { describe, it, expect } from 'vitest' +import { + pointInRing, + pointInPolygon, + pointInGeometry, + haversineKm, + riskKey, + spcRiskAtPoint, + spcProbabilityAtPoint, + normalizeOutlook, + spcOutlookUrl, + isUsPoint, + reportIcon, + reportLabel, + normalizeStormReports, + normalizeActiveAlerts, + alertType, + isUrgentWarning, +} from '../../../src/lib/api/storm.js' + +// --- fixtures ------------------------------------------------------------- + +const box = (w, s, e, n) => [[[w, s], [e, s], [e, n], [w, n], [w, s]]] + +const fc = (features) => ({ type: 'FeatureCollection', features }) + +const catFeature = (label, geom) => ({ + type: 'Feature', + properties: { LABEL: label, ISSUE_ISO: '2026-10-17T16:14:00+00:00' }, + geometry: geom, +}) + +const lsrFeature = (props, lon, lat) => ({ + type: 'Feature', + properties: { type: 'Feature', ...props }, + geometry: { type: 'Point', coordinates: [lon, lat] }, +}) + +// --- tests ---------------------------------------------------------------- + +describe('storm geometry helpers', () => { + const square = box(-97, 35, -96, 36) + + it('pointInRing handles inside, outside and open rings', () => { + expect(pointInRing(-96.5, 35.5, square[0])).toBe(true) + expect(pointInRing(-97.5, 35.5, square[0])).toBe(false) + expect(pointInRing(-96.5, 36.5, square[0])).toBe(false) + expect(pointInRing(-96.5, 35.5, [])).toBe(false) + }) + + it('pointInPolygon excludes holes', () => { + const withHole = [box(-97, 35, -96, 36)[0], box(-96.8, 35.2, -96.6, 35.4)[0]] + expect(pointInPolygon(-96.9, 35.6, withHole)).toBe(true) // in the ring, outside the hole + expect(pointInPolygon(-96.7, 35.3, withHole)).toBe(false) // inside the hole + }) + + it('pointInGeometry supports Polygon and MultiPolygon', () => { + const poly = { type: 'Polygon', coordinates: square } + const multi = { + type: 'MultiPolygon', + coordinates: [box(-100, 30, -99, 31), box(-97, 35, -96, 36)], + } + expect(pointInGeometry(-96.5, 35.5, poly)).toBe(true) + expect(pointInGeometry(-99.5, 30.5, multi)).toBe(true) + expect(pointInGeometry(-98, 33, multi)).toBe(false) + expect(pointInGeometry(-96.5, 35.5, null)).toBe(false) + }) + + it('haversineKm is within a few km of the real distance', () => { + // Tulsa (36.154, -95.993) → Oklahoma City (35.468, -97.516) ≈ 157 km + const km = haversineKm(36.154, -95.993, 35.468, -97.516) + expect(km).toBeGreaterThan(150) + expect(km).toBeLessThan(165) + // Zero distance for the same point. + expect(haversineKm(36, -95, 36, -95)).toBeCloseTo(0, 5) + }) + + it('isUsPoint recognises CONUS, Alaska, Hawaii and rejects elsewhere', () => { + expect(isUsPoint(36.36, -95.81)).toBe(true) // Oklahoma + expect(isUsPoint(61.2, -149.9)).toBe(true) // Anchorage + expect(isUsPoint(21.3, -157.8)).toBe(true) // Honolulu + expect(isUsPoint(51.5, -0.12)).toBe(false) // London + // The CONUS band is deliberately wide, so southern Canada passes; the SPC + // polygons (not this check) decide whether a point actually has an outlook. + expect(isUsPoint(43.6, -79.4)).toBe(true) // Toronto + expect(isUsPoint(19.4, -99.1)).toBe(false) // Mexico City + }) +}) + +describe('SPC outlook parsing', () => { + it('riskKey maps SPC labels and ignores unknown ones', () => { + expect(riskKey('TSTM')).toBe('TSTM') + expect(riskKey(' slgt ')).toBe('SLGT') + expect(riskKey('HIGH')).toBe('HIGH') + expect(riskKey('Less Than 2% All Areas')).toBe('NONE') + expect(riskKey(undefined)).toBe('NONE') + }) + + it('spcRiskAtPoint returns the highest containing category', () => { + const data = fc([ + catFeature('TSTM', { type: 'Polygon', coordinates: box(-100, 30, -90, 40) }), + catFeature('ENH', { type: 'Polygon', coordinates: box(-97, 35, -96, 36) }), + catFeature('SLGT', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) }), + ]) + const inside = spcRiskAtPoint(-96.5, 35.5, data) + expect(inside.key).toBe('ENH') + expect(inside.short).toBe('ENH') + expect(inside.label).toBe('Enhanced risk') + + // Inside SLGT + TSTM but outside ENH. + expect(spcRiskAtPoint(-97.5, 36.5, data).key).toBe('SLGT') + + // Inside the TSTM box only. + expect(spcRiskAtPoint(-99, 39, data).key).toBe('TSTM') + + // Outside everything. + const none = spcRiskAtPoint(-80, 25, data) + expect(none.key).toBe('NONE') + expect(none.label).toBe('No outlook') + }) + + it('spcRiskAtPoint handles MultiPolygon and empty collections', () => { + const multi = catFeature('MDT', { + type: 'MultiPolygon', + coordinates: [box(-100, 30, -99, 31), box(-97, 35, -96, 36)], + }) + expect(spcRiskAtPoint(-96.5, 35.5, fc([multi])).key).toBe('MDT') + expect(spcRiskAtPoint(-96.5, 35.5, fc([])).key).toBe('NONE') + expect(spcRiskAtPoint(-96.5, 35.5, null).key).toBe('NONE') + }) + + it('spcProbabilityAtPoint takes the max and ignores the "less than" background', () => { + const data = fc([ + catFeature('Less Than 2% All Areas', { type: 'Polygon', coordinates: box(-130, 20, -60, 55) }), + catFeature('2%', { type: 'Polygon', coordinates: box(-100, 30, -90, 40) }), + catFeature('15%', { type: 'Polygon', coordinates: box(-97, 35, -96, 36) }), + ]) + expect(spcProbabilityAtPoint(-96.5, 35.5, data)).toBe(15) + expect(spcProbabilityAtPoint(-99, 39, data)).toBe(2) + expect(spcProbabilityAtPoint(-80, 25, data)).toBe(0) + expect(spcProbabilityAtPoint(-96.5, 35.5, null)).toBe(0) + }) + + it('normalizeOutlook resolves all three days plus day-1 probabilities', () => { + const src = { + cat1: fc([catFeature('SLGT', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) })]), + cat2: fc([catFeature('TSTM', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) })]), + cat3: null, + torn: fc([catFeature('5%', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) })]), + wind: fc([catFeature('15%', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) })]), + hail: fc([catFeature('30%', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) })]), + } + const out = normalizeOutlook(src, -96.5, 35.5) + expect(out.day1.key).toBe('SLGT') + expect(out.day2.key).toBe('TSTM') + expect(out.day3.key).toBe('NONE') + expect(out.probabilities).toEqual({ tornado: 5, wind: 15, hail: 30 }) + expect(out.issued).toBe('2026-10-17T16:14:00+00:00') + }) + + it('spcOutlookUrl builds the light no-label GeoJSON URLs', () => { + expect(spcOutlookUrl(1, 'cat')).toBe( + 'https://www.spc.noaa.gov/products/outlook/day1otlk_cat.nolyr.geojson' + ) + expect(spcOutlookUrl(3, 'hail')).toBe( + 'https://www.spc.noaa.gov/products/outlook/day3otlk_hail.nolyr.geojson' + ) + }) +}) + +describe('local storm reports', () => { + it('reportLabel and reportIcon map NWS type codes', () => { + expect(reportLabel('TORNADO')).toBe('Tornado') + expect(reportLabel('TSTM WND GST')).toBe('Thunderstorm wind gust') + expect(reportLabel('FLASH FLOOD')).toBe('Flash flood') + expect(reportLabel('HAIL')).toBe('Hail') + expect(reportIcon('TORNADO')).toBe('🌪️') + expect(reportIcon('TSTM WND DMG')).toBe('💨') + expect(reportIcon('HAIL')).toBe('🧊') + expect(reportIcon('FLASH FLOOD')).toBe('🌊') + }) + + it('normalizeStormReports filters by radius and sorts nearest first', () => { + const data = fc([ + lsrFeature({ typetext: 'HAIL', city: 'Far', county: 'X', state: 'OK', valid: '2026-10-17T10:00:00Z', magnitude: '1.75' }, -94.0, 36.3), + lsrFeature({ typetext: 'TORNADO', city: 'Near', county: 'Y', state: 'OK', valid: '2026-10-17T11:00:00Z', magnitude: '' }, -95.82, 36.36), + lsrFeature({ typetext: 'TSTM WND GST', city: 'Middle', county: 'Z', state: 'OK', valid: '2026-10-17T09:00:00Z', magnitude: '60' }, -96.5, 36.36), + lsrFeature({ typetext: 'HAIL', city: 'TooFar', county: 'W', state: 'KS', valid: '2026-10-17T09:00:00Z', magnitude: '2.00' }, -90.0, 40.0), + ]) + const out = normalizeStormReports(data, 36.36, -95.82, 100) + expect(out.map((r) => r.city)).toEqual(['Near', 'Middle']) + expect(out[0].label).toBe('Tornado') + expect(out[0].distanceKm).toBeLessThan(1) + expect(out[0].magnitude).toBeNull() + expect(out[1].magnitude).toBe('60') + expect(out[1].distanceKm).toBeGreaterThan(50) + }) +}) + +describe('NWS alert normalization', () => { + const alert = (props) => ({ + type: 'Feature', + properties: { status: 'Actual', messageType: 'Alert', ...props }, + geometry: { type: 'Polygon', coordinates: box(-96, 35, -95, 36) }, + }) + + it('alertType buckets events like the notification store does', () => { + expect(alertType('Tornado Warning')).toBe('storm') + expect(alertType('Severe Thunderstorm Watch')).toBe('storm') + expect(alertType('Flood Warning')).toBe('flood') + expect(alertType('Excessive Heat Warning')).toBe('heat') + expect(alertType('Red Flag Warning')).toBe('advisory') + }) + + it('isUrgentWarning flags tornado/severe warnings and Extreme alerts', () => { + expect(isUrgentWarning('Tornado Warning', 'Extreme')).toBe(true) + expect(isUrgentWarning('Severe Thunderstorm Warning', 'Severe')).toBe(true) + expect(isUrgentWarning('Flood Warning', 'Severe')).toBe(false) + expect(isUrgentWarning('Heat Advisory', 'Moderate')).toBe(false) + expect(isUrgentWarning('Special Weather Statement', 'Extreme')).toBe(true) + }) + + it('normalizeActiveAlerts sorts by severity, drops cancels, keeps fields', () => { + const data = fc([ + alert({ id: 'b', event: 'Flood Advisory', severity: 'Minor', ends: '2026-10-17T20:00:00Z' }), + alert({ id: 'a', event: 'Tornado Warning', severity: 'Extreme', ends: '2026-10-17T19:00:00Z', headline: 'Take cover now' }), + alert({ id: 'c', event: 'Heat Advisory', severity: 'Moderate', ends: '2026-10-18T00:00:00Z' }), + alert({ id: 'd', event: 'Old Warning', severity: 'Extreme', messageType: 'Cancel' }), + ]) + const out = normalizeActiveAlerts(data) + expect(out.map((a) => a.id)).toEqual(['a', 'c', 'b']) + expect(out[0].event).toBe('Tornado Warning') + expect(out[0].headline).toBe('Take cover now') + expect(out[0].type).toBe('storm') + expect(out[0].geometry.type).toBe('Polygon') + }) +}) diff --git a/tests/lib/stores/storm.test.js b/tests/lib/stores/storm.test.js new file mode 100644 index 0000000..ef45db8 --- /dev/null +++ b/tests/lib/stores/storm.test.js @@ -0,0 +1,194 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { StormStore, STORM_REFRESH_MS } from '../../../src/lib/stores/storm.svelte.js' +import { app } from '../../../src/lib/stores/app.svelte.js' +import { clearStormCache } from '../../../src/lib/api/storm.js' + +// ---- fixtures ------------------------------------------------------------ + +const box = (w, s, e, n) => [[[w, s], [e, s], [e, n], [w, n], [w, s]]] + +const catFc = (label) => ({ + type: 'FeatureCollection', + features: [{ + type: 'Feature', + properties: { LABEL: label, ISSUE_ISO: '2026-10-17T16:14:00+00:00' }, + geometry: { type: 'Polygon', coordinates: box(-100, 30, -90, 40) }, + }], +}) + +const probFc = (label) => ({ + type: 'FeatureCollection', + features: [{ + type: 'Feature', + properties: { LABEL: label }, + geometry: { type: 'Polygon', coordinates: box(-100, 30, -90, 40) }, + }], +}) + +const alertsFc = (features) => ({ type: 'FeatureCollection', features }) + +const alertFeature = (props) => ({ + type: 'Feature', + properties: { status: 'Actual', messageType: 'Alert', ...props }, + geometry: { type: 'Polygon', coordinates: box(-97, 35, -96, 36) }, +}) + +const lsrFc = (features) => ({ type: 'FeatureCollection', features }) + +const lsr = (city, lon, lat) => ({ + type: 'Feature', + properties: { + typetext: 'HAIL', city, county: 'X', state: 'OK', source: 'Trained Spotter', + valid: '2026-10-17T11:00:00Z', magnitude: '1.75', remark: 'quarter size', + }, + geometry: { type: 'Point', coordinates: [lon, lat] }, +}) + +// ---- fetch stub ---------------------------------------------------------- + +let calls +let overrides + +function installFetch() { + calls = [] + overrides = {} + globalThis.fetch = vi.fn(async (url) => { + const u = String(url) + calls.push(u) + for (const [match, handler] of Object.entries(overrides)) { + if (u.includes(match)) return handler(u) + } + const json = (body) => ({ ok: true, status: 200, json: async () => body }) + if (u.includes('api.weather.gov/alerts/active')) return json(alertsFc([])) + if (u.includes('day1otlk_cat')) return json(catFc('SLGT')) + if (u.includes('day2otlk_cat')) return json(catFc('MRGL')) + if (u.includes('day3otlk_cat')) return json(catFc('TSTM')) + if (u.includes('day1otlk_torn')) return json(probFc('5%')) + if (u.includes('day1otlk_wind')) return json(probFc('15%')) + if (u.includes('day1otlk_hail')) return json(probFc('30%')) + if (u.includes('mesonet.agron.iastate.edu')) return json(lsrFc([lsr('Near', -95.82, 36.36)])) + return { ok: false, status: 404, json: async () => ({}) } + }) +} + +function setLocation(lat, lon, name = 'Test') { + app.locations = [{ id: 'loc-1', name, lat, lon, isCurrent: true }] + app.selectedLocationId = 'loc-1' +} + +// ---- tests --------------------------------------------------------------- + +describe('StormStore', () => { + let store + + beforeEach(() => { + clearStormCache() + installFetch() + store = new StormStore() + setLocation(36.3567, -95.8146, 'Home') + }) + + afterEach(() => { + store.stop() + vi.restoreAllMocks() + }) + + it('loads alerts, outlook and reports for the selected location', async () => { + overrides['api.weather.gov/alerts/active'] = async () => ({ + ok: true, status: 200, + json: async () => alertsFc([ + alertFeature({ id: 't1', event: 'Tornado Warning', severity: 'Extreme', ends: '2026-10-17T19:00:00Z' }), + ]), + }) + + await store.watch({ force: true }) + + expect(store.alerts).toHaveLength(1) + expect(store.alerts[0].event).toBe('Tornado Warning') + expect(store.banner?.id).toBe('t1') + expect(store.outlook.day1.key).toBe('SLGT') + expect(store.outlook.day2.key).toBe('MRGL') + expect(store.outlook.probabilities).toEqual({ tornado: 5, wind: 15, hail: 30 }) + expect(store.reports).toHaveLength(1) + expect(store.reports[0].city).toBe('Near') + expect(store.loading).toBe(false) + expect(store.updatedAt).toBeGreaterThan(0) + expect(store.alertCount).toBe(1) + }) + + it('serves fresh data from the store/HTTP cache until it expires', async () => { + await store.watch({ force: true }) + const firstCount = calls.length + expect(firstCount).toBeGreaterThan(0) + + await store.watch() // store-level: data for this location is fresh + expect(calls.length).toBe(firstCount) + + await store.watch({ force: true }) // HTTP cache still warm + expect(calls.length).toBe(firstCount) + + clearStormCache() // simulate the TTLs expiring + await store.watch({ force: true }) + expect(calls.length).toBeGreaterThan(firstCount) + }) + + it('refetches when the selected location changes', async () => { + await store.watch({ force: true }) + const firstCount = calls.length + + setLocation(27.2, -82.3, 'Elsewhere') + await store.watch() + + const fresh = calls.slice(firstCount) + expect(fresh.length).toBeGreaterThan(0) + expect(fresh.some((u) => u.includes('36.3567'))).toBe(false) // old point not refetched + expect(fresh.some((u) => u.includes('27.2'))).toBe(true) // new point queried + }) + + it('keeps the other feeds when one request fails', async () => { + overrides['mesonet.agron.iastate.edu'] = async () => ({ + ok: false, status: 503, json: async () => ({}), + }) + + await store.watch({ force: true }) + + expect(store.reports).toEqual([]) + expect(store.outlook.day1.key).toBe('SLGT') + expect(store.error).toMatch(/reports/) + }) + + it('skips SPC/LSR requests outside the US and clears the outlook', async () => { + setLocation(51.5, -0.12, 'London') + await store.watch({ force: true }) + + expect(store.hasUsCoverage).toBe(false) + expect(store.outlook).toBeNull() + expect(store.reports).toEqual([]) + expect(calls.some((u) => u.includes('spc.noaa.gov'))).toBe(false) + expect(calls.some((u) => u.includes('api.weather.gov'))).toBe(true) + }) + + it('reset() drops data and the location key', async () => { + await store.watch({ force: true }) + store.reset() + expect(store.alerts).toEqual([]) + expect(store.outlook).toBeNull() + expect(store.reports).toEqual([]) + expect(store.updatedAt).toBe(0) + }) + + it('start() schedules a refresh at the documented interval', () => { + vi.useFakeTimers() + try { + const spy = vi.spyOn(store, 'watch').mockResolvedValue() + store.start() + store.start() // idempotent + vi.advanceTimersByTime(STORM_REFRESH_MS + 10) + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledWith({ force: true }) + store.stop() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/vite.config.js b/vite.config.js index bdfa993..6e21eb8 100644 --- a/vite.config.js +++ b/vite.config.js @@ -16,7 +16,7 @@ function getCommitHash() { } } -export default defineConfig(({ command }) => ({ +export default defineConfig(({ command, mode }) => ({ define: { __WEATHER_COMMIT__: JSON.stringify(getCommitHash()), }, @@ -44,6 +44,11 @@ export default defineConfig(({ command }) => ({ preview: { allowedHosts: ["dev.thecookiejar.me"] }, + // Vitest runs with mode 'test'; resolving the browser builds of Svelte is + // what makes client-side `mount()` available in component tests. Leave the + // build untouched — forcing conditions here breaks the production bundle + // (Svelte's server entry gets picked and mount() throws). + resolve: mode === 'test' ? { conditions: ['browser'] } : undefined, test: { environment: 'jsdom', globals: true,