Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
|
|
|
// Shared response cache (IndexedDB) + cross-view prefetch.
|
|
|
|
|
// Cached API JSON lets the views (a full page load each) reuse data instead of
|
|
|
|
|
// refetching. Entries live in IndexedDB — no localStorage ~5 MB quota, so even
|
|
|
|
|
// multi-year calendar payloads persist across tabs and restarts — with a small
|
|
|
|
|
// in-memory map in front for same-page rereads.
|
|
|
|
|
//
|
|
|
|
|
// Freshness is layered:
|
|
|
|
|
// * fresh (within TTL, stored today) → served as-is, no request.
|
|
|
|
|
// * stale + caller passed onUpdate → served instantly, then revalidated in
|
|
|
|
|
// the background with If-None-Match (stale-while-revalidate). A 304 just
|
|
|
|
|
// re-stamps the entry; a changed payload triggers onUpdate for a repaint.
|
|
|
|
|
// * stale without onUpdate / no entry → network (still conditional when an
|
|
|
|
|
// etag is stored, so an unchanged payload costs an empty 304).
|
|
|
|
|
// * network failure with any entry cached → the stale copy, rather than an error.
|
|
|
|
|
|
|
|
|
|
export const TTL = { grade: 600000, forecast: 1800000, calendar: 21600000, day: 600000 }; // calendar: 6h
|
|
|
|
|
|
|
|
|
|
const IDB_NAME = "thermograph-cache", IDB_STORE = "resp";
|
|
|
|
|
const IDB_MAX_AGE = 14 * 86400000; // prune entries untouched for 2 weeks
|
|
|
|
|
let _dbp = null;
|
|
|
|
|
function idb() {
|
|
|
|
|
if (_dbp) return _dbp;
|
|
|
|
|
_dbp = new Promise((resolve) => {
|
|
|
|
|
let req;
|
|
|
|
|
try { req = indexedDB.open(IDB_NAME, 1); } catch (e) { return resolve(null); }
|
|
|
|
|
req.onupgradeneeded = () => { req.result.createObjectStore(IDB_STORE, { keyPath: "url" }); };
|
|
|
|
|
req.onsuccess = () => resolve(req.result);
|
|
|
|
|
req.onerror = () => resolve(null); // blocked/private mode — cache disabled
|
|
|
|
|
req.onblocked = () => resolve(null);
|
|
|
|
|
});
|
|
|
|
|
return _dbp;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const MEM = new Map(); // page-lifetime L1 over IndexedDB
|
|
|
|
|
|
|
|
|
|
async function cacheGet(url) {
|
|
|
|
|
if (MEM.has(url)) return MEM.get(url);
|
|
|
|
|
const db = await idb();
|
|
|
|
|
if (!db) return null;
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
try {
|
|
|
|
|
const req = db.transaction(IDB_STORE, "readonly").objectStore(IDB_STORE).get(url);
|
|
|
|
|
req.onsuccess = () => { const r = req.result || null; if (r) MEM.set(url, r); resolve(r); };
|
|
|
|
|
req.onerror = () => resolve(null);
|
|
|
|
|
} catch (e) { resolve(null); }
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function cachePut(rec) {
|
|
|
|
|
MEM.set(rec.url, rec);
|
|
|
|
|
const db = await idb();
|
|
|
|
|
if (!db) return;
|
|
|
|
|
try { db.transaction(IDB_STORE, "readwrite").objectStore(IDB_STORE).put(rec); } catch (e) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// One-time hygiene per page load: drop cache entries untouched for weeks, and
|
|
|
|
|
// clear the legacy localStorage/sessionStorage entries this cache replaced.
|
|
|
|
|
(async function cleanCache() {
|
|
|
|
|
try {
|
|
|
|
|
for (const st of [localStorage, sessionStorage]) {
|
|
|
|
|
const ks = [];
|
|
|
|
|
for (let i = 0; i < st.length; i++) { const k = st.key(i); if (k && k.startsWith("tg:")) ks.push(k); }
|
|
|
|
|
ks.forEach((k) => st.removeItem(k));
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
const db = await idb();
|
|
|
|
|
if (!db) return;
|
|
|
|
|
try {
|
|
|
|
|
const os = db.transaction(IDB_STORE, "readwrite").objectStore(IDB_STORE);
|
|
|
|
|
os.openCursor().onsuccess = (e) => {
|
|
|
|
|
const cur = e.target.result;
|
|
|
|
|
if (!cur) return;
|
|
|
|
|
if (!cur.value.t || Date.now() - cur.value.t > IDB_MAX_AGE) cur.delete();
|
|
|
|
|
cur.continue();
|
|
|
|
|
};
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
// The viewer's own calendar day ("YYYY-MM-DD", local clock). Every cached response
|
|
|
|
|
// is tagged with the day it was stored, so a cache made yesterday is treated as
|
|
|
|
|
// stale today — the newest available day (and the day counts derived from it) can't
|
|
|
|
|
// be hidden behind an otherwise-fresh cache across a midnight rollover. (The
|
|
|
|
|
// revalidation that follows is conditional: unchanged data costs an empty 304.)
|
|
|
|
|
function localDay() {
|
|
|
|
|
const d = new Date();
|
|
|
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isFresh = (rec, ttlMs) => !!(rec && rec.day === localDay() && Date.now() - rec.t < ttlMs);
|
|
|
|
|
|
|
|
|
|
// GET + store, conditional when a cached etag exists. A 304 re-stamps the cached
|
|
|
|
|
// entry (fresh again, no payload moved); a 200 stores payload + etag.
|
|
|
|
|
async function fetchStore(url, rec) {
|
|
|
|
|
const headers = rec && rec.etag ? { "If-None-Match": rec.etag } : {};
|
|
|
|
|
const res = await fetch(url, { headers });
|
|
|
|
|
if (res.status === 304 && rec) {
|
|
|
|
|
cachePut({ ...rec, t: Date.now(), day: localDay() });
|
|
|
|
|
return rec.d;
|
|
|
|
|
}
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
// Error bodies aren't always JSON (a proxy 502 serves an HTML page) — fall
|
|
|
|
|
// back to the status line rather than surfacing a JSON parse error.
|
|
|
|
|
let detail = null;
|
|
|
|
|
try { detail = (await res.json()).detail; } catch (e) {}
|
|
|
|
|
const err = new Error(detail || `Request failed (${res.status} ${res.statusText})`.trim());
|
|
|
|
|
err.status = res.status;
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
const d = await res.json();
|
|
|
|
|
cachePut({ url, t: Date.now(), day: localDay(), etag: res.headers.get("ETag") || null, d });
|
|
|
|
|
return d;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// `persist` is legacy (everything persists in IndexedDB now); kept so call sites
|
|
|
|
|
// don't churn. `onUpdate` opts into stale-while-revalidate: a stale entry is
|
|
|
|
|
// returned immediately and onUpdate(fresh) fires only if revalidation finds
|
|
|
|
|
// actually-changed data.
|
|
|
|
|
export async function getJSON(url, ttlMs = 600000, persist = false, onUpdate = null) {
|
|
|
|
|
const rec = await cacheGet(url);
|
|
|
|
|
if (isFresh(rec, ttlMs)) return rec.d;
|
|
|
|
|
if (rec && onUpdate) {
|
|
|
|
|
fetchStore(url, rec).then((d) => {
|
|
|
|
|
if (d !== rec.d && JSON.stringify(d) !== JSON.stringify(rec.d)) onUpdate(d);
|
|
|
|
|
}).catch(() => {}); // background refresh is best-effort; the stale render stands
|
|
|
|
|
return rec.d;
|
|
|
|
|
}
|
|
|
|
|
try { return await fetchStore(url, rec); }
|
|
|
|
|
catch (e) { if (rec) return rec.d; throw e; } // offline/unreachable → stale beats an error
|
|
|
|
|
}
|
|
|
|
|
|
Extract the chart library and the chunked streaming fetch (#49)
app.js carried the whole SVG chart implementation (~380 lines: palette,
percentile fans, the shared line-series renderer, per-metric wrappers,
pointer hover) alongside its page logic. That moves to chart.js: the
drawing dimensions live there as live-bound module state behind
setChartWidth(); attachChartHover takes the day list as a parameter
instead of reaching into page globals. app.js keeps buildChart and the
PNG export — page orchestration — and drops to ~500 lines. Any page can
now plot a metric series (e.g. a history sparkline on the day page).
calendar.js's hand-rolled streaming loader — bounded-parallel pool,
token cancellation, stale-while-revalidate re-merge — becomes
cache.js chunkedFetch(urls, {ttl, concurrency, isCurrent, onResult});
fetchCalendar keeps only its intent (chunk URLs, day merge, contiguous-
prefix rendering). Same pool semantics, verbatim.
Verified: node --check; 108 backend tests; headless-Chromium smoke on
all five pages against live data (chart renders + metric toggle,
calendar grid streams + metric switch, ladders, compare, legend).
2026-07-11 20:33:06 +00:00
|
|
|
// Load an ordered list of URLs through the cache with a bounded-parallel pool.
|
|
|
|
|
// Results (or {error}) are reported in place via onResult(i, result, isUpdate) —
|
|
|
|
|
// including stale-while-revalidate updates — so callers can render the
|
|
|
|
|
// contiguous loaded prefix while later chunks are still in flight. `isCurrent`
|
|
|
|
|
// is the caller's supersession guard: once it returns false the pool stops
|
|
|
|
|
// launching and reports nothing more. Resolves when all chunks settle (or the
|
|
|
|
|
// caller is superseded).
|
|
|
|
|
export async function chunkedFetch(urls, { ttl, concurrency = 4, isCurrent = () => true, onResult }) {
|
|
|
|
|
const run = async (i) => {
|
|
|
|
|
try {
|
|
|
|
|
const d = await getJSON(urls[i], ttl, true, (upd) => {
|
|
|
|
|
if (isCurrent()) onResult(i, upd, true);
|
|
|
|
|
});
|
|
|
|
|
if (isCurrent()) onResult(i, d, false);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if (isCurrent()) onResult(i, { error: err }, false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
await new Promise((resolve) => {
|
|
|
|
|
let launched = 0, settled = 0;
|
|
|
|
|
const pump = () => {
|
|
|
|
|
if (!isCurrent()) return resolve();
|
|
|
|
|
while (launched < urls.length && launched - settled < concurrency) {
|
|
|
|
|
run(launched++).finally(() => {
|
|
|
|
|
settled++;
|
|
|
|
|
if (settled === urls.length) resolve();
|
|
|
|
|
else pump();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
pump();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
|
|
|
// Is there already a usable (fresh, same-day) cache entry for this URL? Lets the
|
|
|
|
|
// prefetch skip views that are already warm and only reach out for the ones missing.
|
|
|
|
|
export async function hasFreshCache(url, ttlMs) {
|
|
|
|
|
return isFresh(await cacheGet(url), ttlMs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Seed the cache for a URL from data that arrived by other means (a bundle
|
|
|
|
|
// slice), with the etag its own endpoint would have sent — so the entry is
|
|
|
|
|
// later revalidated per-view exactly as if it had been fetched directly.
|
|
|
|
|
function seedCache(url, data, etag) {
|
|
|
|
|
cachePut({ url, t: Date.now(), day: localDay(), etag: etag || null, d: data });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The per-view primary URLs the /cell bundle's slices map onto. This is the
|
|
|
|
|
// bundle contract (the server builds exactly these default requests), so it
|
|
|
|
|
// lives here next to the bundle fetch — not knowledge of any one page.
|
|
|
|
|
function viewFetches(q, today) {
|
|
|
|
|
return {
|
|
|
|
|
grade: [`api/grade?${q}`, TTL.grade],
|
|
|
|
|
forecast: [`api/v2/forecast?${q}`, TTL.forecast],
|
|
|
|
|
calendar: [`api/v2/calendar?${q}&months=24`, TTL.calendar],
|
|
|
|
|
day: [`api/v2/day?${q}&date=${today}`, TTL.day],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// After the landing page's own data is up, warm the OTHER views so switching to
|
|
|
|
|
// them is instant — with ONE /api/v2/cell bundle request instead of one per view.
|
|
|
|
|
// Its slices are the exact per-view payloads (each carrying the etag its endpoint
|
|
|
|
|
// would emit), seeded under the per-view URLs the views actually request. The
|
|
|
|
|
// bundle call itself is conditional via a remembered etag, so a still-valid spot
|
|
|
|
|
// costs an empty 304. Deduped per spot; afterwards the 8 surrounding grid cells
|
|
|
|
|
// are warmed server-side (see prefetchNeighbors).
|
|
|
|
|
//
|
|
|
|
|
// `ownViews` lists the view names the calling page already loads in the
|
|
|
|
|
// foreground (so they're skipped) — the page declares what it owns instead of
|
|
|
|
|
// this module knowing every page.
|
|
|
|
|
let _prefetched = "";
|
|
|
|
|
export function prefetchViews(lat, lon, ownViews) {
|
|
|
|
|
const tag = `${lat.toFixed(4)},${lon.toFixed(4)}`;
|
|
|
|
|
if (_prefetched === tag) return;
|
|
|
|
|
_prefetched = tag;
|
|
|
|
|
const q = `lat=${lat}&lon=${lon}`;
|
|
|
|
|
// Yield first so the landing view finishes rendering before we fetch the rest.
|
|
|
|
|
setTimeout(async () => {
|
|
|
|
|
const fetches = viewFetches(q, localDay());
|
|
|
|
|
const own = new Set(ownViews);
|
|
|
|
|
let missing = false;
|
|
|
|
|
for (const name of Object.keys(fetches)) {
|
|
|
|
|
if (own.has(name)) continue;
|
|
|
|
|
if (!(await hasFreshCache(...fetches[name]))) { missing = true; break; }
|
|
|
|
|
}
|
|
|
|
|
if (missing) {
|
|
|
|
|
try {
|
|
|
|
|
const ek = "tg-cell-etag:" + q;
|
|
|
|
|
let prev = null;
|
|
|
|
|
try { prev = sessionStorage.getItem(ek); } catch (e) {}
|
|
|
|
|
const res = await fetch(`api/v2/cell?${q}`, prev ? { headers: { "If-None-Match": prev } } : {});
|
|
|
|
|
if (res.ok && res.status !== 304) {
|
|
|
|
|
const bundle = await res.json();
|
|
|
|
|
try { sessionStorage.setItem(ek, res.headers.get("ETag") || ""); } catch (e) {}
|
|
|
|
|
for (const [name, slice] of Object.entries(bundle.slices || {})) {
|
|
|
|
|
if (!slice || !slice.data) continue;
|
|
|
|
|
const url = name === "day"
|
|
|
|
|
? `api/v2/day?${q}&date=${bundle.today}` // the day slice targets today
|
|
|
|
|
: (fetches[name] || [])[0];
|
|
|
|
|
if (url) seedCache(url, slice.data, slice.etag);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {} // prefetch is a bonus; every view still fetches for itself
|
|
|
|
|
}
|
|
|
|
|
prefetchNeighbors(lat, lon);
|
|
|
|
|
}, 350);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Warm the server for the 8 grid cells around the selected one, so tapping a
|
|
|
|
|
// nearby spot lands on already-graded data. prefetch=1 is warm-only: the server
|
|
|
|
|
// never spends weather-API quota for it (a cold cell answers 204), and at most
|
|
|
|
|
// one reverse-geocode lookup happens per brand-new cell — hence the ≥1.1 s
|
|
|
|
|
// stagger, respecting Nominatim's 1-request/second policy. Responses aren't
|
|
|
|
|
// stored client-side (cache keys are coordinate-based); the value is server-side
|
|
|
|
|
// warmth. Mirrors backend/grid.py's snapping math.
|
|
|
|
|
const _warmedCells = new Set();
|
|
|
|
|
function prefetchNeighbors(lat, lon) {
|
|
|
|
|
const latStep = 1 / 34.5;
|
|
|
|
|
const i = Math.floor(lat / latStep);
|
|
|
|
|
const centerLat = (i + 0.5) * latStep;
|
|
|
|
|
const lonStep = latStep / Math.max(Math.cos((centerLat * Math.PI) / 180), 0.05);
|
|
|
|
|
const j = Math.floor(lon / lonStep);
|
|
|
|
|
const centerLon = (j + 0.5) * lonStep;
|
|
|
|
|
let k = 0;
|
|
|
|
|
for (const di of [-1, 0, 1]) {
|
|
|
|
|
for (const dj of [-1, 0, 1]) {
|
|
|
|
|
if (!di && !dj) continue;
|
|
|
|
|
const nLat = centerLat + di * latStep;
|
|
|
|
|
if (Math.abs(nLat) > 90) continue; // no rows past the poles
|
|
|
|
|
const nLon = ((centerLon + dj * lonStep + 540) % 360) - 180; // wrap across the antimeridian
|
|
|
|
|
const cellTag = `${i + di}_${j + dj}`;
|
|
|
|
|
if (_warmedCells.has(cellTag)) continue;
|
|
|
|
|
_warmedCells.add(cellTag);
|
|
|
|
|
const nq = `lat=${nLat.toFixed(5)}&lon=${nLon.toFixed(5)}`;
|
|
|
|
|
setTimeout(() => { fetch(`api/v2/cell?${nq}&prefetch=1`).catch(() => {}); }, 1500 + 1100 * k++);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|