Update cache to include backup calls to ICON data
This commit is contained in:
fraxle
2026-05-29 12:09:55 +01:00
parent b5497ffc1f
commit 75537d0250
3 changed files with 106 additions and 14 deletions
+2 -2
View File
@@ -753,9 +753,9 @@ ${(isPro ? (activeProfile === 'custom' || activeProfile === 'showall' || activeC
${fmt(r.concreteT)}${u('°C')}
</td>`}
${visibleCols.soilT && html`
<td class=${groupStart('soilT')} style=${{ color: airTempFontColor(), background: airTempBg(r.soilT0, rPrev?.soilT0, rNext?.soilT0) }}>${fmt(r.soilT0)}${u('°C')}</td>`}
<td class=${groupStart('soilT')} style=${{ color: airTempFontColor(), background: airTempBg(r.soilT0, rPrev?.soilT0, rNext?.soilT0) }}>${r.soilT0 != null ? fmt(r.soilT0) + u('°C') : '—'}</td>`}
${visibleCols.soilT6 && html`
<td class=${groupStart('soilT6')} style=${{ color: airTempFontColor(), background: airTempBg(r.soilT6, rPrev?.soilT6, rNext?.soilT6) }}>${fmt(r.soilT6)}${u('°C')}</td>`}
<td class=${groupStart('soilT6')} style=${{ color: airTempFontColor(), background: airTempBg(r.soilT6, rPrev?.soilT6, rNext?.soilT6) }}>${r.soilT6 != null ? fmt(r.soilT6) + u('°C') : '—'}</td>`}
${visibleCols.soilM && html`
<td class=${groupStart('soilM')} style=${(() => { const sm = soilMoistureBg(r.soilM); return { color: sm.fg, background: sm.bg }; })()}>${r.soilM != null ? fmt(r.soilM * 100, 1) + u('%') : '—'}</td>`}
${visibleCols.air && html`<td class=${groupStart('air')} style=${{ background: airTempBg(r.Ta, rPrev?.Ta, rNext?.Ta), color: airTempFontColor() }}>${fmt(r.Ta)}${u('°C')}</td>`}
+1 -1
View File
@@ -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) => {
+103 -11
View File
@@ -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 ─────────────────────────────