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(``);
+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(``);
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(``);
+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(``);
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(``);
-var root_1$6 = /* @__PURE__ */ from_html(``);
-var root_2$6 = /* @__PURE__ */ from_html(``);
-var root_3$4 = /* @__PURE__ */ from_html(``);
+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(``);
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 Speed
Direction
Gusts
Condition
Humidity
Pressure
Feels Like
Is Day
UV Index
Sunrise
Sunset
Max UV Today
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 Speed
Direction
Gusts
Condition
Humidity
Pressure
Feels Like
Is Day
UV Index
Sunrise
Sunset
Max UV Today
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(` Retry
`);
-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(` Retry
`);
-var root_6$2 = /* @__PURE__ */ from_html(``);
-var root_7$1 = /* @__PURE__ */ from_html(`No radar data available.
Reload `);
-var root_8 = /* @__PURE__ */ from_html(` `, 1);
-var root_9 = /* @__PURE__ */ from_html(``);
+var root$5 = /* @__PURE__ */ from_html(` Retry
`);
+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(` Retry
`);
+var root_6$3 = /* @__PURE__ */ from_html(``);
+var root_7$2 = /* @__PURE__ */ from_html(`No radar data available.
Reload `);
+var root_8$2 = /* @__PURE__ */ from_html(` `, 1);
+var root_9$2 = /* @__PURE__ */ from_html(``);
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(` 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(` 🌪️ Severe-weather outlook
`);
+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
+ Add a Location `);
var root_4 = /* @__PURE__ */ from_html(`⏳
Loading weather data...
`);
-var root_5 = /* @__PURE__ */ from_html(`
`);
-var root_6 = /* @__PURE__ */ from_html(`Now Hourly Daily More Radar
Current Hourly 7-Day Forecast Details Radar
`, 1);
-var root_7 = /* @__PURE__ */ from_html(``);
+var root_5 = /* @__PURE__ */ from_html(``);
+var root_6 = /* @__PURE__ */ from_html(`⚠️ Details › `);
+var root_7 = /* @__PURE__ */ from_html(`
`);
+var root_8 = /* @__PURE__ */ from_html(` Now Hourly Daily More Radar Storms
Current Hourly 7-Day Forecast Details Radar Storms
`, 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}
+
+
+
{storm.loading ? '⏳' : '↻'}
+
+
+ {#if storm.error}
+
{storm.error}
+ {/if}
+
+
+
+
+ ⚠️
+ Active alerts
+ {#if alerts.length}{alerts.length} {/if}
+
+
+ {#if alerts.length}
+
+ {: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}
+
+ {/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
+
+
+ {#if reports.length > maxReports}
+
+ {/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}
+
+
+
+
+
+
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,