First pass
This commit is contained in:
commit
4ffb44e107
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
97
AGENTS.md
Normal file
97
AGENTS.md
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
# WeatherLens — Agent Context
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
A single-file, offline-capable weather app built with **Svelte 5** (runes-based reactivity). Pulls data from the free **Open-Meteo API** (no API key required), persists user locations and settings in IndexedDB, and provides configurable weather alerts. The production build is a single `dist/index.html` with zero network dependencies — it works from `file://`, USB, or any static server. Weather data is fetched live from the CDN.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── App.svelte # Root component — geolocation init, sidebar + main layout
|
||||||
|
├── main.js # Entry point, mounts App + imports CSS
|
||||||
|
├── components/
|
||||||
|
│ ├── LocationSidebar.svelte # Saved locations list, inline geocoding search
|
||||||
|
│ ├── AddLocationDialog.svelte# Modal: geocoding search → add location
|
||||||
|
│ ├── CurrentWeather.svelte # Hero card: current conditions, dynamic gradient bg
|
||||||
|
│ ├── HourlyForecast.svelte # Horizontal scroll: next 24h with precip bars
|
||||||
|
│ ├── 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
|
||||||
|
│ └── 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)
|
||||||
|
│ │ └── 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
|
||||||
|
│ └── notifications.svelte.js # Alert generation from forecast data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
- **Svelte 5 runes** — Use `$state`, `$derived`, `$effect`. Props-based event passing. No legacy Svelte events.
|
||||||
|
- **$state proxy pitfall** — Svelte 5 `$state` wraps values in JavaScript Proxy objects. Any API that uses the structured clone algorithm (IndexedDB `put()`/`add()`, `structuredClone()`, `postMessage()`, `history.pushState()`) will throw `"Proxy object could not be cloned"` if given a `$state` value directly. To avoid this:
|
||||||
|
- **Never pass `$state` objects directly to IndexedDB.** Always spread into a plain object first: `db.put('store', { ...proxyObj })`.
|
||||||
|
- **Never use `structuredClone()` on `$state` values.** Use `{ ...obj, nested: { ...obj.nested } }` or `JSON.parse(JSON.stringify(obj))` for deep copies.
|
||||||
|
- **DB functions should sanitize inputs** at the storage layer: `JSON.parse(JSON.stringify(settings))` before `db.put()`.
|
||||||
|
- **Reading from IndexedDB returns plain objects** (no proxies), so reads are safe.
|
||||||
|
- **Open-Meteo API** — Free forecast API (no key required). Two endpoints used:
|
||||||
|
- `api.open-meteo.com/v1/forecast` — Current + hourly (24h) + daily (7 days) weather
|
||||||
|
- `geocoding-api.open-meteo.com/v1/search` — City/location search by name
|
||||||
|
- **Weather state via emoji** — All weather icons use Unicode emoji, so no icon fonts or image assets are needed. WMO codes map to emoji via `weather-codes.js`.
|
||||||
|
- **Dynamic backgrounds** — CurrentWeather uses CSS gradient classes (`weather-bg-clear`, `weather-bg-rain`, `weather-bg-storm`, etc.) determined by weather severity.
|
||||||
|
- **Alert/notification system** — `notifications.svelte.js` analyzes forecast data each time it changes and generates alerts for: high precipitation, thunderstorms, extreme temps, high UV, strong winds. Alerts are shown only in the 🔔 bell dropdown. Dismissed alerts persist in IndexedDB and only auto-reset when the forecast date changes (so refreshing the same day won't resurrect cleared alerts).
|
||||||
|
- **Geolocation on first launch** — If no locations are saved, the app requests browser geolocation and adds "Current Location" automatically. Falls back to manual "Add Location" if denied.
|
||||||
|
- **Locations persist in IndexedDB** — Via `idb`. Two stores: `locations` and `settings`. Current location pinned at top of sidebar.
|
||||||
|
- **Single-file build** — `vite-plugin-singlefile` inlines all JS/CSS; post-build script inlines favicon as base64 data URI.
|
||||||
|
- **Mobile-first responsive** — Slide-over sidebar on mobile (same pattern as password manager). Bottom tab nav on mobile, desktop tab bar on wide screens. Touch-friendly with large tap targets.
|
||||||
|
- **No minification** — `minify: false` in vite.config.js for readable single-file output.
|
||||||
|
|
||||||
|
## Weather Alert System
|
||||||
|
|
||||||
|
The notification store checks each forecast refresh for:
|
||||||
|
|
||||||
|
| Alert Type | Trigger | Severity |
|
||||||
|
|---|---|---|
|
||||||
|
| Thunderstorm (current) | WMO code 65, 75, 82, 86, 95-99 active now | danger |
|
||||||
|
| Thunderstorm (forecast) | WMO code 65, 75, 82, 86, 95-99 in next 3 days | danger |
|
||||||
|
| Precipitation | precipitation_probability_max ≥ threshold (default 70%) | warning |
|
||||||
|
| High Temperature | temperature_2m_max > threshold (default 35°C) | warning |
|
||||||
|
| Low Temperature | temperature_2m_min < threshold (default 0°C) | warning |
|
||||||
|
| UV Index | uv_index_max ≥ threshold (default 6) | warning |
|
||||||
|
| Wind | wind_speed_10m_max ≥ threshold (default 40 km/h) | warning |
|
||||||
|
|
||||||
|
All thresholds are configurable in Settings.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `npm run dev` | Vite dev server with HMR on `:5173` |
|
||||||
|
| `npm run build` | Production build → `dist/index.html` (single self-contained file) |
|
||||||
|
| `npm run preview` | Preview production build |
|
||||||
|
| `npm run test` | Vitest (watch mode) |
|
||||||
|
| `npm run test:run` | Vitest (single run) |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Framework: **Vitest** with **jsdom** environment
|
||||||
|
- Test setup: `tests/setup.js` (fake-indexeddb polyfill, Web Crypto API polyfill, mock matchMedia, mock navigator)
|
||||||
|
- Test files:
|
||||||
|
- `tests/lib/api/weather-codes.test.js` — WMO code → icon/desc/severity mapping, isSevereWeather
|
||||||
|
- `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
|
||||||
|
- Run with `npm run test` or `npm run test:run`
|
||||||
|
|
||||||
|
## API Notes
|
||||||
|
|
||||||
|
- Open-Meteo is free and requires no API key.
|
||||||
|
- Forecast parameters: current (10 metrics), hourly (4 metrics × time), daily (8 metrics × 7 days)
|
||||||
|
- 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.
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>WeatherLens</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
16
jsconfig.json
Normal file
16
jsconfig.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"checkJs": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
|
||||||
|
}
|
||||||
2671
package-lock.json
generated
Normal file
2671
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "weather-app",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"sideEffects": [
|
||||||
|
"src/lib/stores/app.svelte.js",
|
||||||
|
"src/lib/stores/notifications.svelte.js",
|
||||||
|
"src/styles/main.css"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build && node scripts/inline-assets.js",
|
||||||
|
"preview": "vite preview --host 0.0.0.0",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
"esbuild": "^0.28.0",
|
||||||
|
"fake-indexeddb": "^6.2.5",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
|
"svelte": "^5.55.5",
|
||||||
|
"vite": "^8.0.12",
|
||||||
|
"vite-plugin-singlefile": "^2.3.3",
|
||||||
|
"vitest": "^4.1.6"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"idb": "^8.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
public/favicon.svg
Normal file
17
public/favicon.svg
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 815 B |
1
public/icons.svg
Normal file
1
public/icons.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0" />
|
||||||
|
After Width: | Height: | Size: 82 B |
47
scripts/inline-assets.js
Normal file
47
scripts/inline-assets.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Post-build script: inline remaining external assets (favicon) into index.html
|
||||||
|
* and remove leftover files so only a single HTML file remains.
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync, rmSync, existsSync } from 'fs'
|
||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const distDir = join(__dirname, '..', 'dist')
|
||||||
|
|
||||||
|
// Read favicon SVG and encode as data URI
|
||||||
|
const faviconPath = join(distDir, 'favicon.svg')
|
||||||
|
if (existsSync(faviconPath)) {
|
||||||
|
const svgContent = readFileSync(faviconPath, 'utf8')
|
||||||
|
const encoded = Buffer.from(svgContent).toString('base64')
|
||||||
|
const dataUri = `data:image/svg+xml;base64,${encoded}`
|
||||||
|
|
||||||
|
// Replace the favicon link in index.html
|
||||||
|
const indexPath = join(distDir, 'index.html')
|
||||||
|
let html = readFileSync(indexPath, 'utf8')
|
||||||
|
html = html.replace(
|
||||||
|
/<link rel="icon"[^>]*href="[^"]*favicon\.svg"[^>]*\/?>/i,
|
||||||
|
`<link rel="icon" type="image/svg+xml" href="${dataUri}" />`
|
||||||
|
)
|
||||||
|
writeFileSync(indexPath, html)
|
||||||
|
|
||||||
|
// Remove the standalone SVG
|
||||||
|
rmSync(faviconPath)
|
||||||
|
console.log('[inline-assets] Inlined favicon.svg into index.html')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any other leftover asset files (e.g. icons.svg from Svelte compiler)
|
||||||
|
const iconsPath = join(distDir, 'icons.svg')
|
||||||
|
if (existsSync(iconsPath)) {
|
||||||
|
rmSync(iconsPath)
|
||||||
|
console.log('[inline-assets] Removed icons.svg')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove assets directory if it exists
|
||||||
|
const assetsDir = join(distDir, 'assets')
|
||||||
|
if (existsSync(assetsDir)) {
|
||||||
|
rmSync(assetsDir, { recursive: true })
|
||||||
|
console.log('[inline-assets] Removed assets/ directory')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[inline-assets] Done — dist/ contains only index.html')
|
||||||
555
src/App.svelte
Normal file
555
src/App.svelte
Normal file
@ -0,0 +1,555 @@
|
|||||||
|
<script>
|
||||||
|
import { app } from './lib/stores/app.svelte.js'
|
||||||
|
import { notifications } from './lib/stores/notifications.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 NotificationBell from './components/NotificationBell.svelte'
|
||||||
|
import SettingsDialog from './components/SettingsDialog.svelte'
|
||||||
|
import AddLocationDialog from './components/AddLocationDialog.svelte'
|
||||||
|
|
||||||
|
let viewMode = $state('current') // 'current' | 'hourly' | 'daily' | 'detail'
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
initDone = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-analyze alerts whenever forecast data changes
|
||||||
|
$effect(() => {
|
||||||
|
if (app.forecastData && app.settings.alertsEnabled) {
|
||||||
|
notifications.analyze()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRefresh() {
|
||||||
|
app.refreshForecast()
|
||||||
|
}
|
||||||
|
</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={handleRefresh} aria-label="Refresh" title="Refresh">
|
||||||
|
{app.loading ? '⏳' : '🔄'}
|
||||||
|
</button>
|
||||||
|
<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">
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick={handleRefresh} disabled={app.loading}>
|
||||||
|
{app.loading ? '⏳' : '🔄'} Refresh
|
||||||
|
</button>
|
||||||
|
<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}
|
||||||
|
|
||||||
|
<!-- Weather content -->
|
||||||
|
{#if app.forecastData && app.selectedLocation}
|
||||||
|
<!-- Mobile bottom nav -->
|
||||||
|
<div class="mobile-nav">
|
||||||
|
<button
|
||||||
|
class="nav-btn"
|
||||||
|
class:active={viewMode === 'current'}
|
||||||
|
onclick={() => viewMode = 'current'}
|
||||||
|
>Now</button>
|
||||||
|
<button
|
||||||
|
class="nav-btn"
|
||||||
|
class:active={viewMode === 'hourly'}
|
||||||
|
onclick={() => viewMode = 'hourly'}
|
||||||
|
>Hourly</button>
|
||||||
|
<button
|
||||||
|
class="nav-btn"
|
||||||
|
class:active={viewMode === 'daily'}
|
||||||
|
onclick={() => viewMode = 'daily'}
|
||||||
|
>Daily</button>
|
||||||
|
<button
|
||||||
|
class="nav-btn"
|
||||||
|
class:active={viewMode === 'detail'}
|
||||||
|
onclick={() => viewMode = 'detail'}
|
||||||
|
>More</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop tab bar -->
|
||||||
|
<div class="desktop-tabs">
|
||||||
|
<button class="tab-btn" class:active={viewMode === 'current'} onclick={() => viewMode = 'current'}>Current</button>
|
||||||
|
<button class="tab-btn" class:active={viewMode === 'hourly'} onclick={() => viewMode = 'hourly'}>Hourly</button>
|
||||||
|
<button class="tab-btn" class:active={viewMode === 'daily'} onclick={() => viewMode = 'daily'}>7-Day Forecast</button>
|
||||||
|
<button class="tab-btn" class:active={viewMode === 'detail'} onclick={() => viewMode = 'detail'}>Details</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content area -->
|
||||||
|
<div class="content-area">
|
||||||
|
{#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>
|
||||||
|
{/if}
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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>
|
||||||
294
src/components/AddLocationDialog.svelte
Normal file
294
src/components/AddLocationDialog.svelte
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
<script>
|
||||||
|
import { app } from '../lib/stores/app.svelte.js'
|
||||||
|
import { autofocus } from '../lib/autofocus.js'
|
||||||
|
|
||||||
|
let { onClose, onAdd } = $props()
|
||||||
|
|
||||||
|
let searchQuery = $state('')
|
||||||
|
let searchTimer = null
|
||||||
|
let geocodingResults = $state([])
|
||||||
|
let loading = $state(false)
|
||||||
|
let error = $state('')
|
||||||
|
|
||||||
|
function handleSearch(e) {
|
||||||
|
const val = e.target.value
|
||||||
|
searchQuery = val
|
||||||
|
clearTimeout(searchTimer)
|
||||||
|
|
||||||
|
if (val.length < 2) {
|
||||||
|
geocodingResults = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true
|
||||||
|
error = ''
|
||||||
|
|
||||||
|
searchTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const { searchLocations } = await import('../lib/api/weather.js')
|
||||||
|
geocodingResults = await searchLocations(val)
|
||||||
|
} catch (e) {
|
||||||
|
error = 'Search failed: ' + (e.message || 'Unknown error')
|
||||||
|
geocodingResults = []
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(location) {
|
||||||
|
onAdd(location)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdrop(e) {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="modal-overlay" onclick={handleBackdrop} role="presentation">
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Add location"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Add Location</h3>
|
||||||
|
<button class="btn-icon" onclick={onClose} aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-row">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search for a city..."
|
||||||
|
value={searchQuery}
|
||||||
|
oninput={handleSearch}
|
||||||
|
use:autofocus
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
{#if loading}
|
||||||
|
<span class="search-spinner">⏳</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="error-msg">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if geocodingResults.length > 0}
|
||||||
|
<div class="results-list">
|
||||||
|
{#each geocodingResults as result}
|
||||||
|
<button class="result-item" onclick={() => handleSelect(result)}>
|
||||||
|
<span class="result-icon">🏙️</span>
|
||||||
|
<div class="result-info">
|
||||||
|
<span class="result-name">{result.name}</span>
|
||||||
|
<span class="result-detail">
|
||||||
|
{result.admin1 || ''}{result.admin1 && result.country ? ', ' : ''}{result.country || ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="result-arrow">→</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if searchQuery.length >= 2 && !loading}
|
||||||
|
<div class="no-results">
|
||||||
|
<p class="text-muted text-sm">No locations found for "{searchQuery}".</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Use current location fallback -->
|
||||||
|
<div class="geolocate-section">
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm w-full"
|
||||||
|
onclick={async () => {
|
||||||
|
try {
|
||||||
|
const pos = await new Promise((resolve, reject) => {
|
||||||
|
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
onAdd({
|
||||||
|
name: 'Current Location',
|
||||||
|
latitude: pos.coords.latitude,
|
||||||
|
longitude: pos.coords.longitude,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
alert('Could not get location. Please search above.')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📍 Use Current Location
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 420px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: fadeInScale 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInScale {
|
||||||
|
from { opacity: 0; transform: scale(0.95); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row input {
|
||||||
|
padding: 10px 36px 10px 14px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-spinner {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 1rem;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: translateY(-50%) rotate(0deg); }
|
||||||
|
to { transform: translateY(-50%) rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
color: #fca5a5;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-list {
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin: 8px 0;
|
||||||
|
background: var(--color-surface-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item:hover {
|
||||||
|
background: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-detail {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-arrow {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geolocate-section {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.modal-overlay {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
max-height: 85dvh;
|
||||||
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
max-width: none;
|
||||||
|
padding-bottom: max(20px, var(--safe-bottom));
|
||||||
|
animation: slideUp 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(100%); }
|
||||||
|
to { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
286
src/components/CurrentWeather.svelte
Normal file
286
src/components/CurrentWeather.svelte
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
<script>
|
||||||
|
import { app } from '../lib/stores/app.svelte.js'
|
||||||
|
import { getWeatherInfo, getWeatherSeverity } from '../lib/api/weather-codes.js'
|
||||||
|
|
||||||
|
const data = $derived(app.forecastData)
|
||||||
|
const current = $derived(data?.current)
|
||||||
|
const daily = $derived(data?.daily)
|
||||||
|
|
||||||
|
const weatherInfo = $derived(getWeatherInfo(current?.weather_code || 0))
|
||||||
|
const severity = $derived(getWeatherSeverity(current?.weather_code || 0))
|
||||||
|
|
||||||
|
// Dynamic gradient background class
|
||||||
|
const bgClass = $derived(`weather-bg-${severity}`)
|
||||||
|
|
||||||
|
// Format time helper
|
||||||
|
function formatTime(isoString) {
|
||||||
|
if (!isoString) return '--'
|
||||||
|
// Open-Meteo returns times like "2026-07-22T05:11"
|
||||||
|
const timePart = isoString.split('T')[1]
|
||||||
|
if (!timePart) return '--'
|
||||||
|
const [h, m] = timePart.split(':')
|
||||||
|
const hours = parseInt(h, 10)
|
||||||
|
const ampm = hours >= 12 ? 'PM' : 'AM'
|
||||||
|
const displayH = hours % 12 || 12
|
||||||
|
return `${displayH}:${m} ${ampm}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wind direction to compass
|
||||||
|
function windCompass(deg) {
|
||||||
|
if (deg == null) return '--'
|
||||||
|
const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
|
||||||
|
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']
|
||||||
|
const idx = Math.round(deg / 22.5) % 16
|
||||||
|
return dirs[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
const unit = $derived(app.settings.units === 'imperial' ? '°F' : '°C')
|
||||||
|
const windUnit = $derived(app.settings.units === 'imperial' ? 'mph' : 'km/h')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if current}
|
||||||
|
<div class="current-hero {bgClass}">
|
||||||
|
<!-- Main temperature display -->
|
||||||
|
<div class="hero-main">
|
||||||
|
<div class="hero-temp">
|
||||||
|
<span class="temp-value">{Math.round(current.temperature_2m)}</span>
|
||||||
|
<span class="temp-unit">{unit}</span>
|
||||||
|
</div>
|
||||||
|
<div class="hero-icon">{weatherInfo.icon}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero-desc">
|
||||||
|
{weatherInfo.desc}
|
||||||
|
{#if Math.abs(current.temperature_2m - current.apparent_temperature) >= 2}
|
||||||
|
<span class="feels-like">· Feels like {Math.round(current.apparent_temperature)}{unit}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metrics grid -->
|
||||||
|
<div class="metrics-grid">
|
||||||
|
<div class="metric-card">
|
||||||
|
<span class="metric-icon">💧</span>
|
||||||
|
<span class="metric-label">Humidity</span>
|
||||||
|
<span class="metric-value">{current.relative_humidity_2m}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span class="metric-icon">💨</span>
|
||||||
|
<span class="metric-label">Wind</span>
|
||||||
|
<span class="metric-value">{Math.round(current.wind_speed_10m)} {windUnit}</span>
|
||||||
|
<span class="metric-sub">{windCompass(current.wind_direction_10m)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span class="metric-icon">🌡️</span>
|
||||||
|
<span class="metric-label">Pressure</span>
|
||||||
|
<span class="metric-value">{Math.round(current.pressure_msl || 0)} hPa</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span class="metric-icon">☀️</span>
|
||||||
|
<span class="metric-label">UV Index</span>
|
||||||
|
<span class="metric-value">{current.uv_index != null ? Math.round(current.uv_index) : '--'}</span>
|
||||||
|
{#if current.uv_index != null}
|
||||||
|
<span class="metric-sub">{current.uv_index <= 2 ? 'Low' : current.uv_index <= 5 ? 'Moderate' : current.uv_index <= 7 ? 'High' : 'Very High'}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if current.wind_gusts_10m}
|
||||||
|
<div class="metric-card">
|
||||||
|
<span class="metric-icon">🌪️</span>
|
||||||
|
<span class="metric-label">Gusts</span>
|
||||||
|
<span class="metric-value">{Math.round(current.wind_gusts_10m)} {windUnit}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sunrise/Sunset -->
|
||||||
|
{#if daily?.sunrise?.[0] && daily?.sunset?.[0]}
|
||||||
|
<div class="sun-times">
|
||||||
|
<div class="sun-time">
|
||||||
|
<span class="sun-icon">🌅</span>
|
||||||
|
<span>Sunrise {formatTime(daily.sunrise[0])}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sun-divider"></div>
|
||||||
|
<div class="sun-time">
|
||||||
|
<span class="sun-icon">🌇</span>
|
||||||
|
<span>Sunset {formatTime(daily.sunset[0])}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.current-hero {
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
color: var(--color-text);
|
||||||
|
transition: background 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Weather background gradients */
|
||||||
|
.weather-bg-clear {
|
||||||
|
background: linear-gradient(135deg, #1a3a5c 0%, #2d6aa8 40%, #4a90d9 100%);
|
||||||
|
}
|
||||||
|
.weather-bg-cloudy {
|
||||||
|
background: linear-gradient(135deg, #1e293b 0%, #334155 50%, #475569 100%);
|
||||||
|
}
|
||||||
|
.weather-bg-rain {
|
||||||
|
background: linear-gradient(135deg, #17202e 0%, #253649 40%, #3b4f6b 100%);
|
||||||
|
}
|
||||||
|
.weather-bg-storm {
|
||||||
|
background: linear-gradient(135deg, #120e1e 0%, #2d1f4e 40%, #4a3178 100%);
|
||||||
|
}
|
||||||
|
.weather-bg-snow {
|
||||||
|
background: linear-gradient(135deg, #1c2838 0%, #2c3e58 50%, #4a6fa5 100%);
|
||||||
|
}
|
||||||
|
.weather-bg-fog {
|
||||||
|
background: linear-gradient(135deg, #1e2430 0%, #2d3545 50%, #3d4757 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-temp {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-value {
|
||||||
|
font-size: 4rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-unit {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
filter: drop-shadow(0 0 12px rgba(255, 255, 255, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-desc {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feels-like {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metrics grid */
|
||||||
|
.metrics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-sub {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sunrise / Sunset */
|
||||||
|
.sun-times {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sun-time {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sun-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sun-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.current-hero {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-value {
|
||||||
|
font-size: 3.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
padding: 10px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sun-times {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sun-divider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
225
src/components/DailyForecast.svelte
Normal file
225
src/components/DailyForecast.svelte
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
<script>
|
||||||
|
import { app } from '../lib/stores/app.svelte.js'
|
||||||
|
import { getWeatherInfo } from '../lib/api/weather-codes.js'
|
||||||
|
|
||||||
|
const data = $derived(app.forecastData)
|
||||||
|
const daily = $derived(data?.daily)
|
||||||
|
const unit = $derived(app.settings.units === 'imperial' ? '°F' : '°C')
|
||||||
|
const windUnit = $derived(app.settings.units === 'imperial' ? 'mph' : 'km/h')
|
||||||
|
|
||||||
|
function formatDay(dateStr, index) {
|
||||||
|
if (index === 0) return 'Today'
|
||||||
|
if (index === 1) return 'Tomorrow'
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDayName(dateStr, index) {
|
||||||
|
if (index === 0) return 'Today'
|
||||||
|
if (index === 1) return 'Tomorrow'
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-US', { weekday: 'long' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if daily?.time}
|
||||||
|
<div class="daily-section">
|
||||||
|
<h3 class="section-title">7-Day Forecast</h3>
|
||||||
|
|
||||||
|
<div class="daily-list">
|
||||||
|
{#each daily.time as day, i}
|
||||||
|
{#if i < 7}
|
||||||
|
{@const info = getWeatherInfo(daily.weather_code[i])}
|
||||||
|
{@const tempMax = Math.round(daily.temperature_2m_max[i])}
|
||||||
|
{@const tempMin = Math.round(daily.temperature_2m_min[i])}
|
||||||
|
{@const precip = daily.precipitation_probability_max?.[i] || 0}
|
||||||
|
{@const wind = daily.wind_speed_10m_max?.[i]}
|
||||||
|
|
||||||
|
<div class="day-card card-glass" class:today={i === 0}>
|
||||||
|
<div class="day-left">
|
||||||
|
<span class="day-name">{getDayName(day, i)}</span>
|
||||||
|
<span class="day-date text-xs text-muted">{formatDay(day, i)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="day-center">
|
||||||
|
<span class="day-icon">{info.icon}</span>
|
||||||
|
<span class="day-desc">{info.label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="day-right">
|
||||||
|
<div class="day-temps">
|
||||||
|
<span class="temp-high">{tempMax}{unit}</span>
|
||||||
|
<span class="temp-low">/ {tempMin}{unit}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if precip > 0}
|
||||||
|
<div class="precip-row">
|
||||||
|
<span class="precip-icon">💧</span>
|
||||||
|
<div class="precip-bar-wrap">
|
||||||
|
<div class="precip-fill" style="width: {Math.min(precip, 100)}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="precip-label">{precip}%</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if wind > 0}
|
||||||
|
<span class="wind-info">💨 {Math.round(wind)} {windUnit}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.daily-section {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 16px;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-card.today {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 16px rgba(56, 189, 248, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-left {
|
||||||
|
min-width: 100px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-date {
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-center {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-icon {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-desc {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-temps {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-high {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-low {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precip-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precip-icon {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precip-bar-wrap {
|
||||||
|
width: 40px;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(96, 165, 250, 0.15);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precip-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-rain);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precip-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wind-info {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.day-card {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 12px 14px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-left {
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-center {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-right {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-desc {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
178
src/components/HourlyForecast.svelte
Normal file
178
src/components/HourlyForecast.svelte
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
<script>
|
||||||
|
import { app } from '../lib/stores/app.svelte.js'
|
||||||
|
import { getWeatherInfo } from '../lib/api/weather-codes.js'
|
||||||
|
|
||||||
|
const data = $derived(app.forecastData)
|
||||||
|
const hourly = $derived(data?.hourly)
|
||||||
|
const unit = $derived(app.settings.units === 'imperial' ? '°F' : '°C')
|
||||||
|
const windUnit = $derived(app.settings.units === 'imperial' ? 'mph' : 'km/h')
|
||||||
|
|
||||||
|
// Get next 24 hours from current time
|
||||||
|
const next24Hours = $derived.by(() => {
|
||||||
|
if (!hourly?.time) return []
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const result = []
|
||||||
|
|
||||||
|
for (let i = 0; i < hourly.time.length && result.length < 24; i++) {
|
||||||
|
const time = new Date(hourly.time[i])
|
||||||
|
if (time >= new Date(now.getTime() - 60 * 60 * 1000)) {
|
||||||
|
result.push({
|
||||||
|
time,
|
||||||
|
temp: hourly.temperature_2m?.[i],
|
||||||
|
precip: hourly.precipitation_probability?.[i],
|
||||||
|
code: hourly.weather_code?.[i],
|
||||||
|
windSpeed: hourly.wind_speed_10m?.[i],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatHour(time) {
|
||||||
|
const hours = time.getHours()
|
||||||
|
if (hours === 0) return '12 AM'
|
||||||
|
if (hours === 12) return '12 PM'
|
||||||
|
return hours < 12 ? `${hours} AM` : `${hours - 12} PM`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNow(index) {
|
||||||
|
return index === 0
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if next24Hours.length > 0}
|
||||||
|
<div class="hourly-section">
|
||||||
|
<h3 class="section-title">Hourly Forecast</h3>
|
||||||
|
|
||||||
|
<div class="hourly-scroll">
|
||||||
|
{#each next24Hours as hour, i}
|
||||||
|
{@const info = getWeatherInfo(hour.code)}
|
||||||
|
<div class="hour-item" class:now={isNow(i)}>
|
||||||
|
<span class="hour-time">{isNow(i) ? 'Now' : formatHour(hour.time)}</span>
|
||||||
|
<span class="hour-icon">{info.icon}</span>
|
||||||
|
<span class="hour-temp">{Math.round(hour.temp)}{unit}</span>
|
||||||
|
{#if hour.precip > 0}
|
||||||
|
<div class="precip-bar-container">
|
||||||
|
<div class="precip-bar" style="height: {Math.min(hour.precip, 100)}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="precip-value">{hour.precip}%</span>
|
||||||
|
{:else}
|
||||||
|
<div class="precip-bar-container">
|
||||||
|
<div class="precip-bar" style="height: 0"></div>
|
||||||
|
</div>
|
||||||
|
<span class="precip-value">--</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hourly-section {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hourly-scroll {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scroll-snap-type: x proximity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 72px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
background: var(--color-surface-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
scroll-snap-align: start;
|
||||||
|
transition: background-color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-item.now {
|
||||||
|
background: rgba(56, 189, 248, 0.1);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 12px rgba(56, 189, 248, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-time {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-item.now .hour-time {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-icon {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-temp {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precip-bar-container {
|
||||||
|
width: 4px;
|
||||||
|
height: 30px;
|
||||||
|
background: rgba(96, 165, 250, 0.15);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precip-bar {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--color-rain);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: height 0.3s ease;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precip-value {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.hour-item {
|
||||||
|
min-width: 64px;
|
||||||
|
padding: 10px 6px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-temp {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precip-bar-container {
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
357
src/components/LocationSidebar.svelte
Normal file
357
src/components/LocationSidebar.svelte
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
<script>
|
||||||
|
import { app } from '../lib/stores/app.svelte.js'
|
||||||
|
import { getWeatherInfo } from '../lib/api/weather-codes.js'
|
||||||
|
import { deleteLocation, setCurrentLocation } from '../lib/storage/db.js'
|
||||||
|
import { autofocus } from '../lib/autofocus.js'
|
||||||
|
|
||||||
|
let searchQuery = $state('')
|
||||||
|
let searchTimer = null
|
||||||
|
|
||||||
|
function handleSearch(e) {
|
||||||
|
const val = e.target.value
|
||||||
|
searchQuery = val
|
||||||
|
clearTimeout(searchTimer)
|
||||||
|
if (val.length >= 2) {
|
||||||
|
searchTimer = setTimeout(() => app.searchGeocoding(val), 300)
|
||||||
|
} else {
|
||||||
|
app.geocodingResults = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteLocation(id) {
|
||||||
|
try {
|
||||||
|
await deleteLocation(id)
|
||||||
|
// If deleting the selected location, select the first remaining
|
||||||
|
const locations = await import('../lib/storage/db.js').then(m => m.getLocations())
|
||||||
|
if (id === app.selectedLocationId) {
|
||||||
|
if (locations.length > 0) {
|
||||||
|
await app.selectLocation(locations[0].id)
|
||||||
|
} else {
|
||||||
|
app.selectedLocationId = null
|
||||||
|
app.forecastData = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await app.refreshLocations()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete location:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSetCurrent(id) {
|
||||||
|
if (app.selectedLocation?.isCurrent) return
|
||||||
|
setCurrentLocation(id).then(() => app.refreshLocations())
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddLocation() {
|
||||||
|
searchQuery = ''
|
||||||
|
app.geocodingResults = []
|
||||||
|
app.showAddLocation = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSelectGeocoding(result) {
|
||||||
|
searchQuery = ''
|
||||||
|
app.geocodingResults = []
|
||||||
|
|
||||||
|
const { addLocation } = await import('../lib/storage/db.js')
|
||||||
|
await addLocation({
|
||||||
|
name: `${result.name}${result.country ? ', ' + result.country : ''}`,
|
||||||
|
lat: result.latitude,
|
||||||
|
lon: result.longitude,
|
||||||
|
isCurrent: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
await app.refreshLocations()
|
||||||
|
|
||||||
|
// Select the newly added location
|
||||||
|
const locations = await import('../lib/storage/db.js').then(m => m.getLocations())
|
||||||
|
const newest = locations[locations.length - 1]
|
||||||
|
if (newest) {
|
||||||
|
app.selectLocation(newest.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h2>🌤️ WeatherLens</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick geocoding search -->
|
||||||
|
<div class="search-box">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search locations..."
|
||||||
|
value={searchQuery}
|
||||||
|
oninput={handleSearch}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
{#if app.geocodingLoading}
|
||||||
|
<span class="search-spinner">⏳</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if app.geocodingResults.length > 0}
|
||||||
|
<div class="geocoding-dropdown">
|
||||||
|
{#each app.geocodingResults as result}
|
||||||
|
<button
|
||||||
|
class="geocoding-item"
|
||||||
|
onclick={() => handleSelectGeocoding(result)}
|
||||||
|
>
|
||||||
|
<span class="geo-name">{result.name}</span>
|
||||||
|
<span class="geo-detail">
|
||||||
|
{result.admin1 || ''}{result.country ? ', ' + result.country : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Saved locations -->
|
||||||
|
<nav class="locations-nav">
|
||||||
|
{#each app.locations as location}
|
||||||
|
{#if location.isCurrent}
|
||||||
|
<!-- Current location pinned -->
|
||||||
|
<div class="location-row current-row">
|
||||||
|
<button
|
||||||
|
class="location-item"
|
||||||
|
class:active={app.selectedLocationId === location.id}
|
||||||
|
onclick={() => app.selectLocation(location.id)}
|
||||||
|
>
|
||||||
|
<span class="loc-icon">📍</span>
|
||||||
|
<span class="loc-name truncate">{location.name}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#each app.locations as location}
|
||||||
|
{#if !location.isCurrent}
|
||||||
|
<div class="location-row">
|
||||||
|
<button
|
||||||
|
class="location-item"
|
||||||
|
class:active={app.selectedLocationId === location.id}
|
||||||
|
onclick={() => app.selectLocation(location.id)}
|
||||||
|
>
|
||||||
|
<span class="loc-icon">🏙️</span>
|
||||||
|
<span class="loc-name truncate">{location.name}</span>
|
||||||
|
</button>
|
||||||
|
<div class="loc-actions">
|
||||||
|
<button
|
||||||
|
class="loc-action-btn"
|
||||||
|
onclick={() => handleSetCurrent(location.id)}
|
||||||
|
title="Set as current location"
|
||||||
|
>📍</button>
|
||||||
|
<button
|
||||||
|
class="loc-action-btn"
|
||||||
|
onclick={() => handleDeleteLocation(location.id)}
|
||||||
|
title="Remove location"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if app.locations.length === 0}
|
||||||
|
<div class="empty-locations">
|
||||||
|
<p class="text-muted text-sm">No saved locations.</p>
|
||||||
|
<p class="text-muted text-xs mt-1">Enable location access or add one below.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<button class="btn btn-ghost btn-sm w-full" onclick={() => app.showSettings = true}>
|
||||||
|
⚙️ Settings
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm w-full mt-2" onclick={openAddLocation}>
|
||||||
|
+ Add Location
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sidebar-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search box */
|
||||||
|
.search-box {
|
||||||
|
padding: 12px 12px 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-spinner {
|
||||||
|
position: absolute;
|
||||||
|
right: 22px;
|
||||||
|
top: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.geocoding-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
top: calc(100% - 4px);
|
||||||
|
background: var(--color-surface-card);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 70;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geocoding-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.geocoding-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geocoding-item:hover {
|
||||||
|
background: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-detail {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Locations nav */
|
||||||
|
.locations-nav {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-row {
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition), color var(--transition);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-item:hover {
|
||||||
|
background: var(--color-surface-hover);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-item.active {
|
||||||
|
background: rgba(56, 189, 248, 0.12);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-icon {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-name {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding-right: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-row:hover .loc-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-action-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background-color 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-action-btn:hover {
|
||||||
|
background: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty-locations {
|
||||||
|
padding: 24px 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 12px;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
313
src/components/NotificationBell.svelte
Normal file
313
src/components/NotificationBell.svelte
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
<script>
|
||||||
|
import { notifications } from '../lib/stores/notifications.svelte.js'
|
||||||
|
|
||||||
|
let open = $state(false)
|
||||||
|
let bellRef = $state(null)
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
open = !open
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDismiss(alertId) {
|
||||||
|
notifications.dismiss(alertId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDismissAll() {
|
||||||
|
for (const alert of [...notifications.alerts]) {
|
||||||
|
notifications.dismiss(alert.id)
|
||||||
|
}
|
||||||
|
open = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdrop(e) {
|
||||||
|
if (open) open = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) return
|
||||||
|
function onClick(e) {
|
||||||
|
if (bellRef && !bellRef.contains(e.target)) {
|
||||||
|
open = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('click', onClick)
|
||||||
|
return () => document.removeEventListener('click', onClick)
|
||||||
|
})
|
||||||
|
|
||||||
|
const count = $derived(notifications.alerts.length)
|
||||||
|
const hasDanger = $derived(notifications.alerts.some((a) => a.severity === 'danger'))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bell-wrapper" bind:this={bellRef}>
|
||||||
|
<button
|
||||||
|
class="btn-icon bell-btn"
|
||||||
|
class:has-alerts={count > 0}
|
||||||
|
class:has-danger={hasDanger}
|
||||||
|
onclick={toggle}
|
||||||
|
aria-label="{count} notifications"
|
||||||
|
title="{count} weather alert{count !== 1 ? 's' : ''}"
|
||||||
|
>
|
||||||
|
{#if count > 0}
|
||||||
|
<span class="bell-icon ringing">🔔</span>
|
||||||
|
<span class="bell-badge">{count}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="bell-icon">🔕</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="bell-dropdown animate-slide-down">
|
||||||
|
<div class="bell-header">
|
||||||
|
<span class="bell-title">
|
||||||
|
{count > 0 ? `${count} Weather Alert${count !== 1 ? 's' : ''}` : 'No Active Alerts'}
|
||||||
|
</span>
|
||||||
|
{#if count > 0}
|
||||||
|
<button class="bell-dismiss-all" onclick={handleDismissAll}>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bell-body">
|
||||||
|
{#if count > 0}
|
||||||
|
{#each notifications.alerts as alert (alert.id)}
|
||||||
|
<div class="bell-alert {alert.severity}">
|
||||||
|
<span class="bell-alert-icon">{alert.icon}</span>
|
||||||
|
<div class="bell-alert-content">
|
||||||
|
<span class="bell-alert-msg">{alert.message}</span>
|
||||||
|
<span class="bell-alert-type">{alert.type}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="bell-dismiss"
|
||||||
|
onclick={() => handleDismiss(alert.id)}
|
||||||
|
aria-label="Dismiss {alert.type}"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<div class="bell-empty">
|
||||||
|
<span class="bell-empty-icon">✅</span>
|
||||||
|
<span class="text-muted text-sm">No weather alerts right now</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bell-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-btn {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-btn.has-alerts {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-btn.has-danger {
|
||||||
|
animation: urgentPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes urgentPulse {
|
||||||
|
0%, 100% { color: var(--color-text); }
|
||||||
|
50% { color: var(--color-danger); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-icon.ringing {
|
||||||
|
animation: ring 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ring {
|
||||||
|
0%, 100% { transform: rotate(0); }
|
||||||
|
15% { transform: rotate(12deg); }
|
||||||
|
30% { transform: rotate(-10deg); }
|
||||||
|
45% { transform: rotate(8deg); }
|
||||||
|
60% { transform: rotate(-6deg); }
|
||||||
|
75% { transform: rotate(3deg); }
|
||||||
|
90% { transform: rotate(-1deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
right: -2px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-danger);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown */
|
||||||
|
.bell-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
width: 340px;
|
||||||
|
max-width: calc(100vw - 16px);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 150;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: fadeInScale 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInScale {
|
||||||
|
from { opacity: 0; transform: scale(0.95) translateY(-4px); }
|
||||||
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-dismiss-all {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-dismiss-all:hover {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-body {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
transition: background-color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-alert:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-alert:hover {
|
||||||
|
background: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-alert.danger {
|
||||||
|
border-left: 3px solid var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-alert.warning {
|
||||||
|
border-left: 3px solid var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-alert.info {
|
||||||
|
border-left: 3px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-alert-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-alert-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-alert-msg {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
display: block;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-alert-type {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: capitalize;
|
||||||
|
margin-top: 2px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-dismiss {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: color var(--transition), background-color var(--transition);
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-alert:hover .bell-dismiss {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-dismiss:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-empty-icon {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.bell-dropdown {
|
||||||
|
position: fixed;
|
||||||
|
top: 52px;
|
||||||
|
left: 8px;
|
||||||
|
right: 8px;
|
||||||
|
bottom: auto;
|
||||||
|
width: auto;
|
||||||
|
max-width: none;
|
||||||
|
max-height: calc(100dvh - 52px - 16px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
350
src/components/SettingsDialog.svelte
Normal file
350
src/components/SettingsDialog.svelte
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
<script>
|
||||||
|
import { app } from '../lib/stores/app.svelte.js'
|
||||||
|
import { saveSettings } from '../lib/storage/db.js'
|
||||||
|
|
||||||
|
let { onClose } = $props()
|
||||||
|
|
||||||
|
let localSettings = $state({
|
||||||
|
...app.settings,
|
||||||
|
alertThresholds: { ...app.settings.alertThresholds },
|
||||||
|
})
|
||||||
|
let saving = $state(false)
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
saving = true
|
||||||
|
try {
|
||||||
|
app.settings = { ...localSettings }
|
||||||
|
await saveSettings({
|
||||||
|
...localSettings,
|
||||||
|
alertThresholds: { ...localSettings.alertThresholds },
|
||||||
|
})
|
||||||
|
// Refresh forecast with new unit if it changed
|
||||||
|
await app.refreshForecast()
|
||||||
|
onClose()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save settings:', e)
|
||||||
|
} finally {
|
||||||
|
saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
if (confirm('Reset all locations and stored data?')) {
|
||||||
|
import('../lib/storage/db.js').then(async (db) => {
|
||||||
|
await db.clearAllLocations()
|
||||||
|
app.locations = []
|
||||||
|
app.selectedLocationId = null
|
||||||
|
app.forecastData = null
|
||||||
|
onClose()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdrop(e) {
|
||||||
|
if (e.target === e.currentTarget) onClose()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="modal-overlay" onclick={handleBackdrop} role="presentation">
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Settings"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>⚙️ Settings</h3>
|
||||||
|
<button class="btn-icon" onclick={onClose} aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-body">
|
||||||
|
<!-- Units -->
|
||||||
|
<div class="settings-group">
|
||||||
|
<h4>Units</h4>
|
||||||
|
<div class="setting-row">
|
||||||
|
<span>Measurement system</span>
|
||||||
|
<select
|
||||||
|
value={localSettings.units}
|
||||||
|
onchange={(e) => localSettings.units = e.target.value}
|
||||||
|
>
|
||||||
|
<option value="metric">Metric (°C, km/h)</option>
|
||||||
|
<option value="imperial">Imperial (°F, mph)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alerts -->
|
||||||
|
<div class="settings-group">
|
||||||
|
<h4>Weather Alerts</h4>
|
||||||
|
<div class="setting-row">
|
||||||
|
<span>Enable Alerts</span>
|
||||||
|
<label class="toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={localSettings.alertsEnabled}
|
||||||
|
onchange={(e) => localSettings.alertsEnabled = e.target.checked}
|
||||||
|
/>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if localSettings.alertsEnabled}
|
||||||
|
<div class="setting-row">
|
||||||
|
<span>Rain threshold</span>
|
||||||
|
<select
|
||||||
|
value={String(localSettings.alertThresholds.precip)}
|
||||||
|
onchange={(e) => localSettings.alertThresholds.precip = parseInt(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="50">50%</option>
|
||||||
|
<option value="60">60%</option>
|
||||||
|
<option value="70">70%</option>
|
||||||
|
<option value="80">80%</option>
|
||||||
|
<option value="90">90%</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<span>Wind gust threshold</span>
|
||||||
|
<select
|
||||||
|
value={String(localSettings.alertThresholds.windGust)}
|
||||||
|
onchange={(e) => localSettings.alertThresholds.windGust = parseInt(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="30">30 {localSettings.windUnit}</option>
|
||||||
|
<option value="40">40 {localSettings.windUnit}</option>
|
||||||
|
<option value="50">50 {localSettings.windUnit}</option>
|
||||||
|
<option value="60">60 {localSettings.windUnit}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<span>UV Index threshold</span>
|
||||||
|
<select
|
||||||
|
value={String(localSettings.alertThresholds.uvIndex)}
|
||||||
|
onchange={(e) => localSettings.alertThresholds.uvIndex = parseInt(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="4">4+ (Moderate)</option>
|
||||||
|
<option value="6">6+ (High)</option>
|
||||||
|
<option value="8">8+ (Very High)</option>
|
||||||
|
<option value="11">11+ (Extreme)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<span>High temp alert</span>
|
||||||
|
<select
|
||||||
|
value={String(localSettings.alertThresholds.tempHigh)}
|
||||||
|
onchange={(e) => localSettings.alertThresholds.tempHigh = parseInt(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="30">30°C / 86°F</option>
|
||||||
|
<option value="35">35°C / 95°F</option>
|
||||||
|
<option value="38">38°C / 100°F</option>
|
||||||
|
<option value="42">42°C / 108°F</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<span>Low temp alert</span>
|
||||||
|
<select
|
||||||
|
value={String(localSettings.alertThresholds.tempLow)}
|
||||||
|
onchange={(e) => localSettings.alertThresholds.tempLow = parseInt(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="5">5°C / 41°F</option>
|
||||||
|
<option value="0">0°C / 32°F</option>
|
||||||
|
<option value="-5">-5°C / 23°F</option>
|
||||||
|
<option value="-10">-10°C / 14°F</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<span>Thunderstorm alerts</span>
|
||||||
|
<label class="toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={localSettings.alertThresholds.thunderstorm}
|
||||||
|
onchange={(e) => localSettings.alertThresholds.thunderstorm = e.target.checked}
|
||||||
|
/>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Danger zone -->
|
||||||
|
<div class="settings-group danger-zone">
|
||||||
|
<h4>Data</h4>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick={handleReset}>
|
||||||
|
🗑 Reset All Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-primary" onclick={handleSave} disabled={saving}>
|
||||||
|
{saving ? 'Saving...' : 'Save Settings'}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost" onclick={onClose}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 460px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: fadeInScale 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInScale {
|
||||||
|
from { opacity: 0; transform: scale(0.95); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group {
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--color-surface-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group h4 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone {
|
||||||
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone h4 {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row + .setting-row {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row span {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row select {
|
||||||
|
width: auto;
|
||||||
|
max-width: 160px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle switch */
|
||||||
|
.toggle {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 24px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked + .toggle-slider {
|
||||||
|
background: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked + .toggle-slider::before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.modal {
|
||||||
|
max-height: 90dvh;
|
||||||
|
max-height: calc(100dvh - 52px);
|
||||||
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
max-width: none;
|
||||||
|
padding-bottom: max(20px, var(--safe-bottom));
|
||||||
|
animation: slideUp 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(100%); }
|
||||||
|
to { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
295
src/components/WeatherDetail.svelte
Normal file
295
src/components/WeatherDetail.svelte
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
<script>
|
||||||
|
import { app } from '../lib/stores/app.svelte.js'
|
||||||
|
import { getWeatherInfo } from '../lib/api/weather-codes.js'
|
||||||
|
|
||||||
|
const data = $derived(app.forecastData)
|
||||||
|
const current = $derived(data?.current)
|
||||||
|
const daily = $derived(data?.daily)
|
||||||
|
const hourly = $derived(data?.hourly)
|
||||||
|
|
||||||
|
const unit = $derived(app.settings.units === 'imperial' ? '°F' : '°C')
|
||||||
|
const windUnit = $derived(app.settings.units === 'imperial' ? 'mph' : 'km/h')
|
||||||
|
|
||||||
|
function windCompass(deg) {
|
||||||
|
if (deg == null) return '--'
|
||||||
|
const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
|
||||||
|
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']
|
||||||
|
const idx = Math.round(deg / 22.5) % 16
|
||||||
|
return dirs[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso) {
|
||||||
|
if (!iso) return '--'
|
||||||
|
const [h, m] = iso.split('T')[1].split(':')
|
||||||
|
const hours = parseInt(h, 10)
|
||||||
|
const ampm = hours >= 12 ? 'PM' : 'AM'
|
||||||
|
return `${hours % 12 || 12}:${m} ${ampm}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBeaufort(speed) {
|
||||||
|
// Convert to km/h if in imperial (mph)
|
||||||
|
const kmh = app.settings.units === 'imperial' ? speed * 1.60934 : speed
|
||||||
|
if (kmh < 2) return 'Calm'
|
||||||
|
if (kmh < 6) return 'Light air'
|
||||||
|
if (kmh < 12) return 'Light breeze'
|
||||||
|
if (kmh < 20) return 'Gentle breeze'
|
||||||
|
if (kmh < 29) return 'Moderate breeze'
|
||||||
|
if (kmh < 39) return 'Fresh breeze'
|
||||||
|
if (kmh < 50) return 'Strong breeze'
|
||||||
|
if (kmh < 62) return 'High wind'
|
||||||
|
if (kmh < 75) return 'Gale'
|
||||||
|
return 'Strong gale'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUVLevel(uv) {
|
||||||
|
if (uv == null) return '--'
|
||||||
|
if (uv <= 2) return 'Low'
|
||||||
|
if (uv <= 5) return 'Moderate'
|
||||||
|
if (uv <= 7) return 'High'
|
||||||
|
if (uv <= 10) return 'Very High'
|
||||||
|
return 'Extreme'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if current}
|
||||||
|
<div class="detail-section">
|
||||||
|
<h3 class="section-title">Weather Details</h3>
|
||||||
|
|
||||||
|
<div class="detail-grid">
|
||||||
|
<!-- Wind details -->
|
||||||
|
<div class="detail-card card">
|
||||||
|
<div class="detail-header">
|
||||||
|
<span class="detail-icon">💨</span>
|
||||||
|
<span class="detail-label">Wind</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-body">
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Speed</span>
|
||||||
|
<span class="detail-value">{Math.round(current.wind_speed_10m)} {windUnit}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Direction</span>
|
||||||
|
<span class="detail-value">{windCompass(current.wind_direction_10m)} ({current.wind_direction_10m}°)</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Gusts</span>
|
||||||
|
<span class="detail-value">{current.wind_gusts_10m != null ? Math.round(current.wind_gusts_10m) + ' ' + windUnit : '--'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Condition</span>
|
||||||
|
<span class="detail-value">{getBeaufort(current.wind_speed_10m)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Atmosphere -->
|
||||||
|
<div class="detail-card card">
|
||||||
|
<div class="detail-header">
|
||||||
|
<span class="detail-icon">🌡️</span>
|
||||||
|
<span class="detail-label">Atmosphere</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-body">
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Humidity</span>
|
||||||
|
<span class="detail-value">{current.relative_humidity_2m}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Pressure</span>
|
||||||
|
<span class="detail-value">{Math.round(current.pressure_msl || 0)} hPa</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Feels Like</span>
|
||||||
|
<span class="detail-value">{Math.round(current.apparent_temperature)}{unit}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Is Day</span>
|
||||||
|
<span class="detail-value">{current.is_day ? '☀️ Yes' : '🌙 No'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sun & UV -->
|
||||||
|
<div class="detail-card card">
|
||||||
|
<div class="detail-header">
|
||||||
|
<span class="detail-icon">☀️</span>
|
||||||
|
<span class="detail-label">Sun & UV</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-body">
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>UV Index</span>
|
||||||
|
<span class="detail-value">
|
||||||
|
{current.uv_index != null ? Math.round(current.uv_index) : '--'}
|
||||||
|
{#if current.uv_index != null}
|
||||||
|
<span class="uv-badge uv-{getUVLevel(current.uv_index).toLowerCase().replace(' ', '-')}">{getUVLevel(current.uv_index)}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Sunrise</span>
|
||||||
|
<span class="detail-value">{formatTime(daily?.sunrise?.[0] || '')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Sunset</span>
|
||||||
|
<span class="detail-value">{formatTime(daily?.sunset?.[0] || '')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Max UV Today</span>
|
||||||
|
<span class="detail-value">{daily?.uv_index_max?.[0] != null ? Math.round(daily.uv_index_max[0]) : '--'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Temperature range -->
|
||||||
|
<div class="detail-card card">
|
||||||
|
<div class="detail-header">
|
||||||
|
<span class="detail-icon">📊</span>
|
||||||
|
<span class="detail-label">Temperature Range</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-body">
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Today's High</span>
|
||||||
|
<span class="detail-value">{daily?.temperature_2m_max?.[0] != null ? Math.round(daily.temperature_2m_max[0]) + unit : '--'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Today's Low</span>
|
||||||
|
<span class="detail-value">{daily?.temperature_2m_min?.[0] != null ? Math.round(daily.temperature_2m_min[0]) + unit : '--'}</span>
|
||||||
|
</div>
|
||||||
|
{#if hourly}
|
||||||
|
{@const temps = hourly.temperature_2m || []}
|
||||||
|
{@const validTemps = temps.filter(t => t != null)}
|
||||||
|
{#if validTemps.length > 0}
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>24h Average</span>
|
||||||
|
<span class="detail-value">{Math.round(validTemps.reduce((a, b) => a + b, 0) / validTemps.length)}{unit}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>Precip Chance</span>
|
||||||
|
<span class="detail-value">{daily?.precipitation_probability_max?.[0] || 0}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time info -->
|
||||||
|
<div class="time-info card mt-3">
|
||||||
|
<div class="time-row">
|
||||||
|
<span>Local time</span>
|
||||||
|
<span class="detail-value">{data.timezone || '--'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="time-row">
|
||||||
|
<span>Data updated</span>
|
||||||
|
<span class="detail-value">{current.time ? new Date(current.time).toLocaleTimeString() : '--'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="time-row">
|
||||||
|
<span>Elevation</span>
|
||||||
|
<span class="detail-value">{data.elevation != null ? (app.settings.units === 'imperial' ? Math.round(data.elevation * 3.28084) + ' ft' : Math.round(data.elevation) + ' m') : '--'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.detail-section {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uv-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uv-badge.low { background: rgba(52, 211, 153, 0.2); color: var(--color-success); }
|
||||||
|
.uv-badge.moderate { background: rgba(251, 191, 36, 0.2); color: var(--color-sun); }
|
||||||
|
.uv-badge.high { background: rgba(245, 158, 11, 0.2); color: var(--color-warning); }
|
||||||
|
.uv-badge.very-high { background: rgba(239, 68, 68, 0.2); color: var(--color-danger); }
|
||||||
|
.uv-badge.extreme { background: rgba(129, 140, 248, 0.25); color: var(--color-storm); }
|
||||||
|
|
||||||
|
/* Time info */
|
||||||
|
.time-info {
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
68
src/lib/api/weather-codes.js
Normal file
68
src/lib/api/weather-codes.js
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* WMO Weather Interpretation Codes (WW) → icon + description.
|
||||||
|
* Reference: https://www.nodc.noaa.gov/archive/arc0021/0002199/1.1/data/0-data/HTML/WMO-CODE/WMO4677.HTM
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CODE_MAP = {
|
||||||
|
0: { icon: '☀️', label: 'Clear', desc: 'Clear sky' },
|
||||||
|
1: { icon: '🌤️', label: 'Mostly Clear', desc: 'Mainly clear' },
|
||||||
|
2: { icon: '⛅', label: 'Partly Cloudy',desc: 'Partly cloudy' },
|
||||||
|
3: { icon: '☁️', label: 'Overcast', desc: 'Overcast' },
|
||||||
|
45: { icon: '🌫️', label: 'Fog', desc: 'Fog' },
|
||||||
|
48: { icon: '🌫️', label: 'Rime Fog', desc: 'Depositing rime fog' },
|
||||||
|
51: { icon: '🌦️', label: 'Light Drizzle',desc: 'Light drizzle' },
|
||||||
|
53: { icon: '🌦️', label: 'Drizzle', desc: 'Moderate drizzle' },
|
||||||
|
55: { icon: '🌧️', label: 'Heavy Drizzle',desc: 'Dense drizzle' },
|
||||||
|
56: { icon: '🌧️', label: 'Freezing Drizzle', desc: 'Light freezing drizzle' },
|
||||||
|
57: { icon: '🌧️', label: 'Freezing Drizzle', desc: 'Dense freezing drizzle' },
|
||||||
|
61: { icon: '🌧️', label: 'Light Rain', desc: 'Slight rain' },
|
||||||
|
63: { icon: '🌧️', label: 'Rain', desc: 'Moderate rain' },
|
||||||
|
65: { icon: '🌧️', label: 'Heavy Rain', desc: 'Heavy rain' },
|
||||||
|
66: { icon: '🌧️', label: 'Freezing Rain',desc: 'Light freezing rain' },
|
||||||
|
67: { icon: '🌧️', label: 'Freezing Rain',desc: 'Heavy freezing rain' },
|
||||||
|
71: { icon: '🌨️', label: 'Light Snow', desc: 'Slight snowfall' },
|
||||||
|
73: { icon: '🌨️', label: 'Snow', desc: 'Moderate snowfall' },
|
||||||
|
75: { icon: '❄️', label: 'Heavy Snow', desc: 'Heavy snowfall' },
|
||||||
|
77: { icon: '❄️', label: 'Snow Grains', desc: 'Snow grains' },
|
||||||
|
80: { icon: '🌧️', label: 'Light Showers', desc: 'Slight rain showers' },
|
||||||
|
81: { icon: '🌧️', label: 'Showers', desc: 'Moderate rain showers' },
|
||||||
|
82: { icon: '⛈️', label: 'Heavy Showers', desc: 'Violent rain showers' },
|
||||||
|
85: { icon: '🌨️', label: 'Snow Showers', desc: 'Slight snow showers' },
|
||||||
|
86: { icon: '❄️', label: 'Snow Showers', desc: 'Heavy snow showers' },
|
||||||
|
95: { icon: '⛈️', label: 'Thunderstorm', desc: 'Thunderstorm' },
|
||||||
|
96: { icon: '⛈️', label: 'Thunderstorm', desc: 'Thunderstorm with slight hail' },
|
||||||
|
99: { icon: '⛈️', label: 'Thunderstorm', desc: 'Thunderstorm with heavy hail' },
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get weather display info for a WMO code.
|
||||||
|
* @param {number} code - WMO weather code (0-99)
|
||||||
|
* @returns {{ icon: string, label: string, desc: string }}
|
||||||
|
*/
|
||||||
|
export function getWeatherInfo(code) {
|
||||||
|
return CODE_MAP[code] || { icon: '❓', label: 'Unknown', desc: 'Unknown conditions' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a severity level for color-coding weather.
|
||||||
|
* @param {number} code
|
||||||
|
* @returns {'clear'|'cloudy'|'rain'|'storm'|'snow'|'fog'}
|
||||||
|
*/
|
||||||
|
export function getWeatherSeverity(code) {
|
||||||
|
if (code === 0) return 'clear'
|
||||||
|
if (code <= 3) return 'cloudy'
|
||||||
|
if (code <= 48) return 'fog'
|
||||||
|
if (code <= 67) return 'rain'
|
||||||
|
if (code <= 86) return 'snow'
|
||||||
|
return 'storm'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a weather code indicates dangerous conditions.
|
||||||
|
* @param {number} code
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isSevereWeather(code) {
|
||||||
|
return code === 65 || code === 75 || code === 82 || code === 86 ||
|
||||||
|
code === 95 || code === 96 || code === 99
|
||||||
|
}
|
||||||
99
src/lib/api/weather.js
Normal file
99
src/lib/api/weather.js
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Open-Meteo API helper functions.
|
||||||
|
* Base URLs:
|
||||||
|
* - Forecast: https://api.open-meteo.com/v1/forecast
|
||||||
|
* - Geocoding: https://geocoding-api.open-meteo.com/v1/search
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FORECAST_BASE = 'https://api.open-meteo.com/v1/forecast'
|
||||||
|
const GEOCODING_BASE = 'https://geocoding-api.open-meteo.com/v1/search'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch weather forecast for a location.
|
||||||
|
* @param {number} lat
|
||||||
|
* @param {number} lon
|
||||||
|
* @param {string} [unit='celsius'] - 'celsius' or 'fahrenheit'
|
||||||
|
* @returns {Promise<object>} Raw Open-Meteo forecast response
|
||||||
|
*/
|
||||||
|
export async function fetchForecast(lat, lon, units = 'metric') {
|
||||||
|
const tempUnit = units === 'imperial' ? 'fahrenheit' : 'celsius'
|
||||||
|
const windUnit = units === 'imperial' ? 'mph' : 'kmh'
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
latitude: lat.toString(),
|
||||||
|
longitude: lon.toString(),
|
||||||
|
current: [
|
||||||
|
'temperature_2m',
|
||||||
|
'relative_humidity_2m',
|
||||||
|
'apparent_temperature',
|
||||||
|
'weather_code',
|
||||||
|
'wind_speed_10m',
|
||||||
|
'wind_direction_10m',
|
||||||
|
'wind_gusts_10m',
|
||||||
|
'pressure_msl',
|
||||||
|
'uv_index',
|
||||||
|
'is_day',
|
||||||
|
].join(','),
|
||||||
|
hourly: [
|
||||||
|
'temperature_2m',
|
||||||
|
'precipitation_probability',
|
||||||
|
'weather_code',
|
||||||
|
'wind_speed_10m',
|
||||||
|
].join(','),
|
||||||
|
daily: [
|
||||||
|
'temperature_2m_max',
|
||||||
|
'temperature_2m_min',
|
||||||
|
'precipitation_probability_max',
|
||||||
|
'weather_code',
|
||||||
|
'sunrise',
|
||||||
|
'sunset',
|
||||||
|
'uv_index_max',
|
||||||
|
'wind_speed_10m_max',
|
||||||
|
].join(','),
|
||||||
|
timezone: 'auto',
|
||||||
|
forecast_days: '7',
|
||||||
|
temperature_unit: tempUnit,
|
||||||
|
wind_speed_unit: windUnit,
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = `${FORECAST_BASE}?${params.toString()}`
|
||||||
|
const res = await fetch(url)
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Weather API error: ${res.status} ${res.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for locations by name using the Open-Meteo geocoding API.
|
||||||
|
* @param {string} query - Location name to search for
|
||||||
|
* @returns {Promise<Array<{ id: number, name: string, latitude: number, longitude: number, country: string, admin1: string, timezone: string }>>}
|
||||||
|
*/
|
||||||
|
export async function searchLocations(query) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
name: query,
|
||||||
|
count: '8',
|
||||||
|
language: 'en',
|
||||||
|
format: 'json',
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = `${GEOCODING_BASE}?${params.toString()}`
|
||||||
|
const res = await fetch(url)
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Geocoding API error: ${res.status} ${res.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
return (data.results || []).map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
latitude: r.latitude,
|
||||||
|
longitude: r.longitude,
|
||||||
|
country: r.country || '',
|
||||||
|
admin1: r.admin1 || '',
|
||||||
|
timezone: r.timezone || 'UTC',
|
||||||
|
}))
|
||||||
|
}
|
||||||
14
src/lib/autofocus.js
Normal file
14
src/lib/autofocus.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Svelte action: autofocus an element on mount.
|
||||||
|
* Usage: <input use:autofocus />
|
||||||
|
*/
|
||||||
|
export function autofocus(node) {
|
||||||
|
// Use requestAnimationFrame to ensure DOM is settled
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
node.focus()
|
||||||
|
// On mobile, also try after a slight delay for virtual keyboard
|
||||||
|
if ('ontouchstart' in window) {
|
||||||
|
setTimeout(() => node.focus(), 100)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
159
src/lib/storage/db.js
Normal file
159
src/lib/storage/db.js
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* IndexedDB storage layer using idb wrapper.
|
||||||
|
* Stores:
|
||||||
|
* - locations: Saved locations { id, name, lat, lon, isCurrent, order }
|
||||||
|
* - settings: App settings { unit, windUnit, alerts, thresholds }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { openDB } from 'idb'
|
||||||
|
|
||||||
|
const DB_NAME = 'weatherlens-db'
|
||||||
|
const DB_VERSION = 1
|
||||||
|
|
||||||
|
function getDb() {
|
||||||
|
return openDB(DB_NAME, DB_VERSION, {
|
||||||
|
upgrade(db) {
|
||||||
|
if (!db.objectStoreNames.contains('locations')) {
|
||||||
|
db.createObjectStore('locations', { keyPath: 'id' })
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains('settings')) {
|
||||||
|
db.createObjectStore('settings')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Locations =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all saved locations sorted by order.
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
export async function getLocations() {
|
||||||
|
const db = await getDb()
|
||||||
|
const all = await db.getAll('locations')
|
||||||
|
return all.sort((a, b) => (a.order || 0) - (b.order || 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new location.
|
||||||
|
* @param {object} location - { name, lat, lon, isCurrent, order? }
|
||||||
|
* @returns {Promise<string>} The created location id
|
||||||
|
*/
|
||||||
|
export async function addLocation(location) {
|
||||||
|
const db = await getDb()
|
||||||
|
const id = `loc_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`
|
||||||
|
const all = await db.getAll('locations')
|
||||||
|
const maxOrder = all.reduce((max, l) => Math.max(max, l.order || 0), 0)
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
id,
|
||||||
|
name: location.name,
|
||||||
|
lat: location.lat,
|
||||||
|
lon: location.lon,
|
||||||
|
isCurrent: !!location.isCurrent,
|
||||||
|
order: location.order != null ? location.order : maxOrder + 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.put('locations', entry)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing location.
|
||||||
|
* @param {object} location - Full location object with id
|
||||||
|
*/
|
||||||
|
export async function updateLocation(location) {
|
||||||
|
const db = await getDb()
|
||||||
|
const existing = await db.get('locations', location.id)
|
||||||
|
if (!existing) throw new Error('Location not found')
|
||||||
|
await db.put('locations', { ...existing, ...location })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a location by id.
|
||||||
|
* @param {string} id
|
||||||
|
*/
|
||||||
|
export async function deleteLocation(id) {
|
||||||
|
const db = await getDb()
|
||||||
|
await db.delete('locations', id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a location as "current" (GPS-based) and clear previous current flag.
|
||||||
|
* @param {string} id
|
||||||
|
*/
|
||||||
|
export async function setCurrentLocation(id) {
|
||||||
|
const db = await getDb()
|
||||||
|
const all = await db.getAll('locations')
|
||||||
|
for (const loc of all) {
|
||||||
|
if (loc.isCurrent && loc.id !== id) {
|
||||||
|
await db.put('locations', { ...loc, isCurrent: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const target = await db.get('locations', id)
|
||||||
|
if (target) {
|
||||||
|
await db.put('locations', { ...target, isCurrent: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder locations.
|
||||||
|
* @param {Array<{ id: string, order: number }>} orderUpdates
|
||||||
|
*/
|
||||||
|
export async function reorderLocations(orderUpdates) {
|
||||||
|
const db = await getDb()
|
||||||
|
for (const update of orderUpdates) {
|
||||||
|
const loc = await db.get('locations', update.id)
|
||||||
|
if (loc) {
|
||||||
|
await db.put('locations', { ...loc, order: update.order })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all locations (full reset).
|
||||||
|
*/
|
||||||
|
export async function clearAllLocations() {
|
||||||
|
const db = await getDb()
|
||||||
|
await db.clear('locations')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Settings =====
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
units: 'metric',
|
||||||
|
alertsEnabled: true,
|
||||||
|
alertThresholds: {
|
||||||
|
precip: 70,
|
||||||
|
windGust: 40,
|
||||||
|
uvIndex: 6,
|
||||||
|
tempHigh: 35,
|
||||||
|
tempLow: 0,
|
||||||
|
thunderstorm: true,
|
||||||
|
},
|
||||||
|
dismissedAlerts: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load settings. Returns defaults if none saved.
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function loadSettings() {
|
||||||
|
const db = await getDb()
|
||||||
|
const saved = await db.get('settings', 'prefs')
|
||||||
|
return saved ? { ...DEFAULT_SETTINGS, ...saved } : { ...DEFAULT_SETTINGS }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save settings.
|
||||||
|
* @param {object} settings
|
||||||
|
*/
|
||||||
|
export async function saveSettings(settings) {
|
||||||
|
const db = await getDb()
|
||||||
|
// JSON round-trip strips Svelte 5 $state proxies, which structuredClone
|
||||||
|
// (used internally by IndexedDB) cannot handle.
|
||||||
|
await db.put('settings', JSON.parse(JSON.stringify(settings)), 'prefs')
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DEFAULT_SETTINGS }
|
||||||
161
src/lib/stores/app.svelte.js
Normal file
161
src/lib/stores/app.svelte.js
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* App-level reactive state using Svelte 5 runes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getLocations, loadSettings } from '../storage/db.js'
|
||||||
|
import { fetchForecast } from '../api/weather.js'
|
||||||
|
|
||||||
|
export class AppStore {
|
||||||
|
// Location state
|
||||||
|
locations = $state([])
|
||||||
|
selectedLocationId = $state(null)
|
||||||
|
selectedLocation = $derived(
|
||||||
|
this.locations.find((l) => l.id === this.selectedLocationId) || null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Forecast data
|
||||||
|
forecastData = $state(null)
|
||||||
|
loading = $state(false)
|
||||||
|
error = $state('')
|
||||||
|
|
||||||
|
// Geocoding
|
||||||
|
geocodingResults = $state([])
|
||||||
|
geocodingLoading = $state(false)
|
||||||
|
geocodingError = $state('')
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
showAddLocation = $state(false)
|
||||||
|
showSettings = $state(false)
|
||||||
|
sidebarOpen = $state(false)
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
settings = $state({
|
||||||
|
units: 'metric',
|
||||||
|
alertsEnabled: true,
|
||||||
|
alertThresholds: {
|
||||||
|
precip: 70,
|
||||||
|
windGust: 40,
|
||||||
|
uvIndex: 6,
|
||||||
|
tempHigh: 35,
|
||||||
|
tempLow: 0,
|
||||||
|
thunderstorm: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the app: load locations + settings.
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
// Load settings first so we can use unit for forecasts
|
||||||
|
try {
|
||||||
|
this.settings = await loadSettings()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load settings:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load locations
|
||||||
|
try {
|
||||||
|
this.locations = await getLocations()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load locations:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load forecast for selected or first location
|
||||||
|
if (this.locations.length > 0) {
|
||||||
|
const current = this.locations.find((l) => l.isCurrent)
|
||||||
|
if (current) {
|
||||||
|
this.selectedLocationId = current.id
|
||||||
|
} else {
|
||||||
|
this.selectedLocationId = this.locations[0].id
|
||||||
|
}
|
||||||
|
await this.refreshForecast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh settings from DB.
|
||||||
|
*/
|
||||||
|
async refreshSettings() {
|
||||||
|
try {
|
||||||
|
this.settings = await loadSettings()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to refresh settings:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the forecasts list from DB.
|
||||||
|
*/
|
||||||
|
async refreshLocations() {
|
||||||
|
try {
|
||||||
|
this.locations = await getLocations()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to refresh locations:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch forecast data for the currently selected location.
|
||||||
|
*/
|
||||||
|
async refreshForecast(locationId = null) {
|
||||||
|
const locId = locationId || this.selectedLocationId
|
||||||
|
if (!locId) return
|
||||||
|
|
||||||
|
const location = this.locations.find((l) => l.id === locId)
|
||||||
|
if (!location) return
|
||||||
|
|
||||||
|
this.loading = true
|
||||||
|
this.error = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchForecast(location.lat, location.lon, this.settings.units)
|
||||||
|
this.forecastData = data
|
||||||
|
|
||||||
|
// Update the location name if we got a timezone abbreviation
|
||||||
|
if (data.timezone_abbreviation && !location.timezone) {
|
||||||
|
// We could update the location with timezone info, but skip for now
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message || 'Failed to fetch weather data'
|
||||||
|
console.error('Forecast fetch error:', e)
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a location and fetch its forecast.
|
||||||
|
* @param {string} id
|
||||||
|
*/
|
||||||
|
async selectLocation(id) {
|
||||||
|
this.selectedLocationId = id
|
||||||
|
this.sidebarOpen = false
|
||||||
|
await this.refreshForecast(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search locations via geocoding API.
|
||||||
|
* @param {string} query
|
||||||
|
*/
|
||||||
|
async searchGeocoding(query) {
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
this.geocodingResults = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.geocodingLoading = true
|
||||||
|
this.geocodingError = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { searchLocations } = await import('../api/weather.js')
|
||||||
|
this.geocodingResults = await searchLocations(query)
|
||||||
|
} catch (e) {
|
||||||
|
this.geocodingError = e.message || 'Search failed'
|
||||||
|
this.geocodingResults = []
|
||||||
|
} finally {
|
||||||
|
this.geocodingLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const app = new AppStore()
|
||||||
152
src/lib/stores/notifications.svelte.js
Normal file
152
src/lib/stores/notifications.svelte.js
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Weather alert / notification store using Svelte 5 runes.
|
||||||
|
* Monitors forecast data and generates alert banners.
|
||||||
|
* Dismissed alerts persist in IndexedDB and only reset when the forecast date changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isSevereWeather } from '../api/weather-codes.js'
|
||||||
|
import { app } from './app.svelte.js'
|
||||||
|
import { saveSettings } from '../storage/db.js'
|
||||||
|
|
||||||
|
export class NotificationStore {
|
||||||
|
/** @type {Array<{ id: string, type: string, severity: 'info'|'warning'|'danger', message: string, icon: string }>} */
|
||||||
|
alerts = $state([])
|
||||||
|
/** @type {Set<string>} */
|
||||||
|
dismissedIds = $state(new Set())
|
||||||
|
/** Track which forecast day last generated alerts — reset dismissals when the day changes */
|
||||||
|
lastForecastDate = null
|
||||||
|
/** Whether dismissals have been loaded from DB */
|
||||||
|
_dismissalsLoaded = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check forecast data and generate appropriate alerts.
|
||||||
|
*/
|
||||||
|
analyze() {
|
||||||
|
const data = app.forecastData
|
||||||
|
const settings = app.settings
|
||||||
|
|
||||||
|
if (!data || !settings.alertsEnabled) {
|
||||||
|
this.alerts = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load persisted dismissals from DB on first analysis
|
||||||
|
if (!this._dismissalsLoaded) {
|
||||||
|
this._dismissalsLoaded = true
|
||||||
|
this._loadDismissed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track the forecast date. If it changed, reset dismissals.
|
||||||
|
const forecastDate = data.daily?.time?.[0] || null
|
||||||
|
if (forecastDate && this.lastForecastDate && forecastDate !== this.lastForecastDate) {
|
||||||
|
this.resetDismissed()
|
||||||
|
}
|
||||||
|
this.lastForecastDate = forecastDate
|
||||||
|
|
||||||
|
const t = settings.alertThresholds
|
||||||
|
const newAlerts = []
|
||||||
|
|
||||||
|
function add(id, type, severity, message, icon) {
|
||||||
|
if (!app.settings.alertsEnabled) return
|
||||||
|
newAlerts.push({ id, type, severity, message, icon })
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = data.current
|
||||||
|
const daily = data.daily || {}
|
||||||
|
|
||||||
|
// --- Current weather alerts ---
|
||||||
|
|
||||||
|
if (t.thunderstorm && isSevereWeather(current.weather_code)) {
|
||||||
|
add('storm-now', 'storm', 'danger',
|
||||||
|
'⚡ Thunderstorm active — seek shelter if outdoors', '⛈️')
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Daily forecast alerts ---
|
||||||
|
|
||||||
|
if (daily.time) {
|
||||||
|
for (let i = 0; i < daily.time.length && i < 3; i++) {
|
||||||
|
const day = daily.time[i]
|
||||||
|
const dateLabel = i === 0 ? 'Today' : i === 1 ? 'Tomorrow' : new Date(day).toLocaleDateString('en-US', { weekday: 'short' })
|
||||||
|
|
||||||
|
const precip = daily.precipitation_probability_max?.[i] || 0
|
||||||
|
if (precip >= t.precip) {
|
||||||
|
add(`rain-${i}`, 'precip', 'warning',
|
||||||
|
`🌧️ ${precip}% chance of rain ${dateLabel.toLowerCase()}`, '🌧️')
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = daily.weather_code?.[i]
|
||||||
|
if (t.thunderstorm && isSevereWeather(code)) {
|
||||||
|
add(`storm-${i}`, 'storm', 'danger',
|
||||||
|
`⛈️ Thunderstorms expected ${dateLabel.toLowerCase()}`, '⛈️')
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempHigh = daily.temperature_2m_max?.[i]
|
||||||
|
if (tempHigh != null && tempHigh > t.tempHigh) {
|
||||||
|
add(`heat-${i}`, 'heat', 'warning',
|
||||||
|
`🔥 ${dateLabel} high of ${Math.round(tempHigh)}° — stay hydrated`, '🔥')
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempMin = daily.temperature_2m_min?.[i]
|
||||||
|
if (tempMin != null && tempMin < t.tempLow) {
|
||||||
|
add(`cold-${i}`, 'cold', 'warning',
|
||||||
|
`🥶 ${dateLabel} low of ${Math.round(tempMin)}° — bundle up`, '❄️')
|
||||||
|
}
|
||||||
|
|
||||||
|
const uv = daily.uv_index_max?.[i]
|
||||||
|
if (uv != null && uv >= t.uvIndex) {
|
||||||
|
add(`uv-${i}`, 'uv', 'warning',
|
||||||
|
`☀️ High UV index (${uv}) ${dateLabel.toLowerCase()} — wear sunscreen`, '🧴')
|
||||||
|
}
|
||||||
|
|
||||||
|
const wind = daily.wind_speed_10m_max?.[i]
|
||||||
|
if (wind != null && wind >= t.windGust) {
|
||||||
|
add(`wind-${i}`, 'wind', 'warning',
|
||||||
|
`💨 Strong winds (${Math.round(wind)} ${app.settings.units === 'imperial' ? 'mph' : 'km/h'}) ${dateLabel.toLowerCase()}`, '💨')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.alerts = newAlerts.filter((a) => !this.dismissedIds.has(a.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss a specific alert. Persists to IndexedDB.
|
||||||
|
* @param {string} alertId
|
||||||
|
*/
|
||||||
|
dismiss(alertId) {
|
||||||
|
this.dismissedIds.add(alertId)
|
||||||
|
this.alerts = this.alerts.filter((a) => a.id !== alertId)
|
||||||
|
this._persistDismissed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all dismissed alerts (e.g., when forecast date changes).
|
||||||
|
*/
|
||||||
|
resetDismissed() {
|
||||||
|
this.dismissedIds.clear()
|
||||||
|
this._persistDismissed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load dismissed alert IDs from the persisted settings.
|
||||||
|
*/
|
||||||
|
_loadDismissed() {
|
||||||
|
const saved = app.settings.dismissedAlerts || []
|
||||||
|
this.dismissedIds = new Set(saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save dismissed alert IDs to settings in IndexedDB.
|
||||||
|
*/
|
||||||
|
async _persistDismissed() {
|
||||||
|
const arr = Array.from(this.dismissedIds)
|
||||||
|
app.settings.dismissedAlerts = arr
|
||||||
|
try {
|
||||||
|
await saveSettings(app.settings)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to persist dismissed alerts:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notifications = new NotificationStore()
|
||||||
9
src/main.js
Normal file
9
src/main.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { mount } from 'svelte'
|
||||||
|
import './styles/main.css'
|
||||||
|
import App from './App.svelte'
|
||||||
|
|
||||||
|
const app = mount(App, {
|
||||||
|
target: document.getElementById('app'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app
|
||||||
297
src/styles/main.css
Normal file
297
src/styles/main.css
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
/* ===== CSS Reset & Base ===== */
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-bg: #0a1628;
|
||||||
|
--color-bg-alt: #0d1f3c;
|
||||||
|
--color-surface: #132744;
|
||||||
|
--color-surface-hover: #1a3359;
|
||||||
|
--color-surface-card: #183056;
|
||||||
|
--color-border: #1e3a66;
|
||||||
|
--color-border-light: #264a7a;
|
||||||
|
--color-text: #e8eef5;
|
||||||
|
--color-text-secondary: #c0cde0;
|
||||||
|
--color-text-muted: #7b8eb5;
|
||||||
|
|
||||||
|
/* Weather colors */
|
||||||
|
--color-sun: #fbbf24;
|
||||||
|
--color-sun-glow: rgba(251, 191, 36, 0.15);
|
||||||
|
--color-sky: #38bdf8;
|
||||||
|
--color-sky-glow: rgba(56, 189, 248, 0.12);
|
||||||
|
--color-rain: #60a5fa;
|
||||||
|
--color-rain-glow: rgba(96, 165, 250, 0.12);
|
||||||
|
--color-cloud: #94a3b8;
|
||||||
|
--color-storm: #818cf8;
|
||||||
|
--color-storm-glow: rgba(129, 140, 248, 0.15);
|
||||||
|
--color-snow: #e2e8f0;
|
||||||
|
--color-danger: #ef4444;
|
||||||
|
--color-danger-glow: rgba(239, 68, 68, 0.12);
|
||||||
|
--color-warning: #f59e0b;
|
||||||
|
--color-warning-glow: rgba(245, 158, 11, 0.12);
|
||||||
|
--color-success: #34d399;
|
||||||
|
--color-success-glow: rgba(52, 211, 153, 0.12);
|
||||||
|
|
||||||
|
--color-primary: #38bdf8;
|
||||||
|
--color-primary-hover: #0ea5e9;
|
||||||
|
--color-accent: #fbbf24;
|
||||||
|
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 10px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--radius-xl: 24px;
|
||||||
|
--shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
--shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
--transition: 180ms ease;
|
||||||
|
|
||||||
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Typography ===== */
|
||||||
|
h1 { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; }
|
||||||
|
h2 { font-size: 1.25rem; font-weight: 600; }
|
||||||
|
h3 { font-size: 1.05rem; font-weight: 600; }
|
||||||
|
h4 { font-size: 0.95rem; font-weight: 600; }
|
||||||
|
|
||||||
|
/* ===== Buttons ===== */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition), opacity var(--transition), transform var(--transition);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--color-primary), var(--color-primary-hover));
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(56, 189, 248, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, var(--color-primary-hover), #0284c7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: var(--color-danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-surface-hover);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
transition: color var(--transition), background-color var(--transition);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 36px;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background-color: var(--color-surface-hover);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Inputs ===== */
|
||||||
|
input[type="text"],
|
||||||
|
input[type="search"],
|
||||||
|
input[type="number"],
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color var(--transition), box-shadow var(--transition);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Cards ===== */
|
||||||
|
.card {
|
||||||
|
background: var(--color-surface-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-glass {
|
||||||
|
background: linear-gradient(135deg, var(--color-surface), var(--color-surface-card));
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 16px;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Scrollbar ===== */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Utility ===== */
|
||||||
|
.text-muted { color: var(--color-text-muted); }
|
||||||
|
.text-secondary { color: var(--color-text-secondary); }
|
||||||
|
.text-sm { font-size: 0.85rem; }
|
||||||
|
.text-xs { font-size: 0.75rem; }
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.mt-1 { margin-top: 4px; }
|
||||||
|
.mt-2 { margin-top: 8px; }
|
||||||
|
.mt-3 { margin-top: 12px; }
|
||||||
|
.mt-4 { margin-top: 16px; }
|
||||||
|
.mb-2 { margin-bottom: 8px; }
|
||||||
|
.mb-3 { margin-bottom: 12px; }
|
||||||
|
.mb-4 { margin-bottom: 16px; }
|
||||||
|
.flex { display: flex; }
|
||||||
|
.flex-col { flex-direction: column; }
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.gap-1 { gap: 4px; }
|
||||||
|
.gap-2 { gap: 8px; }
|
||||||
|
.gap-3 { gap: 12px; }
|
||||||
|
.gap-4 { gap: 16px; }
|
||||||
|
.w-full { width: 100%; }
|
||||||
|
.flex-1 { flex: 1; }
|
||||||
|
.truncate {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Animations ===== */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInScale {
|
||||||
|
from { opacity: 0; transform: scale(0.95); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from { opacity: 0; transform: translateY(-12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-down {
|
||||||
|
animation: slideDown 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse {
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
2
svelte.config.js
Normal file
2
svelte.config.js
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
|
||||||
|
export default {}
|
||||||
119
tests/lib/api/weather-codes.test.js
Normal file
119
tests/lib/api/weather-codes.test.js
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { getWeatherInfo, getWeatherSeverity, isSevereWeather } from '../../../src/lib/api/weather-codes.js'
|
||||||
|
|
||||||
|
describe('weather-codes', () => {
|
||||||
|
describe('getWeatherInfo', () => {
|
||||||
|
it('returns correct info for clear sky (0)', () => {
|
||||||
|
const info = getWeatherInfo(0)
|
||||||
|
expect(info.icon).toBe('☀️')
|
||||||
|
expect(info.label).toBe('Clear')
|
||||||
|
expect(info.desc).toBe('Clear sky')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns correct info for partly cloudy (2)', () => {
|
||||||
|
const info = getWeatherInfo(2)
|
||||||
|
expect(info.icon).toBe('⛅')
|
||||||
|
expect(info.label).toBe('Partly Cloudy')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns correct info for thunderstorm (95)', () => {
|
||||||
|
const info = getWeatherInfo(95)
|
||||||
|
expect(info.icon).toBe('⛈️')
|
||||||
|
expect(info.label).toBe('Thunderstorm')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns fallback for unknown code', () => {
|
||||||
|
const info = getWeatherInfo(999)
|
||||||
|
expect(info.icon).toBe('❓')
|
||||||
|
expect(info.label).toBe('Unknown')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles fog codes', () => {
|
||||||
|
expect(getWeatherInfo(45).icon).toBe('🌫️')
|
||||||
|
expect(getWeatherInfo(48).icon).toBe('🌫️')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles drizzle codes', () => {
|
||||||
|
expect(getWeatherInfo(51).icon).toBe('🌦️')
|
||||||
|
expect(getWeatherInfo(53).icon).toBe('🌦️')
|
||||||
|
expect(getWeatherInfo(55).icon).toBe('🌧️')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles rain codes', () => {
|
||||||
|
expect(getWeatherInfo(61).icon).toBe('🌧️')
|
||||||
|
expect(getWeatherInfo(63).icon).toBe('🌧️')
|
||||||
|
expect(getWeatherInfo(65).icon).toBe('🌧️')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles snow codes', () => {
|
||||||
|
expect(getWeatherInfo(71).icon).toBe('🌨️')
|
||||||
|
expect(getWeatherInfo(73).icon).toBe('🌨️')
|
||||||
|
expect(getWeatherInfo(75).icon).toBe('❄️')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles showers', () => {
|
||||||
|
expect(getWeatherInfo(80).icon).toBe('🌧️')
|
||||||
|
expect(getWeatherInfo(81).icon).toBe('🌧️')
|
||||||
|
expect(getWeatherInfo(82).icon).toBe('⛈️')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getWeatherSeverity', () => {
|
||||||
|
it('returns clear for code 0', () => {
|
||||||
|
expect(getWeatherSeverity(0)).toBe('clear')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns cloudy for codes 1-3', () => {
|
||||||
|
expect(getWeatherSeverity(1)).toBe('cloudy')
|
||||||
|
expect(getWeatherSeverity(3)).toBe('cloudy')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns fog for codes 45-48', () => {
|
||||||
|
expect(getWeatherSeverity(45)).toBe('fog')
|
||||||
|
expect(getWeatherSeverity(48)).toBe('fog')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns rain for codes 51-67', () => {
|
||||||
|
expect(getWeatherSeverity(51)).toBe('rain')
|
||||||
|
expect(getWeatherSeverity(65)).toBe('rain')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns snow for codes 71-86', () => {
|
||||||
|
expect(getWeatherSeverity(71)).toBe('snow')
|
||||||
|
expect(getWeatherSeverity(86)).toBe('snow')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns storm for codes 95-99', () => {
|
||||||
|
expect(getWeatherSeverity(95)).toBe('storm')
|
||||||
|
expect(getWeatherSeverity(99)).toBe('storm')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isSevereWeather', () => {
|
||||||
|
it('identifies heavy rain as severe', () => {
|
||||||
|
expect(isSevereWeather(65)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('identifies heavy snow as severe', () => {
|
||||||
|
expect(isSevereWeather(75)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('identifies violent rain showers as severe', () => {
|
||||||
|
expect(isSevereWeather(82)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('identifies thunderstorms as severe', () => {
|
||||||
|
expect(isSevereWeather(95)).toBe(true)
|
||||||
|
expect(isSevereWeather(96)).toBe(true)
|
||||||
|
expect(isSevereWeather(99)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not consider clear sky severe', () => {
|
||||||
|
expect(isSevereWeather(0)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not consider light drizzle severe', () => {
|
||||||
|
expect(isSevereWeather(51)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
139
tests/lib/storage/db.test.js
Normal file
139
tests/lib/storage/db.test.js
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import {
|
||||||
|
getLocations,
|
||||||
|
addLocation,
|
||||||
|
updateLocation,
|
||||||
|
deleteLocation,
|
||||||
|
setCurrentLocation,
|
||||||
|
clearAllLocations,
|
||||||
|
loadSettings,
|
||||||
|
saveSettings,
|
||||||
|
DEFAULT_SETTINGS,
|
||||||
|
} from '../../../src/lib/storage/db.js'
|
||||||
|
|
||||||
|
describe('db - locations', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await clearAllLocations()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts with empty locations', async () => {
|
||||||
|
const locs = await getLocations()
|
||||||
|
expect(locs).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds and retrieves a location', async () => {
|
||||||
|
const id = await addLocation({
|
||||||
|
name: 'Berlin',
|
||||||
|
lat: 52.52,
|
||||||
|
lon: 13.41,
|
||||||
|
isCurrent: false,
|
||||||
|
})
|
||||||
|
expect(id).toBeTruthy()
|
||||||
|
|
||||||
|
const locs = await getLocations()
|
||||||
|
expect(locs).toHaveLength(1)
|
||||||
|
expect(locs[0].name).toBe('Berlin')
|
||||||
|
expect(locs[0].lat).toBe(52.52)
|
||||||
|
expect(locs[0].lon).toBe(13.41)
|
||||||
|
expect(locs[0].isCurrent).toBe(false)
|
||||||
|
expect(locs[0].order).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds multiple locations with incrementing order', async () => {
|
||||||
|
await addLocation({ name: 'Berlin', lat: 52.52, lon: 13.41, isCurrent: false })
|
||||||
|
await addLocation({ name: 'Paris', lat: 48.85, lon: 2.35, isCurrent: false })
|
||||||
|
await addLocation({ name: 'Tokyo', lat: 35.68, lon: 139.76, isCurrent: false })
|
||||||
|
|
||||||
|
const locs = await getLocations()
|
||||||
|
expect(locs).toHaveLength(3)
|
||||||
|
expect(locs[0].order).toBe(1)
|
||||||
|
expect(locs[1].order).toBe(2)
|
||||||
|
expect(locs[2].order).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects custom order', async () => {
|
||||||
|
await addLocation({ name: 'First', lat: 1, lon: 1, isCurrent: false, order: 10 })
|
||||||
|
await addLocation({ name: 'Second', lat: 2, lon: 2, isCurrent: false })
|
||||||
|
|
||||||
|
const locs = await getLocations()
|
||||||
|
expect(locs[0].name).toBe('First')
|
||||||
|
expect(locs[1].name).toBe('Second')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates a location', async () => {
|
||||||
|
const id = await addLocation({ name: 'Berlin', lat: 52.52, lon: 13.41, isCurrent: false })
|
||||||
|
await updateLocation({ id, name: 'Berlin Updated', lat: 52.52, lon: 13.41, isCurrent: true, order: 5 })
|
||||||
|
|
||||||
|
const locs = await getLocations()
|
||||||
|
expect(locs[0].name).toBe('Berlin Updated')
|
||||||
|
expect(locs[0].isCurrent).toBe(true)
|
||||||
|
expect(locs[0].order).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when updating non-existent location', async () => {
|
||||||
|
await expect(
|
||||||
|
updateLocation({ id: 'nonexistent', name: 'Nope', lat: 0, lon: 0, isCurrent: false })
|
||||||
|
).rejects.toThrow('Location not found')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes a location', async () => {
|
||||||
|
const id = await addLocation({ name: 'Berlin', lat: 52.52, lon: 13.41, isCurrent: false })
|
||||||
|
await deleteLocation(id)
|
||||||
|
|
||||||
|
const locs = await getLocations()
|
||||||
|
expect(locs).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setCurrentLocation updates the current flag', async () => {
|
||||||
|
const id1 = await addLocation({ name: 'Berlin', lat: 52.52, lon: 13.41, isCurrent: true })
|
||||||
|
const id2 = await addLocation({ name: 'Paris', lat: 48.85, lon: 2.35, isCurrent: false })
|
||||||
|
|
||||||
|
await setCurrentLocation(id2)
|
||||||
|
|
||||||
|
const locs = await getLocations()
|
||||||
|
const berlin = locs.find((l) => l.id === id1)
|
||||||
|
const paris = locs.find((l) => l.id === id2)
|
||||||
|
expect(berlin.isCurrent).toBe(false)
|
||||||
|
expect(paris.isCurrent).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clearAllLocations removes all', async () => {
|
||||||
|
await addLocation({ name: 'Berlin', lat: 52.52, lon: 13.41, isCurrent: false })
|
||||||
|
await addLocation({ name: 'Paris', lat: 48.85, lon: 2.35, isCurrent: false })
|
||||||
|
|
||||||
|
await clearAllLocations()
|
||||||
|
const locs = await getLocations()
|
||||||
|
expect(locs).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('db - settings', () => {
|
||||||
|
it('loads default settings when none saved', async () => {
|
||||||
|
const settings = await loadSettings()
|
||||||
|
expect(settings.units).toBe('metric')
|
||||||
|
expect(settings.alertsEnabled).toBe(true)
|
||||||
|
expect(settings.alertThresholds.precip).toBe(70)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves and loads settings', async () => {
|
||||||
|
await saveSettings({
|
||||||
|
units: 'imperial',
|
||||||
|
alertsEnabled: false,
|
||||||
|
alertThresholds: { precip: 80, windGust: 50, uvIndex: 8, tempHigh: 40, tempLow: -5, thunderstorm: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const settings = await loadSettings()
|
||||||
|
expect(settings.units).toBe('imperial')
|
||||||
|
expect(settings.alertsEnabled).toBe(false)
|
||||||
|
expect(settings.alertThresholds.precip).toBe(80)
|
||||||
|
expect(settings.alertThresholds.thunderstorm).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merges partial settings with defaults', async () => {
|
||||||
|
await saveSettings({ units: 'imperial' })
|
||||||
|
|
||||||
|
const settings = await loadSettings()
|
||||||
|
expect(settings.units).toBe('imperial')
|
||||||
|
expect(settings.alertsEnabled).toBe(true) // default preserved
|
||||||
|
})
|
||||||
|
})
|
||||||
46
tests/lib/stores/app.test.js
Normal file
46
tests/lib/stores/app.test.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { AppStore } from '../../../src/lib/stores/app.svelte.js'
|
||||||
|
|
||||||
|
describe('AppStore', () => {
|
||||||
|
let app
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = new AppStore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('initializes with default state', () => {
|
||||||
|
expect(app.locations).toEqual([])
|
||||||
|
expect(app.selectedLocationId).toBeNull()
|
||||||
|
expect(app.forecastData).toBeNull()
|
||||||
|
expect(app.loading).toBe(false)
|
||||||
|
expect(app.error).toBe('')
|
||||||
|
expect(app.showAddLocation).toBe(false)
|
||||||
|
expect(app.showSettings).toBe(false)
|
||||||
|
expect(app.sidebarOpen).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has default settings', () => {
|
||||||
|
expect(app.settings.units).toBe('metric')
|
||||||
|
expect(app.settings.alertsEnabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selectedLocation is derived correctly', () => {
|
||||||
|
expect(app.selectedLocation).toBeNull()
|
||||||
|
|
||||||
|
// Set up locations manually
|
||||||
|
app.locations = [
|
||||||
|
{ id: 'loc1', name: 'Berlin', lat: 52.52, lon: 13.41, isCurrent: false, order: 1 },
|
||||||
|
]
|
||||||
|
app.selectedLocationId = 'loc1'
|
||||||
|
|
||||||
|
expect(app.selectedLocation).toBeTruthy()
|
||||||
|
expect(app.selectedLocation.name).toBe('Berlin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selectedLocation returns null when id not found', () => {
|
||||||
|
app.locations = [{ id: 'loc1', name: 'Berlin', lat: 52.52, lon: 13.41, isCurrent: false, order: 1 }]
|
||||||
|
app.selectedLocationId = 'nonexistent'
|
||||||
|
|
||||||
|
expect(app.selectedLocation).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
169
tests/lib/stores/notifications.test.js
Normal file
169
tests/lib/stores/notifications.test.js
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { NotificationStore } from '../../../src/lib/stores/notifications.svelte.js'
|
||||||
|
import { app } from '../../../src/lib/stores/app.svelte.js'
|
||||||
|
|
||||||
|
// We need to mock app.forecastData and app.settings for alert analysis
|
||||||
|
describe('NotificationStore', () => {
|
||||||
|
let notifications
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
notifications = new NotificationStore()
|
||||||
|
notifications.alerts = []
|
||||||
|
notifications.dismissedIds.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
function setForecast(overrides = {}) {
|
||||||
|
app.forecastData = {
|
||||||
|
current: {
|
||||||
|
weather_code: 0,
|
||||||
|
...overrides.current,
|
||||||
|
},
|
||||||
|
daily: {
|
||||||
|
time: ['2026-07-22', '2026-07-23', '2026-07-24'],
|
||||||
|
precipitation_probability_max: [10, 5, 0],
|
||||||
|
weather_code: [0, 1, 2],
|
||||||
|
temperature_2m_max: [25, 27, 28],
|
||||||
|
temperature_2m_min: [15, 16, 17],
|
||||||
|
uv_index_max: [3, 4, 5],
|
||||||
|
wind_speed_10m_max: [15, 12, 10],
|
||||||
|
...overrides.daily,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
app.settings = {
|
||||||
|
alertsEnabled: true,
|
||||||
|
windUnit: 'kmh',
|
||||||
|
alertThresholds: {
|
||||||
|
precip: 70,
|
||||||
|
windGust: 40,
|
||||||
|
uvIndex: 6,
|
||||||
|
tempHigh: 35,
|
||||||
|
tempLow: 0,
|
||||||
|
thunderstorm: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does not generate alerts when disabled', () => {
|
||||||
|
setForecast()
|
||||||
|
app.settings.alertsEnabled = false
|
||||||
|
|
||||||
|
notifications.analyze()
|
||||||
|
expect(notifications.alerts).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates no alerts for calm weather', () => {
|
||||||
|
setForecast()
|
||||||
|
notifications.analyze()
|
||||||
|
expect(notifications.alerts).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates precipitation alert when threshold exceeded', () => {
|
||||||
|
setForecast({
|
||||||
|
daily: { precipitation_probability_max: [85, 30, 10] },
|
||||||
|
})
|
||||||
|
|
||||||
|
notifications.analyze()
|
||||||
|
expect(notifications.alerts.some((a) => a.type === 'precip')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates thunderstorm alert for severe codes in forecast', () => {
|
||||||
|
setForecast({
|
||||||
|
daily: { weather_code: [95, 0, 0] },
|
||||||
|
})
|
||||||
|
|
||||||
|
notifications.analyze()
|
||||||
|
expect(notifications.alerts.some((a) => a.type === 'storm')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates thunderstorm alert for current conditions', () => {
|
||||||
|
setForecast({
|
||||||
|
current: { weather_code: 95 },
|
||||||
|
})
|
||||||
|
|
||||||
|
notifications.analyze()
|
||||||
|
expect(notifications.alerts.some((a) => a.id === 'storm-now')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates heat alert', () => {
|
||||||
|
setForecast({
|
||||||
|
daily: { temperature_2m_max: [38, 35, 32] },
|
||||||
|
})
|
||||||
|
|
||||||
|
notifications.analyze()
|
||||||
|
expect(notifications.alerts.some((a) => a.type === 'heat')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates cold alert', () => {
|
||||||
|
setForecast({
|
||||||
|
daily: { temperature_2m_min: [-5, 0, 5] },
|
||||||
|
})
|
||||||
|
|
||||||
|
notifications.analyze()
|
||||||
|
expect(notifications.alerts.some((a) => a.type === 'cold')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates UV alert', () => {
|
||||||
|
setForecast({
|
||||||
|
daily: { uv_index_max: [8, 5, 3] },
|
||||||
|
})
|
||||||
|
|
||||||
|
notifications.analyze()
|
||||||
|
expect(notifications.alerts.some((a) => a.type === 'uv')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates wind alert', () => {
|
||||||
|
setForecast({
|
||||||
|
daily: { wind_speed_10m_max: [50, 20, 15] },
|
||||||
|
})
|
||||||
|
|
||||||
|
notifications.analyze()
|
||||||
|
expect(notifications.alerts.some((a) => a.type === 'wind')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dismisses an alert', () => {
|
||||||
|
setForecast({
|
||||||
|
daily: { precipitation_probability_max: [85, 30, 10] },
|
||||||
|
})
|
||||||
|
|
||||||
|
notifications.analyze()
|
||||||
|
const precipAlert = notifications.alerts.find((a) => a.type === 'precip')
|
||||||
|
expect(precipAlert).toBeTruthy()
|
||||||
|
|
||||||
|
notifications.dismiss(precipAlert.id)
|
||||||
|
expect(notifications.alerts.find((a) => a.id === precipAlert.id)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets dismissed alerts', () => {
|
||||||
|
setForecast({
|
||||||
|
daily: { precipitation_probability_max: [85, 30, 10] },
|
||||||
|
})
|
||||||
|
|
||||||
|
notifications.analyze()
|
||||||
|
const alertCount = notifications.alerts.length
|
||||||
|
notifications.dismiss(notifications.alerts[0].id)
|
||||||
|
|
||||||
|
notifications.resetDismissed()
|
||||||
|
notifications.analyze()
|
||||||
|
expect(notifications.alerts.length).toBe(alertCount)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters alerts for only first 3 days', () => {
|
||||||
|
setForecast({
|
||||||
|
daily: {
|
||||||
|
time: ['2026-07-22', '2026-07-23', '2026-07-24', '2026-07-25', '2026-07-26', '2026-07-27', '2026-07-28'],
|
||||||
|
precipitation_probability_max: [80, 80, 80, 80, 80, 80, 80],
|
||||||
|
weather_code: [0, 0, 0, 0, 0, 0, 0],
|
||||||
|
temperature_2m_max: [25, 25, 25, 25, 25, 25, 25],
|
||||||
|
temperature_2m_min: [15, 15, 15, 15, 15, 15, 15],
|
||||||
|
uv_index_max: [3, 3, 3, 3, 3, 3, 3],
|
||||||
|
wind_speed_10m_max: [15, 15, 15, 15, 15, 15, 15],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
notifications.analyze()
|
||||||
|
const precipAlerts = notifications.alerts.filter((a) => a.type === 'precip')
|
||||||
|
// Should only alert for the first 3 days (i < 3)
|
||||||
|
expect(precipAlerts.length).toBeLessThanOrEqual(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
27
tests/setup.js
Normal file
27
tests/setup.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import 'fake-indexeddb/auto'
|
||||||
|
|
||||||
|
// jsdom does not provide crypto.subtle — polyfill it with the real Web Crypto API
|
||||||
|
if (typeof globalThis.crypto === 'undefined' || !globalThis.crypto.subtle) {
|
||||||
|
const { webcrypto } = await import('node:crypto')
|
||||||
|
globalThis.crypto = webcrypto
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock window/document APIs
|
||||||
|
Object.defineProperty(globalThis, 'navigator', {
|
||||||
|
value: {
|
||||||
|
userAgent: 'node',
|
||||||
|
geolocation: {
|
||||||
|
getCurrentPosition: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock matchMedia
|
||||||
|
globalThis.matchMedia = globalThis.matchMedia || function () {
|
||||||
|
return {
|
||||||
|
matches: false,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
37
vite.config.js
Normal file
37
vite.config.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
import { viteSingleFile } from 'vite-plugin-singlefile'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig(({ command }) => ({
|
||||||
|
plugins: [
|
||||||
|
svelte(),
|
||||||
|
...(command === 'build'
|
||||||
|
? [viteSingleFile({
|
||||||
|
inlineStyles: true,
|
||||||
|
inlineScripts: true,
|
||||||
|
removeUnusedCss: true,
|
||||||
|
removeViteModuleLoader: true,
|
||||||
|
})]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
build: {
|
||||||
|
target: 'esnext',
|
||||||
|
minify: false,
|
||||||
|
rollupOptions: {
|
||||||
|
treeshake: {
|
||||||
|
moduleSideEffects: true,
|
||||||
|
annotations: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
allowedHosts: ["dev.thecookiejar.me"]
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./tests/setup.js'],
|
||||||
|
include: ['src/**/*.test.js', 'tests/**/*.test.js'],
|
||||||
|
},
|
||||||
|
}))
|
||||||
Loading…
x
Reference in New Issue
Block a user