feat(weather): 5-min forecast cache + location-change loading indicator
All checks were successful
Test, Build & Deploy / test-and-build (push) Successful in 53s
Test, Build & Deploy / deploy (push) Successful in 24s

This commit is contained in:
hermes-explorigin 2026-10-17 16:49:51 +00:00
parent 4bb08c86a4
commit ae71c36acc
5 changed files with 163 additions and 11 deletions

View File

@ -306,6 +306,16 @@
</div> </div>
{/if} {/if}
<!-- Non-blocking refresh indicator (location switch while data is shown) -->
{#if app.loading && app.forecastData}
<div class="refreshing-overlay" aria-live="polite" aria-busy="true">
<div class="refreshing-chip">
<span class="loading-spinner small">⏳</span>
<span>Updating…</span>
</div>
</div>
{/if}
<!-- Weather content --> <!-- Weather content -->
{#if app.forecastData && app.selectedLocation} {#if app.forecastData && app.selectedLocation}
<!-- Mobile bottom nav --> <!-- Mobile bottom nav -->
@ -567,6 +577,33 @@
animation: spin 1.5s linear infinite; animation: spin 1.5s linear infinite;
} }
/* Non-blocking "Updating…" chip shown while a location change refetches. */
.refreshing-overlay {
position: sticky;
top: 8px;
z-index: 45;
display: flex;
justify-content: center;
pointer-events: none;
}
.refreshing-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
background: var(--color-surface);
border: 1px solid var(--color-border);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.loading-spinner.small {
font-size: 1rem;
}
@keyframes spin { @keyframes spin {
from { transform: rotate(0deg); } from { transform: rotate(0deg); }
to { transform: rotate(360deg); } to { transform: rotate(360deg); }

View File

@ -5,6 +5,7 @@
import { getLocations, loadSettings } from '../storage/db.js' import { getLocations, loadSettings } from '../storage/db.js'
import { fetchForecast } from '../api/weather.js' import { fetchForecast } from '../api/weather.js'
import { fetchForecastNWS, mergeFallbackData } from '../api/nws.js' import { fetchForecastNWS, mergeFallbackData } from '../api/nws.js'
import { cacheKey, getCachedForecast, setCachedForecast } from './forecast-cache.js'
export class AppStore { export class AppStore {
// Location state // Location state
@ -108,19 +109,32 @@ export class AppStore {
/** /**
* Fetch forecast data for the currently selected location. * Fetch forecast data for the currently selected location.
*/ */
async refreshForecast(locationId = null) { async refreshForecast(locationId = null, { force = false } = {}) {
const locId = locationId || this.selectedLocationId const locId = locationId || this.selectedLocationId
if (!locId) return if (!locId) return
const location = this.locations.find((l) => l.id === locId) const location = this.locations.find((l) => l.id === locId)
if (!location) return if (!location) return
const units = this.settings.units
const source = this.settings.source || 'open-meteo'
const key = cacheKey(location, source, units)
// Serve a fresh cache entry immediately — no spinner, no network.
if (!force) {
const cached = getCachedForecast(key)
if (cached) {
this.forecastData = cached
this.error = ''
this.loading = false
return
}
}
this.loading = true this.loading = true
this.error = '' this.error = ''
try { try {
const units = this.settings.units
const source = this.settings.source || 'open-meteo'
let data let data
if (source === 'nws') { if (source === 'nws') {
try { try {
@ -143,11 +157,7 @@ export class AppStore {
data = await fetchForecast(location.lat, location.lon, units) data = await fetchForecast(location.lat, location.lon, units)
} }
this.forecastData = data this.forecastData = data
setCachedForecast(key, 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) { } catch (e) {
this.error = e.message || 'Failed to fetch weather data' this.error = e.message || 'Failed to fetch weather data'
console.error('Forecast fetch error:', e) console.error('Forecast fetch error:', e)
@ -165,7 +175,7 @@ export class AppStore {
const minutes = this.settings.refreshInterval const minutes = this.settings.refreshInterval
if (!minutes || minutes <= 0) return if (!minutes || minutes <= 0) return
this._refreshTimer = setInterval(() => { this._refreshTimer = setInterval(() => {
this.refreshForecast() this.refreshForecast(null, { force: true })
}, minutes * 60 * 1000) }, minutes * 60 * 1000)
} }

View File

@ -0,0 +1,27 @@
/** In-memory forecast cache with a fixed 5-minute TTL. */
export const TTL_MS = 5 * 60 * 1000
const store = new Map() // key -> { data, ts }
/** Key includes units/source so a unit or source change cannot serve wrong data. */
export function cacheKey(location, source, units) {
return `${location.lat},${location.lon}|${source}|${units}`
}
export function getCachedForecast(key, now = Date.now()) {
const hit = store.get(key)
if (!hit) return null
if (now - hit.ts >= TTL_MS) {
store.delete(key)
return null
}
return hit.data
}
export function setCachedForecast(key, data, now = Date.now()) {
store.set(key, { data, ts: now })
}
export function clearForecastCache() {
store.clear()
}

View File

@ -1,5 +1,10 @@
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
import { AppStore } from '../../../src/lib/stores/app.svelte.js' import { AppStore } from '../../../src/lib/stores/app.svelte.js'
import { fetchForecast } from '../../../src/lib/api/weather.js'
import { clearForecastCache } from '../../../src/lib/stores/forecast-cache.js'
vi.mock('../../../src/lib/api/weather.js', () => ({ fetchForecast: vi.fn(), searchLocations: vi.fn() }))
vi.mock('../../../src/lib/api/nws.js', () => ({ fetchForecastNWS: vi.fn(), mergeFallbackData: vi.fn((a) => a) }))
describe('AppStore', () => { describe('AppStore', () => {
let app let app
@ -27,7 +32,6 @@ describe('AppStore', () => {
it('selectedLocation is derived correctly', () => { it('selectedLocation is derived correctly', () => {
expect(app.selectedLocation).toBeNull() expect(app.selectedLocation).toBeNull()
// Set up locations manually
app.locations = [ app.locations = [
{ id: 'loc1', name: 'Berlin', lat: 52.52, lon: 13.41, isCurrent: false, order: 1 }, { id: 'loc1', name: 'Berlin', lat: 52.52, lon: 13.41, isCurrent: false, order: 1 },
] ]
@ -44,3 +48,47 @@ describe('AppStore', () => {
expect(app.selectedLocation).toBeNull() expect(app.selectedLocation).toBeNull()
}) })
}) })
const LOC = { id: 'loc1', name: 'Home', lat: 36.35, lon: -95.81, isCurrent: false }
describe('AppStore forecast caching', () => {
let app
beforeEach(() => {
vi.clearAllMocks()
clearForecastCache()
app = new AppStore()
app.locations = [LOC]
app.selectedLocationId = LOC.id
fetchForecast.mockResolvedValue({ current: { temperature_2m: 20 } })
})
it('fetches once then serves the cache on the next call', async () => {
await app.refreshForecast()
expect(fetchForecast).toHaveBeenCalledTimes(1)
await app.refreshForecast()
expect(fetchForecast).toHaveBeenCalledTimes(1) // cache hit, no second fetch
})
it('sets loading=true while a cache-miss fetch is in flight, then false', async () => {
const p = app.refreshForecast()
expect(app.loading).toBe(true)
await p
expect(app.loading).toBe(false)
expect(app.forecastData).toEqual({ current: { temperature_2m: 20 } })
})
it('a cache hit does not set loading', async () => {
await app.refreshForecast()
const p = app.refreshForecast()
expect(app.loading).toBe(false) // served from cache synchronously
await p
expect(fetchForecast).toHaveBeenCalledTimes(1)
})
it('force=true bypasses a fresh cache entry', async () => {
await app.refreshForecast()
await app.refreshForecast(null, { force: true })
expect(fetchForecast).toHaveBeenCalledTimes(2)
})
})

View File

@ -0,0 +1,30 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
cacheKey, getCachedForecast, setCachedForecast, clearForecastCache, TTL_MS,
} from '../../../src/lib/stores/forecast-cache.js'
describe('forecast cache', () => {
beforeEach(() => clearForecastCache())
it('builds a stable key from coords/source/units', () => {
expect(cacheKey({ lat: 36.35, lon: -95.81 }, 'open-meteo', 'metric'))
.toBe('36.35,-95.81|open-meteo|metric')
})
it('returns data within TTL', () => {
const now = 1_000_000
setCachedForecast('k', { hi: 1 }, now)
expect(getCachedForecast('k', now + TTL_MS - 1)).toEqual({ hi: 1 })
})
it('expires and deletes after TTL', () => {
const now = 1_000_000
setCachedForecast('k', { hi: 1 }, now)
expect(getCachedForecast('k', now + TTL_MS + 1)).toBeNull()
expect(getCachedForecast('k', now + TTL_MS + 2)).toBeNull()
})
it('misses on unknown key', () => {
expect(getCachedForecast('nope')).toBeNull()
})
})