1.7.2
Cache API calls
This commit is contained in:
+7
-1
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user