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