feat(weather): 5-min forecast cache + location-change loading indicator
This commit is contained in:
parent
4bb08c86a4
commit
ae71c36acc
@ -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); }
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
27
src/lib/stores/forecast-cache.js
Normal file
27
src/lib/stores/forecast-cache.js
Normal 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()
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
30
tests/lib/stores/forecast-cache.test.js
Normal file
30
tests/lib/stores/forecast-cache.test.js
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user