From 75537d0250da16e6f0c266c2dbdf1347b719acc1 Mon Sep 17 00:00:00 2001 From: fraxle Date: Fri, 29 May 2026 12:09:55 +0100 Subject: [PATCH] 2.0.7 Update cache to include backup calls to ICON data --- assets/js/app.js | 4 +- assets/js/hooks/useAppState.js | 2 +- assets/js/hooks/useForecast.js | 114 +++++++++++++++++++++++++++++---- 3 files changed, 106 insertions(+), 14 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index f61b076..b7a899a 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -753,9 +753,9 @@ ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeC ${fmt(r.concreteT)}${u('°C')} `} ${visibleCols.soilT && html` - ${fmt(r.soilT0)}${u('°C')}`} + ${r.soilT0 != null ? fmt(r.soilT0) + u('°C') : '—'}`} ${visibleCols.soilT6 && html` - ${fmt(r.soilT6)}${u('°C')}`} + ${r.soilT6 != null ? fmt(r.soilT6) + u('°C') : '—'}`} ${visibleCols.soilM && html` { const sm = soilMoistureBg(r.soilM); return { color: sm.fg, background: sm.bg }; })()}>${r.soilM != null ? fmt(r.soilM * 100, 1) + u('%') : '—'}`} ${visibleCols.air && html`${fmt(r.Ta)}${u('°C')}`} diff --git a/assets/js/hooks/useAppState.js b/assets/js/hooks/useAppState.js index d6ab5f1..d74707e 100644 --- a/assets/js/hooks/useAppState.js +++ b/assets/js/hooks/useAppState.js @@ -44,7 +44,7 @@ export function useAppState() { const saved = localStorage.getItem('sunscope_last_location'); if (saved) return JSON.parse(saved); } catch (e) { /* ignore */ } - return { name: 'Pangbourne, Berkshire', lat: 51.4839, lon: -1.0725, country: 'GB' }; + return { name: 'London, England', lat: 51.509, lon: -0.126, country: 'GB' }; }); const setLocationAndSave = (loc) => { diff --git a/assets/js/hooks/useForecast.js b/assets/js/hooks/useForecast.js index b876a99..f753930 100644 --- a/assets/js/hooks/useForecast.js +++ b/assets/js/hooks/useForecast.js @@ -3,13 +3,18 @@ // given location and keeps them fresh. // // Responsibilities: -// 1. Fetch /v1/forecast on location change, with a 15-minute +// 1. Fetch /v1/forecast on location change, with a 5-minute // localStorage cache. fetchedAt reflects the cache timestamp. -// 2. Fetch /v1/air-quality on location change, with a 6-hour +// 2. Soil data (soil_temperature, soil_moisture) waterfall: +// a. Main auto API — works if the regional model includes soil. +// b. ICON Global fallback — used when the auto model returns nulls +// (e.g. UK Met Office / MetNo don't expose soil variables). +// c. Stale soil cache — used if ICON fetch fails. +// 3. Fetch /v1/air-quality on location change, with a 6-hour // localStorage cache (AQI / pollen update slowly). -// 3. Auto-refresh forecast every 15 minutes (matches Open-Meteo +// 4. Auto-refresh forecast every 15 minutes (matches Open-Meteo // update cadence). Air quality only refetches if cache is stale. -// 4. Tick `now` every 2 minutes to keep the current-row highlight +// 5. Tick `now` every 2 minutes to keep the current-row highlight // accurate without any API cost. // // Inputs: @@ -26,6 +31,7 @@ import { useState, useEffect } from '../../vendor/preact-hooks.js'; +const FIVE_MIN_MS = 5 * 60 * 1000; const FIFTEEN_MIN_MS = 15 * 60 * 1000; const SIX_HOURS_MS = 6 * 60 * 60 * 1000; const TWO_MIN_MS = 2 * 60 * 1000; @@ -59,6 +65,60 @@ function airQualityCacheKey(loc) { return `sunscope_aq_${loc.lat.toFixed(4)}_${loc.lon.toFixed(4)}`; } +// ─── SOIL (ECMWF) ───────────────────────────────────────────────────────── +// Open-Meteo's auto-selected regional models (UK Met Office, MetNo, etc.) +// don't include soil variables — they return all-null arrays. ECMWF IFS +// covers soil globally so we fetch soil fields separately and merge them in. + +function buildSoilUrl(loc) { + return `https://api.open-meteo.com/v1/forecast` + + `?latitude=${loc.lat}&longitude=${loc.lon}` + + `&hourly=soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` + + `&timezone=auto&forecast_days=14&models=icon_global`; +} + +function soilCacheKey(loc) { + return `sunscope_soil_${loc.lat.toFixed(4)}_${loc.lon.toFixed(4)}`; +} + +// Fetches ICON soil hourly data with its own cache. +// Returns stale cache of any age if the fetch fails — caller uses this as last resort. +async function fetchSoilHourly(loc) { + const key = soilCacheKey(loc); + let stale = null; + try { + const cached = localStorage.getItem(key); + if (cached) { + const { ts, data } = JSON.parse(cached); + if (Date.now() - ts < FIVE_MIN_MS) return data; // fresh — skip fetch + stale = data; // stale — keep as fallback + } + } catch (e) { /* ignore bad cache */ } + try { + const r = await fetch(buildSoilUrl(loc)); + if (!r.ok) return stale; + const json = await r.json(); + const h = json.hourly; + try { localStorage.setItem(key, JSON.stringify({ ts: Date.now(), data: h })); } catch (e) {} + return h; + } catch (e) { return stale; } // network failure — return stale if available +} + +// Merges soil arrays into forecast.hourly, aligned by time string. +function mergeSoil(forecastData, soilHourly) { + if (!soilHourly) return forecastData; + const timeToIdx = {}; + soilHourly.time.forEach((t, i) => { timeToIdx[t] = i; }); + const h = { ...forecastData.hourly }; + ['soil_temperature_0cm', 'soil_temperature_6cm', 'soil_moisture_0_to_1cm'].forEach(field => { + h[field] = forecastData.hourly.time.map(t => { + const i = timeToIdx[t]; + return i !== undefined ? (soilHourly[field]?.[i] ?? null) : null; + }); + }); + return { ...forecastData, hourly: h }; +} + export function useForecast(location) { const [forecast, setForecast] = useState(null); const [airQuality, setAirQuality] = useState(null); @@ -68,38 +128,67 @@ export function useForecast(location) { const [fetchedAt, setFetchedAt] = useState(null); // ─── FORECAST LOADER ────────────────────────────────────────────── + // + // Waterfall: + // 1. Cache fresh (< 5 min) → serve immediately, no fetch needed + // 2. Cache stale / missing → fetch main auto API + // 3. Main API soil is null → try ICON for soil data + // 4. ICON fails → fall back to stale cache (any age) + // 5. No cache at all → show error + // async function loadForecast(loc) { const key = forecastCacheKey(loc); + let staleCache = null; + + // Step 1 — serve from cache if fresh enough try { const cached = localStorage.getItem(key); if (cached) { const { ts, data } = JSON.parse(cached); - if (Date.now() - ts < FIFTEEN_MIN_MS) { + if (Date.now() - ts < FIVE_MIN_MS) { setForecast(data); setFetchedAt(new Date(ts)); return; } + staleCache = { ts, data }; // keep for step 4 } } catch (e) { /* ignore bad cache */ } setLoading(true); setError(null); try { + // Step 2 — fetch main forecast const r = await fetch(buildForecastUrl(loc)); if (!r.ok) throw new Error(`Open-Meteo HTTP ${r.status}`); const data = await r.json(); + + // Step 3 — if main API has no soil data, fetch ICON + const mainHasSoil = data.hourly?.soil_moisture_0_to_1cm?.some(v => v != null); + const soilH = mainHasSoil ? null : await fetchSoilHourly(loc); + const merged = mergeSoil(data, soilH); + const ts = Date.now(); - setForecast(data); + setForecast(merged); setFetchedAt(new Date(ts)); try { - localStorage.setItem(key, JSON.stringify({ ts, data })); + localStorage.setItem(key, JSON.stringify({ ts, data: merged })); } catch (e) { /* ignore storage errors */ } - } catch (e) { setError(e.message); } + + } catch (e) { + // Step 4 — API failed, serve stale cache so users still see something + if (staleCache) { + setForecast(staleCache.data); + setFetchedAt(new Date(staleCache.ts)); // old timestamp signals stale data + } else { + setError(e.message); // Step 5 — nothing to fall back to + } + } finally { setLoading(false); } } // ─── AIR QUALITY LOADER ─────────────────────────────────────────── async function loadAirQuality(loc) { const key = airQualityCacheKey(loc); + let stale = null; try { const cached = localStorage.getItem(key); if (cached) { @@ -108,17 +197,20 @@ export function useForecast(location) { setAirQuality(data); return; } + stale = data; // keep stale for fallback } } catch (e) { /* ignore bad cache */ } try { const r = await fetch(buildAirQualityUrl(loc)); - if (!r.ok) return; + if (!r.ok) { if (stale) setAirQuality(stale); return; } const data = await r.json(); setAirQuality(data); try { - localStorage.setItem(airQualityCacheKey(loc), JSON.stringify({ ts: Date.now(), data })); + localStorage.setItem(key, JSON.stringify({ ts: Date.now(), data })); } catch (e) { /* ignore storage errors */ } - } catch (_) { /* silently ignore */ } + } catch (_) { + if (stale) setAirQuality(stale); // network failure — use stale silently + } } // ─── INITIAL FETCH on location change ─────────────────────────────