- 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
218 lines
5.6 KiB
JavaScript
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()
|