// ------------------------------------------------------------------------ // 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'; 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; 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 (< 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 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 — serve from cache if fresh enough try { const cached = localStorage.getItem(key); if (cached) { const { ts, data } = JSON.parse(cached); 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(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]); // ─── 2-MINUTE CLOCK TICK (current-row highlight only) ───────────── useEffect(() => { const id = setInterval(() => setNow(new Date()), TWO_MIN_MS); return () => clearInterval(id); }, []); // ─── AUTO REFRESH (Pro: 5 min, free: 15 min) ───────────────────── // Open-Meteo updates roughly every 15 min; Pro users get a tighter // poll so the current-conditions row stays fresher. useEffect(() => { const refreshMs = isPro ? FIVE_MIN_MS : FIFTEEN_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 }; }