diff --git a/assets/css/ui.css b/assets/css/ui.css index a7c37ce..a9753d2 100644 --- a/assets/css/ui.css +++ b/assets/css/ui.css @@ -525,7 +525,13 @@ border: 1.5px solid #c9b08a; margin-bottom: 18px; } - +.utci-fetch-time { + text-align: left; + font-size: 0.72rem; + opacity: 0.5; + margin: 0.70rem 0 0.4rem; + letter-spacing: 0.03em; +} /* ── 10. LEGEND (thermal-stress band colours at the bottom) ────────── */ diff --git a/assets/js/app.js b/assets/js/app.js index b47d944..fbcfcbc 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -43,7 +43,7 @@ export function UTCIForecast() { // useAppState. See hooks/useAppState.js for the full reading order. const { location, setLocationAndSave, - forecast, airQuality, loading, error, now, + forecast, airQuality, loading, error, now, fetchedAt, searchQuery, setSearchQuery, searchResults, searching, selectedDay, setSelectedDay, proPromptDay, setProPromptDay, @@ -198,6 +198,8 @@ export function UTCIForecast() { ${location.lat.toFixed(3)}°, ${location.lon.toFixed(3)}° + ${fetchedAt && !loading && html` +
Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
`}
diff --git a/assets/js/hooks/useAppState.js b/assets/js/hooks/useAppState.js index 2f3fa04..14ac60e 100644 --- a/assets/js/hooks/useAppState.js +++ b/assets/js/hooks/useAppState.js @@ -51,7 +51,7 @@ export function useAppState() { setLocation(loc); }; - const { forecast, airQuality, loading, error, now } = useForecast(location); + const { forecast, airQuality, loading, error, now, fetchedAt } = useForecast(location); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); @@ -387,7 +387,7 @@ export function useAppState() { // location location, setLocationAndSave, // forecast - forecast, airQuality, loading, error, now, + forecast, airQuality, loading, error, now, fetchedAt, // search searchQuery, setSearchQuery, searchResults, setSearchResults, diff --git a/assets/js/hooks/useForecast.js b/assets/js/hooks/useForecast.js index 8548d49..c2e1e52 100644 --- a/assets/js/hooks/useForecast.js +++ b/assets/js/hooks/useForecast.js @@ -3,12 +3,14 @@ // given location and keeps them fresh. // // Responsibilities: -// 1. Fetch /v1/forecast on location change. +// 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 both every 5 minutes so the displayed data stays -// current as time passes. Air quality only refetches if cache is -// stale. +// 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 } @@ -18,13 +20,15 @@ // 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 5 minutes (drives the "current row") +// 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 SIX_HOURS_MS = 6 * 60 * 60 * 1000; -const FIVE_MIN = 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` + @@ -47,18 +51,57 @@ function buildAirQualityUrl(loc) { `&timezone=auto&forecast_days=5`; } -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()); +function forecastCacheKey(loc) { + return `sunscope_fc_${loc.lat.toFixed(4)}_${loc.lon.toFixed(4)}`; +} - // Air quality loader — also used by the 5-minute refresh below. - async function loadAirQuality(loc) { - const cacheKey = `sunscope_aq_${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(cacheKey); + 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) { @@ -69,56 +112,49 @@ export function useForecast(location) { } catch (e) { /* ignore bad cache */ } try { const r = await fetch(buildAirQualityUrl(loc)); - if (!r.ok) return; // silently fail — these columns just show '—' + if (!r.ok) return; const data = await r.json(); setAirQuality(data); try { - localStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), data })); + localStorage.setItem(airQualityCacheKey(loc), JSON.stringify({ ts: Date.now(), data })); } catch (e) { /* ignore storage errors */ } } catch (_) { /* silently ignore */ } } // ─── INITIAL FETCH on location change ───────────────────────────── useEffect(() => { - async function load() { - setLoading(true); setError(null); - try { - const r = await fetch(buildForecastUrl(location)); - if (!r.ok) throw new Error(`Open-Meteo HTTP ${r.status}`); - setForecast(await r.json()); - } catch (e) { setError(e.message); } - finally { setLoading(false); } - } - load(); + loadForecast(location); loadAirQuality(location); }, [location]); - // ─── AUTO-REFRESH every 5 minutes ───────────────────────────────── - // Updates `now` (drives the "current hour" highlight) and refetches - // the forecast so fresh API data comes in automatically. Air quality - // only refetches if its 6-hour cache has expired. + // ─── 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(() => { - setNow(new Date()); async function refresh() { + const key = forecastCacheKey(location); + try { localStorage.removeItem(key); } catch (e) { /* ignore */ } + await loadForecast(location); + + const aqKey = airQualityCacheKey(location); try { - const r = await fetch(buildForecastUrl(location)); - if (r.ok) setForecast(await r.json()); - } catch (_) { /* silently ignore refresh errors */ } - const cacheKey = `sunscope_aq_${location.lat.toFixed(4)}_${location.lon.toFixed(4)}`; - try { - const cached = localStorage.getItem(cacheKey); + const cached = localStorage.getItem(aqKey); if (cached) { const { ts } = JSON.parse(cached); - if (Date.now() - ts < SIX_HOURS_MS) return; // still fresh + if (Date.now() - ts < SIX_HOURS_MS) return; } } catch (e) { /* ignore */ } loadAirQuality(location); } refresh(); - }, FIVE_MIN); + }, FIFTEEN_MIN_MS); return () => clearInterval(id); }, [location]); - return { forecast, airQuality, loading, error, now }; -} + return { forecast, airQuality, loading, error, now, fetchedAt }; +} \ No newline at end of file