weather_app/src/lib/stores/app.svelte.js
hermes-explorigin 897f49ae22
All checks were successful
Test, Build & Deploy / test-and-build (push) Successful in 1m1s
Test, Build & Deploy / deploy (push) Successful in 22s
Add installable PWA (manifest, service worker, icons) + per-type notification toggles
- manifest.webmanifest + apple/maskable PNG icons; relative paths so the
  app works both at dev root and under /weather
- plain-JS service worker (sw.js) with notificationclick/push handlers and
  an SW-mediated showNotification() path for native Android notifications
- src/lib/pwa.js bootstraps registration and routes alerts to the SW
- Every alert kind (storm/precip/heat/cold/uv/wind) is now a per-type
  preference toggle in Settings; removed the old alertThresholds.thunderstorm
  boolean in favor of notificationTypes.storm
- Store dedups native notifications per forecast day and fires best-effort
  native notifications for newly-appeared alerts
- inline-assets keeps manifest/sw/icons in dist; deploy.yml uploads + verifies
  all PWA files via WebDAV
2026-09-08 00:59:19 +00:00

218 lines
5.6 KiB
JavaScript

/**
* App-level reactive state using Svelte 5 runes.
*/
import { getLocations, loadSettings } from '../storage/db.js'
import { fetchForecast } from '../api/weather.js'
import { fetchForecastNWS, mergeFallbackData } from '../api/nws.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',
source: 'open-meteo',
refreshInterval: 30,
alertsEnabled: true,
showLightning: false, // toggleable lightning strike layer on the radar map
notificationTypes: {
storm: true,
precip: true,
heat: true,
cold: true,
uv: true,
wind: true,
},
alertThresholds: {
precip: 70,
windGust: 40,
uvIndex: 6,
tempHigh: 35,
tempLow: 0,
},
})
/**
* 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 units = this.settings.units
const source = this.settings.source || 'open-meteo'
let data
if (source === 'nws') {
try {
data = await fetchForecastNWS(location.lat, location.lon, units)
// NWS omits pressure + UV index — backfill those from Open-Meteo and
// flag them so the UI can tooltip that they came from the other source.
try {
const om = await fetchForecast(location.lat, location.lon, units)
data = mergeFallbackData(data, om)
} catch (omErr) {
console.warn('Open-Meteo fields unavailable:', omErr.message)
}
} catch (nwsErr) {
// NWS is US-only and can be unavailable — fall back to Open-Meteo
// so the app keeps working rather than breaking.
console.warn('NWS unavailable, falling back to Open-Meteo:', nwsErr.message)
data = await fetchForecast(location.lat, location.lon, units)
}
} else {
data = await fetchForecast(location.lat, location.lon, 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
}
}
/**
* Start (or restart) the auto-refresh timer based on the configured
* interval. A value of 0 (or missing) disables auto-refresh.
*/
startAutoRefresh() {
this.stopAutoRefresh()
const minutes = this.settings.refreshInterval
if (!minutes || minutes <= 0) return
this._refreshTimer = setInterval(() => {
this.refreshForecast()
}, minutes * 60 * 1000)
}
/**
* Stop the auto-refresh timer if it is running.
*/
stopAutoRefresh() {
if (this._refreshTimer) {
clearInterval(this._refreshTimer)
this._refreshTimer = null
}
}
/**
* 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()