Cache API calls
This commit is contained in:
fraxle
2026-05-21 18:48:10 +01:00
parent 073e6c6b23
commit d6eedabbf9
4 changed files with 92 additions and 48 deletions
+7 -1
View File
@@ -525,7 +525,13 @@
border: 1.5px solid #c9b08a; border: 1.5px solid #c9b08a;
margin-bottom: 18px; 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) ────────── */ /* ── 10. LEGEND (thermal-stress band colours at the bottom) ────────── */
+3 -1
View File
@@ -43,7 +43,7 @@ export function UTCIForecast() {
// useAppState. See hooks/useAppState.js for the full reading order. // useAppState. See hooks/useAppState.js for the full reading order.
const { const {
location, setLocationAndSave, location, setLocationAndSave,
forecast, airQuality, loading, error, now, forecast, airQuality, loading, error, now, fetchedAt,
searchQuery, setSearchQuery, searchResults, searching, searchQuery, setSearchQuery, searchResults, searching,
selectedDay, setSelectedDay, selectedDay, setSelectedDay,
proPromptDay, setProPromptDay, proPromptDay, setProPromptDay,
@@ -198,6 +198,8 @@ export function UTCIForecast() {
${location.lat.toFixed(3)}°, ${location.lon.toFixed(3)}° ${location.lat.toFixed(3)}°, ${location.lon.toFixed(3)}°
</span> </span>
</div> </div>
${fetchedAt && !loading && html`
<div class="utci-fetch-time">Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>`}
</div> </div>
<div> <div>
+2 -2
View File
@@ -51,7 +51,7 @@ export function useAppState() {
setLocation(loc); setLocation(loc);
}; };
const { forecast, airQuality, loading, error, now } = useForecast(location); const { forecast, airQuality, loading, error, now, fetchedAt } = useForecast(location);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState([]); const [searchResults, setSearchResults] = useState([]);
@@ -387,7 +387,7 @@ export function useAppState() {
// location // location
location, setLocationAndSave, location, setLocationAndSave,
// forecast // forecast
forecast, airQuality, loading, error, now, forecast, airQuality, loading, error, now, fetchedAt,
// search // search
searchQuery, setSearchQuery, searchQuery, setSearchQuery,
searchResults, setSearchResults, searchResults, setSearchResults,
+72 -36
View File
@@ -3,12 +3,14 @@
// given location and keeps them fresh. // given location and keeps them fresh.
// //
// Responsibilities: // 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 // 2. Fetch /v1/air-quality on location change, with a 6-hour
// localStorage cache (AQI / pollen update slowly). // localStorage cache (AQI / pollen update slowly).
// 3. Auto-refresh both every 5 minutes so the displayed data stays // 3. Auto-refresh forecast every 15 minutes (matches Open-Meteo
// current as time passes. Air quality only refetches if cache is // update cadence). Air quality only refetches if cache is stale.
// stale. // 4. Tick `now` every 2 minutes to keep the current-row highlight
// accurate without any API cost.
// //
// Inputs: // Inputs:
// location - { lat, lon, name, country } // location - { lat, lon, name, country }
@@ -18,13 +20,15 @@
// airQuality - raw /v1/air-quality response, or null // airQuality - raw /v1/air-quality response, or null
// loading - true while the forecast fetch is in flight // loading - true while the forecast fetch is in flight
// error - fetch error message, or null // 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'; import { useState, useEffect } from '../../vendor/preact-hooks.js';
const FIFTEEN_MIN_MS = 15 * 60 * 1000;
const SIX_HOURS_MS = 6 * 60 * 60 * 1000; const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
const FIVE_MIN = 5 * 60 * 1000; const TWO_MIN_MS = 2 * 60 * 1000;
function buildForecastUrl(loc) { function buildForecastUrl(loc) {
return `https://api.open-meteo.com/v1/forecast` + return `https://api.open-meteo.com/v1/forecast` +
@@ -47,18 +51,57 @@ function buildAirQualityUrl(loc) {
`&timezone=auto&forecast_days=5`; `&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) { export function useForecast(location) {
const [forecast, setForecast] = useState(null); const [forecast, setForecast] = useState(null);
const [airQuality, setAirQuality] = useState(null); const [airQuality, setAirQuality] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [now, setNow] = useState(new Date()); const [now, setNow] = useState(new Date());
const [fetchedAt, setFetchedAt] = useState(null);
// Air quality loader — also used by the 5-minute refresh below. // ─── FORECAST LOADER ──────────────────────────────────────────────
async function loadAirQuality(loc) { async function loadForecast(loc) {
const cacheKey = `sunscope_aq_${loc.lat.toFixed(4)}_${loc.lon.toFixed(4)}`; const key = forecastCacheKey(loc);
try { 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) { if (cached) {
const { ts, data } = JSON.parse(cached); const { ts, data } = JSON.parse(cached);
if (Date.now() - ts < SIX_HOURS_MS) { if (Date.now() - ts < SIX_HOURS_MS) {
@@ -69,56 +112,49 @@ export function useForecast(location) {
} catch (e) { /* ignore bad cache */ } } catch (e) { /* ignore bad cache */ }
try { try {
const r = await fetch(buildAirQualityUrl(loc)); 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(); const data = await r.json();
setAirQuality(data); setAirQuality(data);
try { 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 (e) { /* ignore storage errors */ }
} catch (_) { /* silently ignore */ } } catch (_) { /* silently ignore */ }
} }
// ─── INITIAL FETCH on location change ───────────────────────────── // ─── INITIAL FETCH on location change ─────────────────────────────
useEffect(() => { useEffect(() => {
async function load() { loadForecast(location);
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();
loadAirQuality(location); loadAirQuality(location);
}, [location]); }, [location]);
// ─── AUTO-REFRESH every 5 minutes ───────────────────────────────── // ─── 2-MINUTE CLOCK TICK (current-row highlight only) ─────────────
// Updates `now` (drives the "current hour" highlight) and refetches useEffect(() => {
// the forecast so fresh API data comes in automatically. Air quality const id = setInterval(() => setNow(new Date()), TWO_MIN_MS);
// only refetches if its 6-hour cache has expired. return () => clearInterval(id);
}, []);
// ─── 15-MINUTE REFRESH (matches Open-Meteo update cadence) ────────
useEffect(() => { useEffect(() => {
const id = setInterval(() => { const id = setInterval(() => {
setNow(new Date());
async function refresh() { async function refresh() {
const key = forecastCacheKey(location);
try { localStorage.removeItem(key); } catch (e) { /* ignore */ }
await loadForecast(location);
const aqKey = airQualityCacheKey(location);
try { try {
const r = await fetch(buildForecastUrl(location)); const cached = localStorage.getItem(aqKey);
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);
if (cached) { if (cached) {
const { ts } = JSON.parse(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 */ } } catch (e) { /* ignore */ }
loadAirQuality(location); loadAirQuality(location);
} }
refresh(); refresh();
}, FIVE_MIN); }, FIFTEEN_MIN_MS);
return () => clearInterval(id); return () => clearInterval(id);
}, [location]); }, [location]);
return { forecast, airQuality, loading, error, now }; return { forecast, airQuality, loading, error, now, fetchedAt };
} }