Files
sunscope/assets/js/hooks/useForecast.js
T
fraxle d7bfaf11b6 1.8.0
New table column order
Table groups and colouring
Chance of rain column
2026-05-24 11:41:13 +01:00

160 lines
6.1 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 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,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)}`;
}
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 };
}