Files
sunscope/assets/js/hooks/useForecast.js
T
fraxle ede875f0d0 2.6
New Logo
Updated cache system now 15/30mins but scope updates every minute
Font update
2026-06-04 14:07:34 +01:00

280 lines
12 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 5-minute
// localStorage cache. 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 5 minutes for Pro, 15 minutes for
// free (Open-Meteo updates ~every 15 min). Air quality only
// refetches if cache is stale.
// 5. Tick `now` every 2 minutes to keep the current-row highlight
// accurate without any API cost.
//
// Inputs:
// location - { lat, lon, name, country }
// isPro - boolean; true tightens auto-refresh to 5 min
//
// Outputs:
// forecast - raw /v1/forecast response, or null
// airQuality - raw /v1/air-quality response, or null
// loading - true while the forecast fetch is in flight
// error - fetch error message, or null
// now - Date that ticks every 2 minutes (drives the current row)
// fetchedAt - Date the forecast data was last fetched or read from cache
// ------------------------------------------------------------------------
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;
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,snowfall,visibility,` +
`soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` +
`&wind_speed_unit=ms&timezone=auto&forecast_days=14`;
}
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=5`;
}
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)}`;
}
// 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);
// 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
//
async function loadForecast(loc) {
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 (Date.now() - ts < cacheMaxAge) return;
}
} catch (e) { /* ignore bad cache */ }
// Only show spinner if nothing could be served from cache
if (!staleCache) { 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) {
const key = airQualityCacheKey(loc);
let stale = null;
try {
const cached = localStorage.getItem(key);
if (cached) {
const { ts, data } = JSON.parse(cached);
if (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
}
}
// ─── INITIAL FETCH on location change ─────────────────────────────
useEffect(() => {
loadForecast(location);
loadAirQuality(location);
}, [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]);
return { forecast, airQuality, loading, error, now, fetchedAt, liveElev };
}