diff --git a/src/App.svelte b/src/App.svelte index fe16c3a..0fe1a59 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -306,6 +306,16 @@ {/if} + + {#if app.loading && app.forecastData} +
+
+ ⏳ + Updating… +
+
+ {/if} + {#if app.forecastData && app.selectedLocation} @@ -567,6 +577,33 @@ 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 { from { transform: rotate(0deg); } to { transform: rotate(360deg); } diff --git a/src/lib/stores/app.svelte.js b/src/lib/stores/app.svelte.js index 1417b95..829ac1a 100644 --- a/src/lib/stores/app.svelte.js +++ b/src/lib/stores/app.svelte.js @@ -5,6 +5,7 @@ import { getLocations, loadSettings } from '../storage/db.js' import { fetchForecast } from '../api/weather.js' import { fetchForecastNWS, mergeFallbackData } from '../api/nws.js' +import { cacheKey, getCachedForecast, setCachedForecast } from './forecast-cache.js' export class AppStore { // Location state @@ -108,19 +109,32 @@ export class AppStore { /** * Fetch forecast data for the currently selected location. */ - async refreshForecast(locationId = null) { + async refreshForecast(locationId = null, { force = false } = {}) { const locId = locationId || this.selectedLocationId if (!locId) return const location = this.locations.find((l) => l.id === locId) 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.error = '' try { - const units = this.settings.units - const source = this.settings.source || 'open-meteo' let data if (source === 'nws') { try { @@ -143,11 +157,7 @@ export class AppStore { 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 - } + setCachedForecast(key, data) } catch (e) { this.error = e.message || 'Failed to fetch weather data' console.error('Forecast fetch error:', e) @@ -165,7 +175,7 @@ export class AppStore { const minutes = this.settings.refreshInterval if (!minutes || minutes <= 0) return this._refreshTimer = setInterval(() => { - this.refreshForecast() + this.refreshForecast(null, { force: true }) }, minutes * 60 * 1000) } diff --git a/src/lib/stores/forecast-cache.js b/src/lib/stores/forecast-cache.js new file mode 100644 index 0000000..6b9bc24 --- /dev/null +++ b/src/lib/stores/forecast-cache.js @@ -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() +} diff --git a/tests/lib/stores/app.test.js b/tests/lib/stores/app.test.js index c39222b..6238c94 100644 --- a/tests/lib/stores/app.test.js +++ b/tests/lib/stores/app.test.js @@ -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 { 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', () => { let app @@ -27,7 +32,6 @@ describe('AppStore', () => { 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 }, ] @@ -44,3 +48,47 @@ describe('AppStore', () => { 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) + }) +}) diff --git a/tests/lib/stores/forecast-cache.test.js b/tests/lib/stores/forecast-cache.test.js new file mode 100644 index 0000000..928082e --- /dev/null +++ b/tests/lib/stores/forecast-cache.test.js @@ -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() + }) +})