weather_app/src/App.svelte
hermes-explorigin 4616c297a9
All checks were successful
Test, Build & Deploy / test-and-build (push) Successful in 24s
Test, Build & Deploy / deploy (push) Successful in 18s
feat(storms): NWS alerts, SPC outlook and IEM storm reports
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.
2026-10-17 18:10:46 +00:00

846 lines
22 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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'
import { parseHash, viewToHash, VALID_VIEWS } from './lib/router.js'
// Hash-routable tab: initialize from the URL hash so a shared link like
// #/radar opens straight to that tab.
let viewMode = $state(parseHash(typeof window !== 'undefined' ? window.location.hash : ''))
/** Switch the active tab and reflect it in the URL hash. */
function setView(v) {
viewMode = v
if (typeof window !== 'undefined') {
window.location.hash = viewToHash(v)
}
}
// Keep viewMode in sync with the hash — covers browser back/forward and a
// manually edited URL. Guarded so we never re-enter on the same value.
$effect(() => {
if (typeof window === 'undefined') return
const onHash = () => {
const v = parseHash(window.location.hash)
if (v !== viewMode) viewMode = v
}
window.addEventListener('hashchange', onHash)
return () => window.removeEventListener('hashchange', onHash)
})
// --- Mobile swipe-to-switch tabs (live drag + visual feedback) ---
// Horizontal swipe on the content area moves between tabs (touch only). The
// content translates with the finger in real time, showing a "peek" chip of
// the destination, then animates to the next tab (or snaps back) on release.
// Gated to non-radar views: the radar map handles its own pan gestures via
// pointer events, so a swipe there should pan the map, not switch tabs.
const SWIPE_THRESHOLD = 70 // px of horizontal travel before it counts as a swipe
const AXIS_THRESHOLD = 12 // travel before we decide horizontal vs vertical
const SWIPE_TIMEOUT = 160 // ms for the snap-back / snap-forward animation
let contentEl = $state(null) // bound to .content-area, gives travel distance
let swiping = $state(false) // a touch drag is in progress
let swipeAnchor = $state(null) // { x, y } of the starting touch
let swipeDX = $state(0) // live horizontal travel in px (drives the transform)
let swipeLock = $state(null) // 'x' | 'y' | null once the axis is decided
let settling = $state(false) // true while animating on release, enables CSS transition
const clampIdx = (i) => Math.min(Math.max(i, 0), VALID_VIEWS.length - 1)
// The destination view to hint at as the finger drags (only after we've
// committed to a horizontal swipe, so vertical scrolling shows nothing).
function onSwipeStart(e) {
if (viewMode === 'radar' || !e.touches || !e.touches[0]) return
const t = e.touches[0]
swipeAnchor = { x: t.clientX, y: t.clientY }
swipeDX = 0
swipeLock = null
settling = false
swiping = true
}
function onSwipeMove(e) {
if (!swiping || !swipeAnchor) return
const t = e.touches[0]
const dx = t.clientX - swipeAnchor.x
const dy = t.clientY - swipeAnchor.y
// Decide the axis the first time we travel far enough; once locked to Y we
// leave default scrolling alone and never pop the peek nipple.
if (!swipeLock) {
if (Math.abs(dx) > AXIS_THRESHOLD && Math.abs(dx) > Math.abs(dy)) swipeLock = 'x'
else if (Math.abs(dy) > AXIS_THRESHOLD) swipeLock = 'y'
}
if (swipeLock === 'x') {
e.preventDefault() // stop the page from scrolling while we drag sideways
swipeDX = dx
} else if (swipeLock === 'y') {
swipeDX = 0 // let vertical scroll pass through
}
}
function finishSwipe(e) {
if (!swiping) return
const t = e.changedTouches && e.changedTouches[0]
const dx = swipeLock === 'x' ? (t ? t.clientX - swipeAnchor.x : swipeDX) : swipeDX
const dy = t ? t.clientY - swipeAnchor.y : 0
const horizontal = swipeLock === 'x' && Math.abs(dx) >= SWIPE_THRESHOLD && Math.abs(dx) >= Math.abs(dy)
if (horizontal && viewMode !== 'radar') {
const idx = VALID_VIEWS.indexOf(viewMode)
const step = dx < 0 ? 1 : -1 // left swipe = next, right = previous
const next = VALID_VIEWS[clampIdx(idx + step)]
if (next !== viewMode) {
// Animate the current sheet the rest of the way off-screen, then swap.
settling = true
swipeDX = (contentEl ? contentEl.clientWidth : 420) * step * -1
setTimeout(() => {
setView(next)
swiping = false
swipeDX = 0
swipeLock = null
swipeAnchor = null
settling = false
}, SWIPE_TIMEOUT)
return
}
}
// Not a switch: ease the sheet back to rest.
settling = true
swipeDX = 0
setTimeout(() => {
swiping = false
swipeLock = null
swipeAnchor = null
settling = false
}, SWIPE_TIMEOUT)
}
function cancelSwipe(e) {
if (!swiping) return
settling = true
swipeDX = 0
setTimeout(() => {
swiping = false
swipeLock = null
swipeAnchor = null
settling = false
}, SWIPE_TIMEOUT)
}
const slideStyle = $derived(swipeDX ? `transform: translateX(${swipeDX}px)` : '')
let initDone = $state(false)
let geoError = $state('')
// Initialize app on mount
$effect(() => {
initApp()
})
async function initApp() {
await app.init()
// If no locations exist, try to geolocate
if (app.locations.length === 0) {
await tryGeolocation()
}
// Trigger alert analysis after initial forecast
if (app.forecastData) {
notifications.analyze()
notifications.refreshAdvisories() // official NWS advisories (cached, best-effort)
}
// Storm feeds (alerts, SPC outlook, storm reports) for the location.
storm.watch()
storm.start()
initDone = true
}
// Re-analyze alerts whenever forecast data changes
$effect(() => {
if (app.forecastData && app.settings.alertsEnabled) {
notifications.analyze()
notifications.refreshAdvisories() // official NWS advisories (cached, best-effort)
}
})
// 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
app.startAutoRefresh()
})
async function tryGeolocation() {
if (!navigator.geolocation) {
geoError = 'Geolocation not supported. Add a location to get started.'
return
}
try {
const position = await new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 600000, // 10 minutes
})
})
const { latitude, longitude } = position.coords
const { addLocation, setCurrentLocation } = await import('./lib/storage/db.js')
const id = await addLocation({
name: 'Current Location',
lat: Math.round(latitude * 10000) / 10000,
lon: Math.round(longitude * 10000) / 10000,
isCurrent: true,
order: 0,
})
await setCurrentLocation(id)
await app.refreshLocations()
app.selectedLocationId = id
await app.refreshForecast()
} catch (e) {
geoError = 'Location access denied. Add a location manually.'
}
}
async function handleAddLocation(location) {
const { addLocation } = await import('./lib/storage/db.js')
await addLocation({
name: location.name,
lat: location.latitude,
lon: location.longitude,
isCurrent: false,
})
await app.refreshLocations()
app.showAddLocation = false
app.geocodingResults = []
// Select the newly added location
const updated = await import('./lib/storage/db.js').then(m => m.getLocations())
const newest = updated[updated.length - 1]
if (newest) {
app.selectLocation(newest.id)
}
}
</script>
<div class="app-shell">
<!-- Mobile header -->
<div class="mobile-header">
<button
class="btn-icon"
onclick={() => app.sidebarOpen = !app.sidebarOpen}
aria-label="Toggle menu"
>
{app.sidebarOpen ? '✕' : '☰'}
</button>
<span class="mobile-title">
{app.selectedLocation?.name || 'WeatherLens'}
</span>
<button class="btn-icon" onclick={() => app.showSettings = true} aria-label="Settings" title="Settings">
⚙️
</button>
<NotificationBell />
</div>
<!-- Sidebar overlay for mobile -->
{#if app.sidebarOpen}
<button
class="sidebar-overlay"
onclick={() => app.sidebarOpen = false}
aria-label="Close sidebar"
></button>
{/if}
<!-- Sidebar -->
<aside class="sidebar" class:open={app.sidebarOpen}>
<LocationSidebar />
</aside>
<!-- Main content -->
<main class="main-content">
<!-- Desktop header -->
<div class="top-bar">
<div class="top-bar-title">
<h1>{app.selectedLocation?.name || 'WeatherLens'}</h1>
{#if app.selectedLocation?.isCurrent}
<span class="current-badge">📍 Current</span>
{/if}
</div>
<div class="top-bar-actions">
<NotificationBell />
</div>
</div>
<!-- Error banner -->
{#if app.error}
<div class="error-banner animate-slide-down">
<span>⚠️ {app.error}</span>
<button class="btn-icon" onclick={() => app.error = ''} aria-label="Dismiss">✕</button>
</div>
{/if}
<!-- Geolocation error on first load -->
{#if geoError && !app.forecastData}
<div class="empty-state">
<div class="empty-icon">🌍</div>
<h2>Welcome to WeatherLens</h2>
<p class="text-muted">{geoError}</p>
<button class="btn btn-primary mt-3" onclick={() => app.showAddLocation = true}>+ Add a Location</button>
</div>
{/if}
<!-- Loading state -->
{#if app.loading && !app.forecastData}
<div class="loading-state">
<div class="loading-spinner">⏳</div>
<p class="text-muted mt-2">Loading weather data...</p>
</div>
{/if}
<!-- Non-blocking refresh indicator (location switch while data is shown) -->
{#if app.loading && app.forecastData}
<div class="refreshing-overlay" aria-live="polite" aria-busy="true">
<div class="refreshing-chip">
<span class="loading-spinner small">⏳</span>
<span>Updating…</span>
</div>
</div>
{/if}
<!-- 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
class="nav-btn"
class:active={viewMode === 'current'}
onclick={() => setView('current')}
>Now</button>
<button
class="nav-btn"
class:active={viewMode === 'hourly'}
onclick={() => setView('hourly')}
>Hourly</button>
<button
class="nav-btn"
class:active={viewMode === 'daily'}
onclick={() => setView('daily')}
>Daily</button>
<button
class="nav-btn"
class:active={viewMode === 'detail'}
onclick={() => setView('detail')}
>More</button>
<button
class="nav-btn"
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 -->
<div class="desktop-tabs">
<button class="tab-btn" class:active={viewMode === 'current'} onclick={() => setView('current')}>Current</button>
<button class="tab-btn" class:active={viewMode === 'hourly'} onclick={() => setView('hourly')}>Hourly</button>
<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 -->
<div
class="content-area"
class:swiping={swiping}
bind:this={contentEl}
ontouchstart={onSwipeStart}
ontouchmove={onSwipeMove}
ontouchend={finishSwipe}
ontouchcancel={cancelSwipe}
>
<div
class="swipe-layer"
class:settle={settling}
style={slideStyle}
>
{#if viewMode === 'current'}
<div class="animate-fade-in">
<CurrentWeather />
</div>
{:else if viewMode === 'hourly'}
<div class="animate-fade-in">
<HourlyForecast />
</div>
{:else if viewMode === 'daily'}
<div class="animate-fade-in">
<DailyForecast />
</div>
{:else if viewMode === 'detail'}
<div class="animate-fade-in">
<WeatherDetail />
</div>
{:else if viewMode === 'radar'}
<div class="animate-fade-in">
<PrecipitationRadar />
</div>
{:else if viewMode === 'storms'}
<div class="animate-fade-in">
<StormWatch />
</div>
{/if}
</div>
</div>
{/if}
</main>
<!-- Settings dialog -->
{#if app.showSettings}
<SettingsDialog onClose={() => app.showSettings = false} />
{/if}
<!-- Add location dialog -->
{#if app.showAddLocation}
<AddLocationDialog
onClose={() => {
app.showAddLocation = false
app.geocodingResults = []
}}
onAdd={handleAddLocation}
/>
{/if}
</div>
<style>
.app-shell {
display: flex;
min-height: 100vh;
min-height: 100dvh;
max-width: 100vw;
overflow-x: hidden;
}
/* Mobile header */
.mobile-header {
display: none;
align-items: center;
justify-content: space-between;
padding: max(8px, var(--safe-top)) 8px 8px max(12px, var(--safe-top));
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: 100;
}
.mobile-title {
font-weight: 700;
font-size: 0.95rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
margin: 0 8px;
}
/* Sidebar */
.sidebar {
width: 280px;
min-width: 280px;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
height: 100vh;
height: 100dvh;
position: sticky;
top: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
z-index: 60;
}
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 59;
border: none;
cursor: pointer;
padding: 0;
margin: 0;
width: 100%;
height: 100%;
}
/* Main content */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 100vh;
min-height: 100dvh;
}
.top-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: 50;
}
.top-bar-title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.top-bar-title h1 {
font-size: 1.1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.current-badge {
font-size: 0.75rem;
background: rgba(56, 189, 248, 0.15);
color: var(--color-primary);
padding: 2px 8px;
border-radius: 12px;
white-space: nowrap;
}
.top-bar-actions {
display: flex;
gap: 6px;
align-items: center;
}
/* Error banner */
.error-banner {
display: flex;
align-items: center;
justify-content: space-between;
margin: 8px 16px;
padding: 10px 14px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-md);
color: #fca5a5;
font-size: 0.85rem;
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 40px 20px;
text-align: center;
}
.empty-icon {
font-size: 4rem;
margin-bottom: 16px;
}
.empty-state h2 {
margin-bottom: 8px;
}
/* Loading state */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 60px 20px;
}
.loading-spinner {
font-size: 3rem;
animation: spin 1.5s linear infinite;
}
/* Non-blocking "Updating…" chip shown while a location change refetches. */
.refreshing-overlay {
position: sticky;
top: 8px;
z-index: 45;
display: flex;
justify-content: center;
pointer-events: none;
}
.refreshing-chip {
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 {
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); }
}
/* Desktop tabs */
.desktop-tabs {
display: flex;
gap: 0;
padding: 0 20px;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
}
.tab-btn {
padding: 10px 16px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--color-text-muted);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: color var(--transition), border-color var(--transition);
}
.tab-btn:hover {
color: var(--color-text-secondary);
}
.tab-btn.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
/* Mobile bottom nav */
.mobile-nav {
display: none;
position: sticky;
top: 52px;
z-index: 90;
padding: 4px 12px;
gap: 2px;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
}
.nav-btn {
flex: 1;
padding: 8px 4px;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-muted);
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
transition: background-color var(--transition), color var(--transition);
white-space: nowrap;
text-align: center;
}
.nav-btn.active {
background: rgba(56, 189, 248, 0.12);
color: var(--color-primary);
}
.content-area {
flex: 1;
padding: 16px;
overflow-y: auto;
overflow-x: hidden;
min-width: 0;
}
.swipe-layer {
position: relative;
min-height: 100%;
will-change: transform;
touch-action: pan-y;
}
.swipe-layer.settle {
transition: transform 160ms ease-out;
}
.content-area.swiping {
cursor: grabbing;
}
/* Responsive */
@media (max-width: 1023px) {
.app-shell {
flex-direction: column;
}
.mobile-header {
display: flex;
}
.top-bar {
display: none;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 60;
display: none;
transition: none;
width: min(300px, 85vw);
min-width: 0;
height: 100dvh;
padding-top: var(--safe-top);
}
.sidebar.open {
display: flex;
}
.sidebar-overlay {
display: block;
}
.desktop-tabs {
display: none;
}
.mobile-nav {
display: flex;
}
.content-area {
padding: 8px 8px max(16px, var(--safe-bottom));
}
.error-banner {
margin: 4px 8px;
}
}
@media (min-width: 1024px) {
.mobile-nav {
display: none;
}
}
</style>