feat(storms): NWS alerts, SPC outlook and IEM storm reports
All checks were successful
Test, Build & Deploy / test-and-build (push) Successful in 24s
Test, Build & Deploy / deploy (push) Successful in 18s

Adds a Storms tab driven by three keyless, CORS-enabled feeds:
- api.weather.gov/alerts/active?point=… for watches/warnings/advisories,
  with a banner above the content for tornado/severe/Extreme alerts
- SPC Day 1-3 categorical outlooks plus Day 1 tornado/hail/wind
  probabilities, resolved by point-in-polygon (nolyr GeoJSON)
- NWS Local Storm Reports via IEM (bounding box + radius cut), nearest first

New: src/lib/api/storm.js (pure helpers + cached fetchers),
src/lib/stores/storm.svelte.js (5-min polling, stale-response guard),
src/components/StormWatch.svelte. Router/nav gain a 'storms' view.
Tests: geometry/parsing (16), store (7), component render (3).
Docs: AGENTS.md storm section + API notes.
This commit is contained in:
hermes-explorigin 2026-10-17 18:10:46 +00:00
parent ae71c36acc
commit 4616c297a9
14 changed files with 3677 additions and 223 deletions

View File

@ -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.

17
dist/favicon.svg vendored
View File

@ -1,17 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="none" viewBox="0 0 48 48">
<!-- Sun circle -->
<circle cx="22" cy="22" r="12" fill="#fbbf24" />
<!-- Sun rays -->
<g stroke="#fbbf24" stroke-width="3" stroke-linecap="round">
<line x1="22" y1="4" x2="22" y2="9" />
<line x1="22" y1="35" x2="22" y2="40" />
<line x1="4" y1="22" x2="9" y2="22" />
<line x1="35" y1="22" x2="40" y2="22" />
<line x1="9.5" y1="9.5" x2="13" y2="13" />
<line x1="31" y1="31" x2="34.5" y2="34.5" />
<line x1="9.5" y1="34.5" x2="13" y2="31" />
<line x1="31" y1="13" x2="34.5" y2="9.5" />
</g>
<!-- Cloud overlay -->
<path d="M22 34 Q16 34 14 31 Q12 28 15 27 Q13 23 17 21 Q21 19 25 21 Q29 17 34 19 Q38 21 37 26 Q41 27 39 31 Q37 35 22 34Z" fill="#60a5fa" opacity="0.9" />
</svg>

Before

Width:  |  Height:  |  Size: 815 B

1
dist/icons.svg vendored
View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0" />

Before

Width:  |  Height:  |  Size: 82 B

1962
dist/index.html vendored

File diff suppressed because it is too large Load Diff

24
dist/pwa-icon.svg vendored
View File

@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0ea5e9" />
<stop offset="1" stop-color="#132744" />
</linearGradient>
</defs>
<rect x="0" y="0" width="512" height="512" rx="112" ry="112" fill="url(#bg)" />
<circle cx="256" cy="256" r="196" fill="#fbbf24" opacity="0.92" />
<g stroke="#fbbf24" stroke-width="12" stroke-linecap="round" opacity="0.9">
<line x1="256" y1="92" x2="256" y2="140" />
<line x1="256" y1="372" x2="256" y2="420" />
<line x1="92" y1="256" x2="140" y2="256" />
<line x1="372" y1="256" x2="420" y2="256" />
<line x1="142" y1="142" x2="184" y2="184" />
<line x1="328" y1="328" x2="370" y2="370" />
<line x1="142" y1="328" x2="184" y2="370" />
<line x1="328" y1="142" x2="370" y2="184" />
</g>
<path d="M256 332 Q214 332 196 306 Q184 276 192 256 Q188 232 214 218 Q206 204 238 196
Q246 190 262 194 Q268 178 296 190 Q300 208 314 216 Q328 226 326 238
Q342 246 344 268 Q348 288 336 300 Q330 318 316 330 Q310 344 256 332Z"
fill="#38bdf8" opacity="0.95" />
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,12 +1,14 @@
<script>
import { app } from './lib/stores/app.svelte.js'
import { notifications } from './lib/stores/notifications.svelte.js'
import { storm } from './lib/stores/storm.svelte.js'
import LocationSidebar from './components/LocationSidebar.svelte'
import CurrentWeather from './components/CurrentWeather.svelte'
import HourlyForecast from './components/HourlyForecast.svelte'
import DailyForecast from './components/DailyForecast.svelte'
import WeatherDetail from './components/WeatherDetail.svelte'
import PrecipitationRadar from './components/PrecipitationRadar.svelte'
import StormWatch from './components/StormWatch.svelte'
import NotificationBell from './components/NotificationBell.svelte'
import SettingsDialog from './components/SettingsDialog.svelte'
import AddLocationDialog from './components/AddLocationDialog.svelte'
@ -159,6 +161,10 @@
notifications.refreshAdvisories() // official NWS advisories (cached, best-effort)
}
// Storm feeds (alerts, SPC outlook, storm reports) for the location.
storm.watch()
storm.start()
initDone = true
}
@ -170,6 +176,14 @@
}
})
// Refresh storm data whenever the selected location changes. The store skips
// the fetch when it already holds fresh data for that point.
$effect(() => {
if (!initDone) return
if (app.selectedLocation) storm.watch()
else storm.reset()
})
// Restart the auto-refresh timer whenever the interval preference changes
$effect(() => {
app.settings.refreshInterval
@ -318,6 +332,22 @@
<!-- Weather content -->
{#if app.forecastData && app.selectedLocation}
<!-- Active tornado / severe-thunderstorm / extreme alert banner -->
{#if storm.banner}
<button
class="storm-banner animate-slide-down"
onclick={() => setView('storms')}
aria-label="{storm.banner.event} — open storm details"
>
<span class="storm-banner-icon">⚠️</span>
<span class="storm-banner-text">
<strong>{storm.banner.event}</strong>
<span class="storm-banner-area">{storm.banner.areaDesc.split(';').slice(0, 2).join(' · ')}</span>
</span>
<span class="storm-banner-cta">Details ›</span>
</button>
{/if}
<!-- Mobile bottom nav -->
<div class="mobile-nav">
<button
@ -345,6 +375,11 @@
class:active={viewMode === 'radar'}
onclick={() => setView('radar')}
>Radar</button>
<button
class="nav-btn"
class:active={viewMode === 'storms'}
onclick={() => setView('storms')}
>Storms</button>
</div>
<!-- Desktop tab bar -->
@ -354,6 +389,7 @@
<button class="tab-btn" class:active={viewMode === 'daily'} onclick={() => setView('daily')}>7-Day Forecast</button>
<button class="tab-btn" class:active={viewMode === 'detail'} onclick={() => setView('detail')}>Details</button>
<button class="tab-btn" class:active={viewMode === 'radar'} onclick={() => setView('radar')}>Radar</button>
<button class="tab-btn" class:active={viewMode === 'storms'} onclick={() => setView('storms')}>Storms</button>
</div>
<!-- Content area -->
@ -391,6 +427,10 @@
<div class="animate-fade-in">
<PrecipitationRadar />
</div>
{:else if viewMode === 'storms'}
<div class="animate-fade-in">
<StormWatch />
</div>
{/if}
</div>
</div>
@ -604,6 +644,55 @@
font-size: 1rem;
}
/* Active severe-alert banner */
.storm-banner {
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 { font-size: 1.1rem; }
.storm-banner-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
font-size: 0.82rem;
}
.storm-banner-area {
font-size: 0.72rem;
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.storm-banner-cta {
font-size: 0.75rem;
font-weight: 600;
color: #fca5a5;
white-space: nowrap;
}
@media (max-width: 1023px) {
.storm-banner {
margin: 6px 8px 0;
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }

View File

@ -0,0 +1,622 @@
<script>
import { app } from '../lib/stores/app.svelte.js'
import { storm } from '../lib/stores/storm.svelte.js'
const tz = $derived(app.forecastData?.timezone || undefined)
const alerts = $derived(storm.alerts)
const outlook = $derived(storm.outlook)
const reports = $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: 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: 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(new Date().toISOString())
const d = new Date(iso)
const tomorrow = localDay(new Date(Date.now() + 86400000).toISOString())
const time = clock(iso)
if (day === today) return `until ${time}`
if (day === tomorrow) return `until tomorrow ${time}`
const weekday = new Intl.DateTimeFormat('en-US', { timeZone: tz, weekday: 'short' }).format(d)
return `until ${weekday} ${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 / 60000)
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.settings.units === 'imperial') {
const mi = km * 0.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.settings.units === 'imperial'
? `${mph} mph`
: `${Math.round(mph * 1.609)} km/h`
: ''
}
return String(r.magnitude)
}
function outlookDay(dayKey) {
return outlook?.[dayKey] || null
}
function refresh() {
storm.watch({ force: true })
}
const issuedLabel = $derived(outlook?.issued ? clock(outlook.issued) : '')
</script>
<div class="storm-page">
<div class="storm-head">
<div class="storm-head-text">
<h3 class="storm-title">⛈️ Storm Watch</h3>
<p class="storm-sub">
{#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}
</p>
</div>
<button
class="btn-icon refresh-btn"
onclick={refresh}
disabled={storm.loading}
aria-label="Refresh storm data"
title="Refresh storm data"
>{storm.loading ? '⏳' : '↻'}</button>
</div>
{#if storm.error}
<div class="storm-error">{storm.error}</div>
{/if}
<!-- 1. Active NWS alerts ------------------------------------------------>
<section class="storm-card card" aria-label="Active weather alerts">
<div class="section-head">
<span class="section-icon">⚠️</span>
<span class="section-title">Active alerts</span>
{#if alerts.length}<span class="count-badge">{alerts.length}</span>{/if}
</div>
{#if alerts.length}
<ul class="alert-list">
{#each alerts as a (a.id)}
<li class="alert-item {SEVERITY_CLASS[a.severity] || 'sev-minor'}">
<div class="alert-row">
<span class="alert-event">{a.event}</span>
<span class="alert-until">{untilLabel(a.ends)}</span>
</div>
{#if a.headline}
<div class="alert-headline">{a.headline}</div>
{/if}
<details class="alert-details">
<summary>Details</summary>
{#if a.areaDesc}<div class="alert-area">{a.areaDesc}</div>{/if}
{#if a.description}<p class="alert-desc">{a.description}</p>{/if}
{#if a.instruction}<p class="alert-instruction">{a.instruction}</p>{/if}
<div class="alert-source">
{a.senderName || 'National Weather Service'} · expires {clock(a.expires)}
</div>
</details>
</li>
{/each}
</ul>
{:else if storm.loading && !storm.updatedAt}
<div class="empty-line">Checking for active alerts…</div>
{:else}
<div class="empty-line ok">✅ No active watches, warnings or advisories</div>
{/if}
</section>
<!-- 2. SPC severe-weather outlook --------------------------------------->
<section class="storm-card card" aria-label="SPC severe weather outlook">
<div class="section-head">
<span class="section-icon">🌪️</span>
<span class="section-title">Severe-weather outlook</span>
</div>
{#if outlook}
<div class="outlook-days">
{#each [['day1', 'Today'], ['day2', 'Tomorrow'], ['day3', 'Day 3']] as [key, label] (key)}
{@const r = outlookDay(key)}
{#if r}
<div
class="outlook-day"
class:risky={r.key !== 'NONE' && r.key !== 'TSTM'}
style={`--risk-color: ${r.color}`}
>
<span class="outlook-label">{label}</span>
<span class="outlook-chip">{r.short}</span>
<span class="outlook-desc">{r.label}</span>
</div>
{/if}
{/each}
</div>
{#if (outlook.probabilities?.tornado || outlook.probabilities?.wind || outlook.probabilities?.hail)}
<div class="prob-row">
<div class="prob">
<span class="prob-icon">🌪️</span>
<span class="prob-value">{outlook.probabilities.tornado}%</span>
<span class="prob-label">tornado</span>
</div>
<div class="prob">
<span class="prob-icon">💨</span>
<span class="prob-value">{outlook.probabilities.wind}%</span>
<span class="prob-label">wind</span>
</div>
<div class="prob">
<span class="prob-icon">🧊</span>
<span class="prob-value">{outlook.probabilities.hail}%</span>
<span class="prob-label">hail</span>
</div>
</div>
<div class="outlook-note">
Day 1 probability of severe weather within 25 miles of this point.
</div>
{:else if outlook.day1.key === 'NONE'}
<div class="empty-line">No severe-weather area covers this location right now.</div>
{/if}
{#if issuedLabel}
<div class="outlook-foot">SPC Day 1 outlook issued {issuedLabel} · updates ~4× daily</div>
{/if}
{:else if !storm.hasUsCoverage}
<div class="empty-line">SPC outlooks cover the United States only.</div>
{:else if storm.loading}
<div class="empty-line">Loading outlook…</div>
{:else}
<div class="empty-line">Outlook unavailable right now.</div>
{/if}
</section>
<!-- 3. Recent Local Storm Reports --------------------------------------->
<section class="storm-card card" aria-label="Recent storm reports">
<div class="section-head">
<span class="section-icon">📍</span>
<span class="section-title">Recent reports</span>
{#if reports.length}<span class="count-badge">{reports.length}</span>{/if}
</div>
{#if reports.length}
<div class="reports-sub">
Past 24 hours within {distance(160)} · nearest first
</div>
<ul class="report-list">
{#each reports.slice(0, maxReports) as r (r.id)}
<li class="report-item">
<span class="report-icon">{r.icon}</span>
<div class="report-body">
<div class="report-main">
<span class="report-type">{r.label}</span>
{#if magnitudeLabel(r)}<span class="report-mag">{magnitudeLabel(r)}</span>{/if}
</div>
<div class="report-place">
{[r.city, r.county && `${r.county} Co.`, r.state].filter(Boolean).join(', ')}
</div>
{#if r.remark}
<div class="report-remark">{r.remark}</div>
{/if}
</div>
<div class="report-meta">
<span>{distance(r.distanceKm)}</span>
<span class="report-ago">{ago(r.valid)}</span>
</div>
</li>
{/each}
</ul>
{#if reports.length > maxReports}
<div class="outlook-foot">+{reports.length - maxReports} more reports nearby</div>
{/if}
{:else if !storm.hasUsCoverage}
<div class="empty-line">Local Storm Reports cover the United States only.</div>
{:else if storm.loading && !storm.updatedAt}
<div class="empty-line">Loading recent reports…</div>
{:else}
<div class="empty-line ok">✅ No storm reports within {distance(160)} in the last 24 hours</div>
{/if}
<div class="outlook-foot">
Reports: NWS Local Storm Reports via Iowa Environmental Mesonet · Outlook: NOAA Storm Prediction Center
</div>
</section>
</div>
<style>
.storm-page {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 900px;
}
.storm-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 2px 2px 0;
}
.storm-title {
font-size: 1.05rem;
font-weight: 700;
}
.storm-sub {
font-size: 0.78rem;
color: var(--color-text-muted);
margin-top: 2px;
}
.refresh-btn {
font-size: 1.15rem;
}
.storm-error {
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 {
padding: 14px 16px;
}
.section-head {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 10px;
margin-bottom: 10px;
border-bottom: 1px solid var(--color-border);
}
.section-icon { font-size: 1rem; }
.section-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
flex: 1;
}
.count-badge {
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 {
font-size: 0.82rem;
color: var(--color-text-muted);
padding: 4px 0;
}
.empty-line.ok { color: var(--color-success); }
/* --- alerts --- */
.alert-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.alert-item {
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 { --sev-color: #ff2d55; --sev-bg: rgba(255, 45, 85, 0.12); }
.sev-severe { --sev-color: #ef4444; --sev-bg: rgba(239, 68, 68, 0.1); }
.sev-moderate { --sev-color: #f59e0b; --sev-bg: rgba(245, 158, 11, 0.1); }
.sev-minor { --sev-color: #38bdf8; --sev-bg: rgba(56, 189, 248, 0.07); }
.alert-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.alert-event {
font-size: 0.88rem;
font-weight: 700;
color: var(--color-text);
}
.alert-until {
font-size: 0.72rem;
color: var(--color-text-secondary);
white-space: nowrap;
}
.alert-headline {
font-size: 0.78rem;
color: var(--color-text-secondary);
margin-top: 3px;
}
.alert-details {
margin-top: 6px;
}
.alert-details summary {
font-size: 0.72rem;
color: var(--color-primary);
cursor: pointer;
width: fit-content;
list-style: none;
}
.alert-details summary::-webkit-details-marker { display: none; }
.alert-details summary::before { content: '▸ '; }
.alert-details[open] summary::before { content: '▾ '; }
.alert-area,
.alert-desc,
.alert-instruction {
font-size: 0.76rem;
color: var(--color-text-secondary);
margin-top: 6px;
white-space: pre-wrap;
line-height: 1.45;
}
.alert-instruction {
color: var(--color-text);
border-left: 2px solid var(--sev-color, var(--color-border));
padding-left: 8px;
}
.alert-source {
font-size: 0.7rem;
color: var(--color-text-muted);
margin-top: 6px;
}
/* --- outlook --- */
.outlook-days {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.outlook-day {
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 {
border-color: var(--risk-color);
background: color-mix(in srgb, var(--risk-color) 14%, transparent);
}
.outlook-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
}
.outlook-chip {
font-size: 0.95rem;
font-weight: 800;
letter-spacing: 0.02em;
color: var(--risk-color, var(--color-text));
}
.outlook-desc {
font-size: 0.68rem;
color: var(--color-text-secondary);
text-align: center;
line-height: 1.25;
}
.prob-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 12px;
}
.prob {
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 { font-size: 0.95rem; }
.prob-value {
font-size: 0.95rem;
font-weight: 700;
}
.prob-label {
font-size: 0.68rem;
color: var(--color-text-muted);
}
.outlook-note,
.reports-sub,
.outlook-foot {
font-size: 0.7rem;
color: var(--color-text-muted);
margin-top: 8px;
}
.outlook-foot {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--color-border);
}
/* --- reports --- */
.report-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 2px;
}
.report-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid rgba(30, 58, 102, 0.6);
}
.report-item:last-child { border-bottom: none; }
.report-icon {
font-size: 1rem;
line-height: 1.3;
}
.report-body {
flex: 1;
min-width: 0;
}
.report-main {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.report-type {
font-size: 0.85rem;
font-weight: 600;
}
.report-mag {
font-size: 0.75rem;
color: var(--color-warning);
font-weight: 600;
}
.report-place {
font-size: 0.75rem;
color: var(--color-text-secondary);
margin-top: 1px;
}
.report-remark {
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 {
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 {
color: var(--color-text-muted);
}
@media (max-width: 480px) {
.outlook-desc { display: none; }
.storm-card { padding: 12px; }
}
</style>

448
src/lib/api/storm.js Normal file
View File

@ -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)
}

View File

@ -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'
/**

View File

@ -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()

View File

@ -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')
})
})

237
tests/lib/api/storm.test.js Normal file
View File

@ -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')
})
})

View File

@ -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()
}
})
})

View File

@ -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,