// 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. // // URLs are resolved via account.js's `uv()` (backend's own origin+base, API // version pinned) rather than a bare relative fetch() — account.js is always // served by backend, so this keeps every request correctly targeted at // backend even when this script is loaded from a frontend_ssr-rendered page // on a different origin (repo-split Stage 5). credentials: "include" (not the // fetch default) is what makes the auth cookie ride along in that same case. // Every URL a caller passes into getJSON/chunkedFetch below is expected to // already be uv()-resolved (a full absolute URL) — this module doesn't // resolve raw resource paths itself. // // 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. import { uv } from "./account.js"; export const TTL = { grade: 600000, forecast: 1800000, calendar: 21600000, day: 600000, score: 21600000 }; // calendar/score: 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, credentials: "include" }); 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 } // A guarded stale-while-revalidate load for a single primary view (grade, score…). // Centralizes the fiddly, easy-to-get-wrong bits shared by those loaders: a // spinner delayed 150ms so a warm render never flashes it, supersession so a // newer selection wins (`seq` is this call's captured token, `current()` the live // one), an SWR repaint via getJSON's update callback, and clearing the spinner on // both the success and error paths. `spinner`/`onData`/`onError` are caller UI // callbacks; `onData` handles both the SWR update and the final result. export async function loadView({ url, ttl, seq, current, spinner, onData, onError }) { const spin = setTimeout(() => { if (seq === current()) spinner(); }, 150); let data; try { data = await getJSON(url, ttl, false, (upd) => { if (seq === current()) onData(upd); }); } catch (err) { clearTimeout(spin); if (seq === current()) onError(err); return; } clearTimeout(spin); if (seq === current()) onData(data); } // 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: [uv(`grade?${q}&date=${today}`), TTL.grade], forecast: [uv(`forecast?${q}`), TTL.forecast], calendar: [uv(`calendar?${q}&months=24`), TTL.calendar], day: [uv(`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}`; // The client's local "today" — sent explicitly so the server never has to guess // it from its own (UTC) clock. Getting this wrong is exactly what poisoned the // Weekly view's cache for UTC+ visitors between their local midnight and UTC // midnight: the bundle used to be built against the server's date while the // per-view request stated the client's, so the two silently disagreed. const today = localDay(); 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(uv(`cell?${q}&date=${today}&neighbors=1`), { credentials: "include", ...(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) {} // Seed under bundle.today (the date the server actually resolved — normally // just an echo of the date= we sent) rather than our own `today`, so the // seeded key stays byte-identical to what a per-view request for that same // resolved date will use even in the rare case the two disagree (e.g. a // request that straddles local midnight). const fetches = viewFetches(q, bundle.today); for (const [name, slice] of Object.entries(bundle.slices || {})) { if (own.has(name) || !slice || !slice.data) continue; const url = (fetches[name] || [])[0]; if (url) seedCache(url, slice.data, slice.etag); } } } catch (e) {} // prefetch is a bonus; every view still fetches for itself }, 350); }