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 ─────────────────────────────