Files
sunscope/assets/js/hooks/useForecast.js
T
Fraxle 8f161dc8cb 3.9
Summaries added — every column in COL_DESCRIPTIONS got a short (1–2 sentences) and a link to its reference row. Full desc text kept intact for the day-tab metric labels.
Popup rewired — renders the summary plus a "More about this column →" link, opening in a new tab.
Link styling — .col-info-more pinned below the summary so it stays visible if the text scrolls; centred on mobile with the rest.
Anchors on the reference page — 35 row ids in columns.html, one per column key.
New "Rows" row — the hour/interval header had a popup but no matching row on the reference page; added.
Landing highlight — tr:target gets a gold bar and tint, with scroll offset so the nav doesn't cover it.
2026-08-03 18:06:09 +01:00

406 lines
17 KiB
JavaScript

// ------------------------------------------------------------------------
// useForecast - fetches the weather forecast and air quality for the
// given location and keeps them fresh.
//
// Responsibilities:
// 1. Fetch /v1/forecast on location change, with a localStorage cache
// (15 min Pro / 30 min free). fetchedAt reflects the cache timestamp.
// 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).
// 4. Auto-refresh forecast every 15 minutes for Pro, 30 minutes for
// free (Open-Meteo updates ~every 15 min). Air quality only
// refetches if cache is stale.
// 5. Tick `now` every minute to keep the current-row highlight
// accurate without any API cost.
//
// Inputs:
// location - { lat, lon, name, country }
// isPro - boolean; true tightens auto-refresh to 15 min
//
// Outputs:
// forecast - raw /v1/forecast response, or null
// airQuality - raw /v1/air-quality response, or null
// aqHorizon - last date ('YYYY-MM-DD') air quality covers, or null
// loading - true while the forecast fetch is in flight
// error - fetch error message, or null
// now - Date that ticks every minute (drives the current row)
// fetchedAt - Date the forecast data was last fetched or read from cache
// retry - force a fresh fetch of both, ignoring cache age
// ------------------------------------------------------------------------
import { useState, useEffect, useRef } from '../../vendor/preact-hooks.js';
import { solarElevationDeg } from '../physics.js';
const FIVE_MIN_MS = 5 * 60 * 1000;
const FIFTEEN_MIN_MS = 15 * 60 * 1000;
const THIRTY_MIN_MS = 30 * 60 * 1000;
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
const ONE_MIN_MS = 1 * 60 * 1000;
const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000;
function buildForecastUrl(loc) {
return `https://api.open-meteo.com/v1/forecast` +
`?latitude=${loc.lat}&longitude=${loc.lon}` +
`&hourly=temperature_2m,relative_humidity_2m,dew_point_2m,` +
`wind_speed_10m,wind_direction_10m,wind_gusts_10m,` +
`direct_radiation,diffuse_radiation,shortwave_radiation,` +
`cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,` +
`uv_index,precipitation,precipitation_probability,lightning_potential,cape,snowfall,visibility,` +
`soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` +
`&wind_speed_unit=ms&timezone=auto&forecast_days=14`;
}
// CAMS only publishes 7 days of air quality against the forecast's 14, so the
// AQI and pollen columns run out early. The UI reads the real horizon back off
// the returned time array (see aqHorizon below) rather than trusting this
// number, so raising it later needs no other change.
function buildAirQualityUrl(loc) {
return `https://air-quality-api.open-meteo.com/v1/air-quality` +
`?latitude=${loc.lat}&longitude=${loc.lon}` +
`&hourly=european_aqi,` +
`grass_pollen,birch_pollen,alder_pollen,` +
`mugwort_pollen,olive_pollen,ragweed_pollen` +
`&timezone=auto&forecast_days=7`;
}
function forecastCacheKey(loc) {
return `sunscope_fc_${loc.lat.toFixed(4)}_${loc.lon.toFixed(4)}`;
}
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)}`;
}
// ─── CLIMATE NORMALS (ERA5 archive) ───────────────────────────────────────
// Open-Meteo's Historical Archive gives daily temps back to 1940. We pull the
// WMO reference period (1991-2020) once per location and reduce it into two
// 366-entry day-of-year tables of "normal" daily HIGH and daily MEAN temperature,
// so the app can show how far a day's forecast high AND daily average each sit
// above/below their seasonal norm. Normals don't change, so this caches long.
function buildClimateUrl(loc) {
return `https://archive-api.open-meteo.com/v1/archive` +
`?latitude=${loc.lat}&longitude=${loc.lon}` +
`&start_date=1991-01-01&end_date=2020-12-31` +
`&daily=temperature_2m_max,temperature_2m_mean&timezone=auto`;
}
// Coarser key (~2 dp ≈ 1 km) so nearby lookups share one cached climatology.
// The `_hm` marks the { high, mean } shape so it won't collide with any
// earlier single-array cache from a previous build.
function climateCacheKey(loc) {
return `sunscope_normals_hm_${loc.lat.toFixed(2)}_${loc.lon.toFixed(2)}`;
}
// Cumulative days before each month on a leap-year calendar, so every date
// maps to a stable day-of-year slot (Mar 1 is always 60, Feb 29 is 59).
const LEAP_CUM = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
function doyFromKey(key) {
const m = parseInt(key.slice(5, 7), 10);
const d = parseInt(key.slice(8, 10), 10);
return LEAP_CUM[m - 1] + (d - 1); // 0..365
}
// Reduce one raw daily series into a smoothed 366-entry day-of-year table.
function reduceSeries(times, values) {
if (!times || !values) return null;
const sums = new Array(366).fill(0);
const counts = new Array(366).fill(0);
for (let i = 0; i < times.length; i++) {
const v = values[i];
if (v == null) continue;
const doy = doyFromKey(times[i]);
sums[doy] += v;
counts[doy] += 1;
}
const raw = sums.map((s, i) => (counts[i] > 0 ? s / counts[i] : null));
// ±7-day circular smoothing to remove single-day sampling noise.
const WIN = 7;
const smooth = new Array(366).fill(null);
for (let i = 0; i < 366; i++) {
let s = 0, n = 0;
for (let k = -WIN; k <= WIN; k++) {
const v = raw[(i + k + 366) % 366];
if (v != null) { s += v; n += 1; }
}
if (n > 0) smooth[i] = s / n;
}
return smooth;
}
// Build both the normal-high and normal-mean day-of-year tables from the
// archive response. Returns { high, mean } or null.
function reduceNormals(daily) {
const times = daily?.time;
const high = reduceSeries(times, daily?.temperature_2m_max);
const mean = reduceSeries(times, daily?.temperature_2m_mean);
if (!high && !mean) return null;
return { high, mean };
}
// Fetches (or reads cached) climate normals for a location. Returns
// { high, mean } day-of-year tables (°C), or null on any failure (feature hides).
async function fetchClimateNormals(loc) {
const key = climateCacheKey(loc);
try {
const cached = localStorage.getItem(key);
if (cached) {
const { ts, normals } = JSON.parse(cached);
if (normals && Date.now() - ts < SIXTY_DAYS_MS) return normals;
}
} catch (e) { /* ignore bad cache */ }
try {
const r = await fetch(buildClimateUrl(loc));
if (!r.ok) return null;
const json = await r.json();
const normals = reduceNormals(json.daily);
if (!normals) return null;
try { localStorage.setItem(key, JSON.stringify({ ts: Date.now(), normals })); } catch (e) {}
return normals;
} catch (e) { return null; } // network failure — feature just hides
}
// 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, isPro = false) {
const [forecast, setForecast] = useState(null);
const [airQuality, setAirQuality] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [now, setNow] = useState(new Date());
const [fetchedAt, setFetchedAt] = useState(null);
const [normals, setNormals] = useState(null);
// Mirrors the latest forecast so the loader's catch block can tell whether
// data is already on screen, even inside stale setInterval closures.
const forecastRef = useRef(null);
useEffect(() => { forecastRef.current = forecast; }, [forecast]);
// ─── FORECAST LOADER ──────────────────────────────────────────────
//
// Waterfall:
// 1. Cache fresh (< refresh interval) → 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 but data shown → keep existing data (don't show error)
// 6. Nothing loaded at all → show error
//
// `force` is the manual Retry path: it still paints the cache first (better
// than a blank screen) but never takes the "fresh enough, skip the fetch"
// exit, so pressing Retry always goes to the network.
async function loadForecast(loc, force = false) {
const key = forecastCacheKey(loc);
let staleCache = null;
// Step 1 — stale-while-revalidate: serve any cached data immediately,
// then continue to fetch fresh data silently in the background.
// Only show the loading spinner if there is nothing cached at all.
const cacheMaxAge = isPro ? FIFTEEN_MIN_MS : THIRTY_MIN_MS;
try {
const cached = localStorage.getItem(key);
if (cached) {
const { ts, data } = JSON.parse(cached);
staleCache = { ts, data };
// Always show cached data immediately - no spinner
setForecast(data);
setFetchedAt(new Date(ts));
// If fresh enough, no need to re-fetch
if (!force && Date.now() - ts < cacheMaxAge) return;
}
} catch (e) { /* ignore bad cache */ }
// Only show spinner if nothing could be served from cache. A manual retry
// always spins, so the button visibly does something.
if (!staleCache || force) { 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(merged);
setFetchedAt(new Date(ts));
try {
localStorage.setItem(key, JSON.stringify({ ts, data: merged }));
} catch (e) { /* ignore storage errors */ }
} 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 if (forecastRef.current) {
// Step 5 — no cache (e.g. an auto-refresh cleared it first), but data
// is already on screen. Keep showing it rather than replacing the page
// with a transient network error.
} else {
setError(e.message); // Step 6 — nothing loaded yet, surface the error
}
}
finally { setLoading(false); }
}
// ─── AIR QUALITY LOADER ───────────────────────────────────────────
async function loadAirQuality(loc, force = false) {
const key = airQualityCacheKey(loc);
let stale = null;
try {
const cached = localStorage.getItem(key);
if (cached) {
const { ts, data } = JSON.parse(cached);
if (!force && Date.now() - ts < SIX_HOURS_MS) {
setAirQuality(data);
return;
}
stale = data; // keep stale for fallback
}
} catch (e) { /* ignore bad cache */ }
try {
const r = await fetch(buildAirQualityUrl(loc));
if (!r.ok) { if (stale) setAirQuality(stale); return; }
const data = await r.json();
setAirQuality(data);
try {
localStorage.setItem(key, JSON.stringify({ ts: Date.now(), data }));
} catch (e) { /* ignore storage errors */ }
} catch (_) {
if (stale) setAirQuality(stale); // network failure — use stale silently
}
}
// ─── MANUAL RETRY ─────────────────────────────────────────────────
// Wired to the Retry button in the error banner. Forces past the cache-age
// check on both loaders so a user who taps it always gets a real attempt.
function retry() {
if (!location) return;
loadForecast(location, true);
loadAirQuality(location, true);
}
// ─── INITIAL FETCH on location change ─────────────────────────────
useEffect(() => {
loadForecast(location);
loadAirQuality(location);
// Climate normals are supplementary — load them without blocking. Clear
// first so a previous location's normals don't linger if this fetch fails.
setNormals(null);
if (location?.lat != null) {
fetchClimateNormals(location).then(n => { if (n) setNormals(n); });
}
}, [location]);
// ─── 1-MINUTE CLOCK TICK (current-row highlight + live scope) ─────
useEffect(() => {
const id = setInterval(() => setNow(new Date()), ONE_MIN_MS);
return () => clearInterval(id);
}, []);
// ─── LIVE SOLAR ELEVATION (updates every minute with now tick) ─────
const [liveElev, setLiveElev] = useState(0);
useEffect(() => {
if (location?.lat == null) return;
setLiveElev(solarElevationDeg(location.lat, location.lon, now));
}, [now, location]);
// ─── AUTO REFRESH (Pro: 15 min, free: 30 min) ─────────────────────
// Open-Meteo updates roughly every 15 min; Pro gets tighter polling.
useEffect(() => {
const refreshMs = isPro ? FIFTEEN_MIN_MS : THIRTY_MIN_MS;
const id = setInterval(() => {
async function refresh() {
const key = forecastCacheKey(location);
try { localStorage.removeItem(key); } catch (e) { /* ignore */ }
await loadForecast(location);
const aqKey = airQualityCacheKey(location);
try {
const cached = localStorage.getItem(aqKey);
if (cached) {
const { ts } = JSON.parse(cached);
if (Date.now() - ts < SIX_HOURS_MS) return;
}
} catch (e) { /* ignore */ }
loadAirQuality(location);
}
refresh();
}, refreshMs);
return () => clearInterval(id);
}, [location, isPro]);
// ─── AIR QUALITY HORIZON ──────────────────────────────────────────
// CAMS runs out well before the 14-day forecast does. Read the last date it
// actually returned so the table can say "not forecast this far ahead"
// instead of rendering blank AQI and pollen cells, which read as a bug.
const aqHorizon = (() => {
const times = airQuality?.hourly?.time;
if (!times || times.length === 0) return null;
return times[times.length - 1].slice(0, 10); // 'YYYY-MM-DD'
})();
return { forecast, airQuality, aqHorizon, loading, error, now, fetchedAt, liveElev, normals, retry };
}