thermograph/static/cache.js
Emi Griffith 431457132a Warm neighbor cells server-side (/cell?neighbors=1) (#51)
cache.js contained a JavaScript clone of backend/grid.py's snapping math
(its own comment said so) to compute the 8 surrounding cells and fire 8
staggered prefetch requests — grid geometry had two homes, one per
language, plus a client-side guess at Nominatim pacing.

The server now owns it: grid.neighbors(cell) steps one cell width from
the center and re-snaps (adjacent rows have different longitude steps;
poles and the antimeridian handled by snap), and /api/v2/cell grew a
neighbors=1 flag that enqueues those cells for a single background
worker. The warm-only guarantee matches prefetch=1 — a cell with no
cached archive is skipped, so no weather-API quota is ever spent — and
reverse_geocode's own lock paces the at-most-one Nominatim call per
never-labeled cell. Re-enqueues are TTL-deduped; the worker starts from
the lifespan hook, so tests and offline importers never spawn it.

The client now sends its one conditional bundle request with
neighbors=1 (a warm spot costs an empty 304) instead of skipping the
bundle and firing 8 extra requests; the grid-math clone and the
now-unused hasFreshCache are deleted.

Tests (114): grid.neighbors mid-latitude/pole/antimeridian, the
neighbors=1 enqueue + TTL dedupe, _warm_cell materializing the
history-derived store rows, and the never-fetch-upstream guarantee.
Verified with the headless-Chromium smoke across all five pages.
2026-07-11 20:47:31 +00:00

225 lines
9.7 KiB
JavaScript

// 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
}
// 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();
});
}
// 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
// request is conditional via a remembered etag — a still-valid spot costs an
// empty 304 — and neighbors=1 has the server warm the 8 surrounding grid cells
// itself: it owns the snapping math (grid.py) and the Nominatim pacing, so the
// old client-side clone of both is gone. Deduped per spot per page load.
//
// `ownViews` lists the view names the calling page already loads in the
// foreground (so they're skipped when seeding) — 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}`;
const own = new Set(ownViews);
// Yield first so the landing view finishes rendering before we fetch the rest.
setTimeout(async () => {
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}&neighbors=1`,
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) {}
const fetches = viewFetches(q, localDay());
for (const [name, slice] of Object.entries(bundle.slices || {})) {
if (own.has(name) || !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
}, 350);
}