diff --git a/static/app.js b/static/app.js
index 0dac510..f7953f9 100644
--- a/static/app.js
+++ b/static/app.js
@@ -1,7 +1,10 @@
-"use strict";
-// Shared tier colors, scales, formatters and helpers (single home: shared.js).
-const { TIER_COLORS, SCALE_TEMP, drynessColor, ord, esc, todayISO, placeLabel,
- fmtPrecip, fmtWind, fmtHumid, locHash } = window.Thermograph;
+// Weekly (map) page. Imports replace the old script-tag ordering contract.
+import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
+import { fmtTemp, toUnit, onUnitChange } from "./units.js";
+import { getJSON, TTL, prefetchViews } from "./cache.js";
+import { initFindButton, setFindLabel } from "./mappicker.js";
+import { TIER_COLORS, SCALE_TEMP, drynessColor, ord, esc, todayISO, placeLabel,
+ fmtPrecip, fmtWind, fmtHumid } from "./shared.js";
let selected = null; // {lat, lon}
@@ -38,7 +41,7 @@ function selectLocation(lat, lon) {
// The Find button opens the shared modal map picker; choosing a spot loads it.
const findBtn = document.getElementById("find-btn");
const locLabel = document.getElementById("loc-label");
-window.LocationPicker.initFindButton(findBtn, "Find a location",
+initFindButton(findBtn, "Find a location",
() => selected, (lat, lon) => selectLocation(lat, lon));
dateInput.addEventListener("change", () => { syncTodayBtn(); selected && runGrade(); });
@@ -78,7 +81,7 @@ async function runGrade() {
try {
// 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) => {
+ data = await getJSON(url, TTL.grade, false, (upd) => {
if (seq === gradeSeq) render(upd);
});
} catch (err) {
@@ -92,13 +95,10 @@ async function runGrade() {
render(data);
}
-// Temps flow through the shared unit formatter (°F from the API, shown in the
-// active unit); the others are unit-agnostic.
-const fmtTemp = (v) => window.Thermograph.fmtTemp(v);
// Compact cell formatters for the dense recent/forecast table (units live in the
// column headers, so values stay short). Dry days label the running dry streak
// (see precipCell) rather than the rain amount.
-const cTemp = (v) => window.Thermograph.fmtTemp(v); // active unit, bare degree
+const cTemp = fmtTemp; // active unit, bare degree
const cHum = (v) => (v == null ? "—" : `${Math.round(v)}%`);
const cWind = (v) => (v == null ? "—" : `${Math.round(v)}`);
const cRain = (v) => (v == null ? "—" : v > 0 ? v.toFixed(2) : "·");
@@ -119,7 +119,7 @@ function render(data) {
// results have rendered (otherwise the name would show twice).
locLabel.hidden = true;
locLabel.textContent = "";
- window.LocationPicker.setFindLabel(findBtn, "Change location");
+ setFindLabel(findBtn, "Change location");
const c = data.climatology;
const cell = data.cell;
const yrs = c.year_range ? `${c.year_range[0]}–${c.year_range[1]}` : "—";
@@ -180,7 +180,7 @@ function render(data) {
`;
renderSeries(); // fills #series (chart + graded-days timeline) from the window
- window.Thermograph.prefetchViews(selected.lat, selected.lon, "grade"); // warm calendar/forecast
+ prefetchViews(selected.lat, selected.lon, ["grade", "forecast"]); // warm calendar/day
document.getElementById("btn-link").onclick = copyShareLink;
document.getElementById("btn-png").onclick = downloadChartPng;
}
@@ -249,7 +249,7 @@ function daysTable(rows, targetDate) {
let recentData = null; // /grade response — the whole window, newest-first
// Repaint everything in the newly-picked unit.
-window.Thermograph.onUnitChange(() => {
+onUnitChange(() => {
if (recentData) render(recentData);
});
@@ -561,7 +561,7 @@ function tempChart(key, days, n, xFor, C) {
// Temperature series (° suffix) show in the active unit; wind/gust stay in mph.
// The plot keeps its °F domain — only the labels convert — so positions hold.
const isTemp = s.sfx === "°";
- const fmtV = (v) => `${Math.round(isTemp ? window.Thermograph.toUnit(v) : v)}${s.sfx}`;
+ const fmtV = (v) => `${Math.round(isTemp ? toUnit(v) : v)}${s.sfx}`;
return lineChart({
days, n, xFor, C, key, color: s.color, fan: TEMP_FAN, hasNormals: true, baseZero: false,
getVal: (d) => (d[key] ? d[key].value : null),
@@ -629,7 +629,7 @@ function attachChartHover(wrap) {
const pct = g.percentile == null ? "" : ` · ${ord(g.percentile)} pct`;
const gc = TIER_COLORS[g.class] || "";
// "°" marks a temperature line — show it in the active unit; others are as-is.
- const val = unit === "°" ? Math.round(window.Thermograph.toUnit(g.value)) : g.value;
+ const val = unit === "°" ? Math.round(toUnit(g.value)) : g.value;
return `
${label} ${val}${unit}${pct} ${g.grade}
`;
};
@@ -763,13 +763,13 @@ function flashBtn(id, text) {
function updateHash() {
if (!selected) return;
history.replaceState(null, "", locHash(selected.lat, selected.lon, dateInput.value));
- window.Thermograph.saveLastLocation(selected.lat, selected.lon, dateInput.value);
+ saveLastLocation(selected.lat, selected.lon, dateInput.value);
}
// Start where you left off: an explicit link (hash) wins, otherwise the last
// place you looked at (remembered across views), otherwise the default world map.
function restoreFromHash() {
- const loc = window.Thermograph.loadLastLocation();
+ const loc = loadLastLocation();
if (!loc) return;
if (loc.date) dateInput.value = loc.date;
syncTodayBtn();
diff --git a/static/cache.js b/static/cache.js
new file mode 100644
index 0000000..e329e24
--- /dev/null
+++ b/static/cache.js
@@ -0,0 +1,234 @@
+// 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
+}
+
+// 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++);
+ }
+ }
+}
diff --git a/static/calendar.html b/static/calendar.html
index 45dc6e7..08a463f 100644
--- a/static/calendar.html
+++ b/static/calendar.html
@@ -93,9 +93,6 @@
-
-
-
-
+