// ------------------------------------------------------------------------ // 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 15-minute // localStorage cache. fetchedAt reflects the cache timestamp. // 2. 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 // update cadence). Air quality only refetches if cache is stale. // 4. Tick `now` every 2 minutes to keep the current-row highlight // accurate without any API cost. // // Inputs: // location - { lat, lon, name, country } // // 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 } from '../../vendor/preact-hooks.js'; 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,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)}`; } export function useForecast(location) { 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); // ─── FORECAST LOADER ────────────────────────────────────────────── async function loadForecast(loc) { const key = forecastCacheKey(loc); try { const cached = localStorage.getItem(key); if (cached) { const { ts, data } = JSON.parse(cached); if (Date.now() - ts < FIFTEEN_MIN_MS) { setForecast(data); setFetchedAt(new Date(ts)); return; } } } catch (e) { /* ignore bad cache */ } setLoading(true); setError(null); try { const r = await fetch(buildForecastUrl(loc)); if (!r.ok) throw new Error(`Open-Meteo HTTP ${r.status}`); const data = await r.json(); const ts = Date.now(); setForecast(data); setFetchedAt(new Date(ts)); try { localStorage.setItem(key, JSON.stringify({ ts, data })); } catch (e) { /* ignore storage errors */ } } catch (e) { setError(e.message); } finally { setLoading(false); } } // ─── AIR QUALITY LOADER ─────────────────────────────────────────── async function loadAirQuality(loc) { const key = airQualityCacheKey(loc); try { const cached = localStorage.getItem(key); if (cached) { const { ts, data } = JSON.parse(cached); if (Date.now() - ts < SIX_HOURS_MS) { setAirQuality(data); return; } } } catch (e) { /* ignore bad cache */ } try { const r = await fetch(buildAirQualityUrl(loc)); if (!r.ok) return; const data = await r.json(); setAirQuality(data); try { localStorage.setItem(airQualityCacheKey(loc), JSON.stringify({ ts: Date.now(), data })); } catch (e) { /* ignore storage errors */ } } catch (_) { /* silently ignore */ } } // ─── 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); }, []); // ─── 15-MINUTE REFRESH (matches Open-Meteo update cadence) ──────── useEffect(() => { 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(); }, FIFTEEN_MIN_MS); return () => clearInterval(id); }, [location]); return { forecast, airQuality, loading, error, now, fetchedAt }; }