diff --git a/static/app.js b/static/app.js index d542fff..99a02e9 100644 --- a/static/app.js +++ b/static/app.js @@ -51,12 +51,20 @@ results.addEventListener("click", (e) => { location.href = `day#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}&date=${row.dataset.date}`; }); +let gradeSeq = 0; // supersedes an in-flight load when the user picks a new spot/date + async function runGrade() { if (!selected) return; updateHash(); placeholder.hidden = true; results.hidden = false; - results.innerHTML = `

Fetching decades of climate history…

`; + const seq = ++gradeSeq; + // Delay the spinner: a cache-served render lands in a few ms, so blanking the + // panel immediately would just flash. Only a genuinely slow (network) load + // ever shows it. + const spin = setTimeout(() => { + if (seq === gradeSeq) results.innerHTML = `

Fetching decades of climate history…

`; + }, 150); // Omit the date when it's today, so the URL (and cache key) matches the prefetch // fired from the other views — a same-tab navigation then reuses the response. @@ -64,11 +72,19 @@ async function runGrade() { const url = dateInput.value === todayISO() ? `api/grade?${q}` : `api/grade?${q}&date=${dateInput.value}`; let data; try { - data = await window.Thermograph.getJSON(url, window.Thermograph.TTL.grade); + // Stale-while-revalidate: a stale cached copy renders immediately and the + // update callback repaints if the background revalidation finds new data. + data = await window.Thermograph.getJSON(url, window.Thermograph.TTL.grade, false, (upd) => { + if (seq === gradeSeq) render(upd); + }); } catch (err) { + clearTimeout(spin); + if (seq !== gradeSeq) return; results.innerHTML = `

${err.message}

`; return; } + clearTimeout(spin); + if (seq !== gradeSeq) return; render(data); } diff --git a/static/calendar.js b/static/calendar.js index 3570d48..1d7f69b 100644 --- a/static/calendar.js +++ b/static/calendar.js @@ -450,10 +450,16 @@ async function fetchCalendar() { placeholder.hidden = true; refreshBtn.hidden = true; calHead.hidden = false; - calHead.innerHTML = `

Grading daily weather across the range…

`; - calEl.innerHTML = ""; - keyEl.innerHTML = ""; data = null; + // Delay the spinner: cache-served chunks render in a few ms, so clearing the + // grid immediately would just flash-blank a warm load. A genuinely slow + // (network) load still gets the spinner and a cleared grid. + const spin = setTimeout(() => { + if (token !== fetchToken || data) return; + calHead.innerHTML = `

Grading daily weather across the range…

`; + calEl.innerHTML = ""; + keyEl.innerHTML = ""; + }, 150); const q = `lat=${selected.lat}&lon=${selected.lon}`; // A custom span is split into ≤2-year requests; the default (no range) is one @@ -498,7 +504,15 @@ async function fetchCalendar() { ? `api/v2/calendar?${q}&start=${ch.start}&end=${ch.end}` : `api/v2/calendar?${q}&months=24`; try { - const d = await window.Thermograph.getJSON(url, window.Thermograph.TTL.calendar, true); + // Stale-while-revalidate: a stale cached chunk renders immediately; if the + // background revalidation finds changed data, re-merge that chunk's days + // and repaint (the cell/place metadata is identical across versions). + const d = await window.Thermograph.getJSON(url, window.Thermograph.TTL.calendar, true, (upd) => { + if (token !== fetchToken) return; + results[i] = upd; + for (const x of upd.days) dayMap.set(x.date, x); + advance(); + }); if (token !== fetchToken) return; results[i] = d; for (const x of d.days) dayMap.set(x.date, x); @@ -526,6 +540,7 @@ async function fetchCalendar() { }; pump(); }); + clearTimeout(spin); if (token !== fetchToken) return; // superseded while loading if (!data) { // every chunk failed diff --git a/static/day.js b/static/day.js index 57085e1..920c8b8 100644 --- a/static/day.js +++ b/static/day.js @@ -93,21 +93,42 @@ function stepDay(delta) { } // ---- fetch + render ---- +let daySeq = 0; // supersedes an in-flight load when the user picks a new spot/date + async function fetchDay() { if (!selected) return; const dateQ = curDate ? `&date=${curDate}` : ""; history.replaceState(null, "", `#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}${curDate ? `&date=${curDate}` : ""}`); placeholder.hidden = true; dayHead.hidden = false; - dayHead.innerHTML = `

Building the percentile breakdown…

`; - dayBody.innerHTML = ""; + const seq = ++daySeq; + // Delay the spinner: a cache-served render lands in a few ms, so blanking the + // page immediately would just flash. Only a slow (network) load shows it. + const spin = setTimeout(() => { + if (seq !== daySeq) return; + dayHead.innerHTML = `

Building the percentile breakdown…

`; + dayBody.innerHTML = ""; + }, 150); let data; try { - data = await window.Thermograph.getJSON(`api/v2/day?lat=${selected.lat}&lon=${selected.lon}${dateQ}`, window.Thermograph.TTL.day); + // Stale-while-revalidate: a stale cached copy renders immediately; the update + // callback repaints if the background revalidation finds new data. + data = await window.Thermograph.getJSON( + `api/v2/day?lat=${selected.lat}&lon=${selected.lon}${dateQ}`, window.Thermograph.TTL.day, + false, (upd) => { if (seq === daySeq) finishDay(upd); }); } catch (err) { + clearTimeout(spin); + if (seq !== daySeq) return; dayHead.innerHTML = `

${err.message}

`; + dayBody.innerHTML = ""; return; } + clearTimeout(spin); + if (seq !== daySeq) return; + finishDay(data); +} + +function finishDay(data) { latest = data.latest; curDate = data.detail.date; dateInput.value = curDate; diff --git a/static/nav.js b/static/nav.js index e20ac8a..9967875 100644 --- a/static/nav.js +++ b/static/nav.js @@ -57,112 +57,225 @@ function refreshNav(lat, lon, date) { if (loc) refreshNav(loc.lat, loc.lon, loc.date); })(); -// ---- shared response cache + cross-view prefetch ---- -// Cached API JSON lets the three views (a full page load each) reuse data instead -// of refetching, and the server caches upstream so a client miss is still cheap. -// The calendar cache is *persistent* (localStorage) so a page refresh — or coming -// back to a location viewed earlier — repaints instantly instead of regrading. +// ---- 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. const TTL = { grade: 600000, forecast: 1800000, calendar: 21600000, day: 600000 }; // calendar: 6h -// Evict the oldest N "tg:" entries from a Storage (ordered by stored timestamp) to -// make room when it hits quota. -function evictOldestCache(store, n) { - const items = []; - for (let i = 0; i < store.length; i++) { - const k = store.key(i); - if (!k || !k.startsWith("tg:")) continue; - let t = 0; - try { t = JSON.parse(store.getItem(k)).t || 0; } catch (e) {} - items.push([k, t]); - } - items.sort((a, b) => a[1] - b[1]); - for (let i = 0; i < Math.min(n, items.length); i++) store.removeItem(items[i][0]); +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; } -function putCache(store, key, s) { - try { store.setItem(key, s); } - catch (e) { evictOldestCache(store, 8); try { store.setItem(key, s); } catch (e2) {} } +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. +// 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")}`; } -// `persist` picks localStorage (survives tab close / navigating back to a prior -// spot); otherwise sessionStorage (per-tab). A cache hit needs BOTH freshness -// checks: within its TTL, and stored on today's local date. -async function getJSON(url, ttlMs = 600000, persist = false) { - const store = persist ? localStorage : sessionStorage; - const key = "tg:" + url; - try { - const raw = store.getItem(key); - if (raw) { - const o = JSON.parse(raw); - if (o.day === localDay() && Date.now() - o.t < ttlMs) return o.d; - } - } catch (e) {} - const res = await fetch(url); +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; + } const d = await res.json(); if (!res.ok) { const err = new Error(d.detail || "request failed"); err.status = res.status; throw err; } - try { - const s = JSON.stringify({ t: Date.now(), day: localDay(), d }); - if (s.length < 1500000) putCache(store, key, s); // skip caching very large payloads - } catch (e) {} + 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. +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 +} + // 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. -function hasFreshCache(url, ttlMs, persist) { - const store = persist ? localStorage : sessionStorage; - try { - const raw = store.getItem("tg:" + url); - if (!raw) return false; - const o = JSON.parse(raw); - return o.day === localDay() && Date.now() - o.t < ttlMs; - } catch (e) { return false; } +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 primary API each view loads first. Weekly (the map page) owns two panels — // its grade and its forecast — so leaving that page prefetches neither. -function viewFetches(q) { +function viewFetches(q, today) { return { - grade: [`api/grade?${q}`, TTL.grade, false], - forecast: [`api/v2/forecast?${q}`, TTL.forecast, false], - calendar: [`api/v2/calendar?${q}&months=24`, TTL.calendar, true], - day: [`api/v2/day?${q}`, TTL.day, false], + 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], }; } // The fetch(es) that belong to the page prefetch is called from, so they're skipped // (that data is already loading in the foreground). const VIEW_OWN = { grade: ["grade", "forecast"], calendar: ["calendar"], day: ["day"] }; -// After the landing page's own data is up, warm the OTHER views so switching to them -// is instant. Each view with no fresh same-day cache is fetched once in the -// background (already-cached views are left alone). Deduped per spot. +// 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). let _prefetched = ""; function prefetchViews(lat, lon, skip) { - const tag = `${lat.toFixed(4)},${lon.toFixed(4)},${skip}`; + const tag = `${lat.toFixed(4)},${lon.toFixed(4)}`; if (_prefetched === tag) return; _prefetched = tag; const q = `lat=${lat}&lon=${lon}`; - const fetches = viewFetches(q); - const own = new Set(VIEW_OWN[skip] || [skip]); - const warm = () => { - for (const name of Object.keys(fetches)) { - if (own.has(name)) continue; // the current page's own data - const [url, ttl, persist] = fetches[name]; - if (hasFreshCache(url, ttl, persist)) continue; // already warm — no refetch - getJSON(url, ttl, persist).catch(() => {}); - } - }; // Yield first so the landing view finishes rendering before we fetch the rest. - setTimeout(warm, 350); + setTimeout(async () => { + const fetches = viewFetches(q, localDay()); + const own = new Set(VIEW_OWN[skip] || [skip]); + 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 cellTag = `${i + di}_${j + dj}`; + if (_warmedCells.has(cellTag)) continue; + _warmedCells.add(cellTag); + const nq = `lat=${(centerLat + di * latStep).toFixed(5)}&lon=${(centerLon + dj * lonStep).toFixed(5)}`; + setTimeout(() => { fetch(`api/v2/cell?${nq}&prefetch=1`).catch(() => {}); }, 1500 + 1100 * k++); + } + } } window.Thermograph = { loadLastLocation, saveLastLocation, getJSON, prefetchViews, hasFreshCache, TTL };