Summaries added — every column in COL_DESCRIPTIONS got a short (1–2 sentences) and a link to its reference row. Full desc text kept intact for the day-tab metric labels.
Popup rewired — renders the summary plus a "More about this column →" link, opening in a new tab.
Link styling — .col-info-more pinned below the summary so it stays visible if the text scrolls; centred on mobile with the rest.
Anchors on the reference page — 35 row ids in columns.html, one per column key.
New "Rows" row — the hour/interval header had a popup but no matching row on the reference page; added.
Landing highlight — tr:target gets a gold bar and tint, with scroll offset so the nav doesn't cover it.
This commit is contained in:
Fraxle
2026-08-03 18:06:09 +01:00
parent 2035edad23
commit 8f161dc8cb
18 changed files with 668 additions and 123 deletions
+43 -14
View File
@@ -3,8 +3,8 @@
// given location and keeps them fresh.
//
// Responsibilities:
// 1. Fetch /v1/forecast on location change, with a 5-minute
// localStorage cache. fetchedAt reflects the cache timestamp.
// 1. Fetch /v1/forecast on location change, with a localStorage cache
// (15 min Pro / 30 min free). fetchedAt reflects the cache timestamp.
// 2. Soil data (soil_temperature, soil_moisture) waterfall:
// a. Main auto API — works if the regional model includes soil.
// b. ICON Global fallback — used when the auto model returns nulls
@@ -12,23 +12,25 @@
// c. Stale soil cache — used if ICON fetch fails.
// 3. Fetch /v1/air-quality on location change, with a 6-hour
// localStorage cache (AQI / pollen update slowly).
// 4. Auto-refresh forecast every 5 minutes for Pro, 15 minutes for
// 4. Auto-refresh forecast every 15 minutes for Pro, 30 minutes for
// free (Open-Meteo updates ~every 15 min). Air quality only
// refetches if cache is stale.
// 5. Tick `now` every 2 minutes to keep the current-row highlight
// 5. Tick `now` every minute to keep the current-row highlight
// accurate without any API cost.
//
// Inputs:
// location - { lat, lon, name, country }
// isPro - boolean; true tightens auto-refresh to 5 min
// isPro - boolean; true tightens auto-refresh to 15 min
//
// Outputs:
// forecast - raw /v1/forecast response, or null
// airQuality - raw /v1/air-quality response, or null
// aqHorizon - last date ('YYYY-MM-DD') air quality covers, 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)
// now - Date that ticks every minute (drives the current row)
// fetchedAt - Date the forecast data was last fetched or read from cache
// retry - force a fresh fetch of both, ignoring cache age
// ------------------------------------------------------------------------
import { useState, useEffect, useRef } from '../../vendor/preact-hooks.js';
@@ -53,13 +55,17 @@ function buildForecastUrl(loc) {
`&wind_speed_unit=ms&timezone=auto&forecast_days=14`;
}
// CAMS only publishes 7 days of air quality against the forecast's 14, so the
// AQI and pollen columns run out early. The UI reads the real horizon back off
// the returned time array (see aqHorizon below) rather than trusting this
// number, so raising it later needs no other change.
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`;
`&timezone=auto&forecast_days=7`;
}
function forecastCacheKey(loc) {
@@ -237,7 +243,10 @@ export function useForecast(location, isPro = false) {
// 5. No cache but data shown → keep existing data (don't show error)
// 6. Nothing loaded at all → show error
//
async function loadForecast(loc) {
// `force` is the manual Retry path: it still paints the cache first (better
// than a blank screen) but never takes the "fresh enough, skip the fetch"
// exit, so pressing Retry always goes to the network.
async function loadForecast(loc, force = false) {
const key = forecastCacheKey(loc);
let staleCache = null;
@@ -254,12 +263,13 @@ export function useForecast(location, isPro = false) {
setForecast(data);
setFetchedAt(new Date(ts));
// If fresh enough, no need to re-fetch
if (Date.now() - ts < cacheMaxAge) return;
if (!force && Date.now() - ts < cacheMaxAge) return;
}
} catch (e) { /* ignore bad cache */ }
// Only show spinner if nothing could be served from cache
if (!staleCache) { setLoading(true); }
// Only show spinner if nothing could be served from cache. A manual retry
// always spins, so the button visibly does something.
if (!staleCache || force) { setLoading(true); }
setError(null);
try {
// Step 2 — fetch main forecast
@@ -296,14 +306,14 @@ export function useForecast(location, isPro = false) {
}
// ─── AIR QUALITY LOADER ───────────────────────────────────────────
async function loadAirQuality(loc) {
async function loadAirQuality(loc, force = false) {
const key = airQualityCacheKey(loc);
let stale = null;
try {
const cached = localStorage.getItem(key);
if (cached) {
const { ts, data } = JSON.parse(cached);
if (Date.now() - ts < SIX_HOURS_MS) {
if (!force && Date.now() - ts < SIX_HOURS_MS) {
setAirQuality(data);
return;
}
@@ -323,6 +333,15 @@ export function useForecast(location, isPro = false) {
}
}
// ─── MANUAL RETRY ─────────────────────────────────────────────────
// Wired to the Retry button in the error banner. Forces past the cache-age
// check on both loaders so a user who taps it always gets a real attempt.
function retry() {
if (!location) return;
loadForecast(location, true);
loadAirQuality(location, true);
}
// ─── INITIAL FETCH on location change ─────────────────────────────
useEffect(() => {
loadForecast(location);
@@ -373,5 +392,15 @@ export function useForecast(location, isPro = false) {
return () => clearInterval(id);
}, [location, isPro]);
return { forecast, airQuality, loading, error, now, fetchedAt, liveElev, normals };
// ─── AIR QUALITY HORIZON ──────────────────────────────────────────
// CAMS runs out well before the 14-day forecast does. Read the last date it
// actually returned so the table can say "not forecast this far ahead"
// instead of rendering blank AQI and pollen cells, which read as a bug.
const aqHorizon = (() => {
const times = airQuality?.hourly?.time;
if (!times || times.length === 0) return null;
return times[times.length - 1].slice(0, 10); // 'YYYY-MM-DD'
})();
return { forecast, airQuality, aqHorizon, loading, error, now, fetchedAt, liveElev, normals, retry };
}