2026-07-11 00:29:47 +00:00
|
|
|
|
"use strict";
|
|
|
|
|
|
// Thermograph single-day detail subpage — for one day + location, shows the value
|
|
|
|
|
|
// at every percentile tier boundary in that day-of-year's ±7-day climate window,
|
|
|
|
|
|
// and where the observed values land. Talks to /api/v2/day + /api/v2/geocode.
|
|
|
|
|
|
// Self-contained; re-declares the small shared tier constants (like calendar.js)
|
|
|
|
|
|
// so the page stays independent of app.js.
|
|
|
|
|
|
|
|
|
|
|
|
const TIER_COLORS = {
|
2026-07-11 08:06:30 +00:00
|
|
|
|
"rec-hot": "#8f0e20", "very-hot": "#dd6b52", "hot": "#ef9351", "warm": "#f6c18a",
|
|
|
|
|
|
"normal": "#4a9d5b",
|
|
|
|
|
|
"cool": "#92c5de", "cold": "#4393c3", "very-cold": "#2166ac", "rec-cold": "#0a2f6b",
|
2026-07-11 00:29:47 +00:00
|
|
|
|
};
|
|
|
|
|
|
const PRECIP_COLORS = {
|
2026-07-11 08:06:30 +00:00
|
|
|
|
"wet-1": "#d9f0d3", "wet-2": "#b7e2b1", "wet-3": "#8fd18f", "wet-4": "#5cba9f", "wet-5": "#35a1c0",
|
|
|
|
|
|
"wet-6": "#2b8cbe", "wet-7": "#226bb3", "wet-8": "#164a97", "wet-9": "#08306b",
|
2026-07-11 00:29:47 +00:00
|
|
|
|
};
|
2026-07-11 08:06:30 +00:00
|
|
|
|
const DRY_COLOR = "#c9a24a";
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
const fmtTemp = (v) => (v == null ? "—" : `${Math.round(v)}°`);
|
|
|
|
|
|
const fmtPrecip = (v) => (v == null ? "—" : `${v.toFixed(2)}"`);
|
|
|
|
|
|
const fmtWind = (v) => (v == null ? "—" : `${Math.round(v)} mph`);
|
|
|
|
|
|
const fmtHumid = (v) => (v == null ? "—" : `${v.toFixed(1)} g/m³`);
|
|
|
|
|
|
const ord = (n) => {
|
|
|
|
|
|
const r = Math.round(n), s = ["th", "st", "nd", "rd"], v = r % 100;
|
|
|
|
|
|
return r + (s[(v - 20) % 10] || s[v] || s[0]);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Color a tier the same way the calendar cell would be colored for it.
|
|
|
|
|
|
const tierColor = (c) => (c === "dry" ? DRY_COLOR : TIER_COLORS[c] || PRECIP_COLORS[c] || "");
|
|
|
|
|
|
|
|
|
|
|
|
// Monochrome line icons (currentColor) — no emoji, matching the site's dark look.
|
|
|
|
|
|
const WX = (paths) =>
|
|
|
|
|
|
`<svg class="wx" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
|
|
|
|
|
const WX_CLOUD = `<path d="M20 16.6A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25"/>`;
|
|
|
|
|
|
const WX_ICONS = {
|
|
|
|
|
|
sun: WX(`<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.2" y1="4.2" x2="5.6" y2="5.6"/><line x1="18.4" y1="18.4" x2="19.8" y2="19.8"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.2" y1="19.8" x2="5.6" y2="18.4"/><line x1="18.4" y1="5.6" x2="19.8" y2="4.2"/>`),
|
|
|
|
|
|
drizzle: WX(`${WX_CLOUD}<line x1="8" y1="19" x2="8" y2="21"/><line x1="16" y1="19" x2="16" y2="21"/><line x1="12" y1="20" x2="12" y2="22"/>`),
|
|
|
|
|
|
rain: WX(`${WX_CLOUD}<line x1="8" y1="19" x2="8" y2="22"/><line x1="16" y1="19" x2="16" y2="22"/><line x1="12" y1="19" x2="12" y2="23"/>`),
|
|
|
|
|
|
storm: WX(`<path d="M19 16.9A5 5 0 0 0 18 7h-1.26a8 8 0 1 0-11.62 9"/><polyline points="13 11 9 17 15 17 11 23"/>`),
|
|
|
|
|
|
snow: WX(`<path d="M20 17.6A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25"/><line x1="8" y1="18" x2="8" y2="18"/><line x1="12" y1="20" x2="12" y2="20"/><line x1="16" y1="18" x2="16" y2="18"/><line x1="12" y1="16" x2="12" y2="16"/>`),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Plain-language "what the day was like" descriptor from the observed values —
|
|
|
|
|
|
// mirrors the calendar tooltip. (No dry-streak here, so no-rain days read "clear".)
|
|
|
|
|
|
function weatherType(t, p) {
|
|
|
|
|
|
const wet = p != null && p >= 0.01;
|
|
|
|
|
|
const freezing = t != null && t <= 34;
|
|
|
|
|
|
const temp = t == null ? "" :
|
|
|
|
|
|
t >= 95 ? "Scorching" : t >= 85 ? "Hot" : t >= 72 ? "Warm" :
|
|
|
|
|
|
t >= 58 ? "Mild" : t >= 45 ? "Cool" : t >= 32 ? "Cold" : "Frigid";
|
|
|
|
|
|
let sky, icon;
|
|
|
|
|
|
if (wet && freezing) { sky = p >= 0.3 ? "heavy snow" : "snow"; icon = WX_ICONS.snow; }
|
|
|
|
|
|
else if (wet && p >= 1.0) { sky = "downpour"; icon = WX_ICONS.storm; }
|
|
|
|
|
|
else if (wet && p >= 0.3) { sky = "rain"; icon = WX_ICONS.rain; }
|
|
|
|
|
|
else if (wet) { sky = "light rain"; icon = WX_ICONS.drizzle; }
|
|
|
|
|
|
else { sky = "clear"; icon = WX_ICONS.sun; }
|
|
|
|
|
|
const text = !temp ? sky : wet ? `${temp}, ${sky}` : `${temp} & ${sky}`;
|
|
|
|
|
|
return { icon, text: text.charAt(0).toUpperCase() + text.slice(1) };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let selected = null; // {lat, lon}
|
|
|
|
|
|
let curDate = null; // "YYYY-MM-DD" currently shown
|
|
|
|
|
|
let latest = null; // newest date available in the record
|
|
|
|
|
|
const todayISO = () => new Date().toISOString().slice(0, 10);
|
|
|
|
|
|
|
|
|
|
|
|
const placeholder = document.getElementById("day-placeholder");
|
|
|
|
|
|
const dayHead = document.getElementById("day-head");
|
|
|
|
|
|
const dayBody = document.getElementById("day-body");
|
|
|
|
|
|
const dateInput = document.getElementById("date-input");
|
|
|
|
|
|
|
|
|
|
|
|
// ---- location picker ----
|
|
|
|
|
|
// 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");
|
|
|
|
|
|
findBtn.innerHTML = `${window.LocationPicker.PIN_ICON} <span>Find a location</span>`;
|
|
|
|
|
|
findBtn.addEventListener("click", () => {
|
|
|
|
|
|
window.LocationPicker.open(selected, (lat, lon) => { selected = { lat, lon }; fetchDay(); });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ---- date navigation ----
|
|
|
|
|
|
dateInput.addEventListener("change", () => { if (dateInput.value) { curDate = dateInput.value; fetchDay(); } });
|
|
|
|
|
|
document.getElementById("prev-day").addEventListener("click", () => stepDay(-1));
|
|
|
|
|
|
document.getElementById("next-day").addEventListener("click", () => stepDay(1));
|
|
|
|
|
|
|
|
|
|
|
|
function stepDay(delta) {
|
|
|
|
|
|
if (!curDate) return;
|
|
|
|
|
|
const d = new Date(curDate + "T00:00:00");
|
|
|
|
|
|
d.setDate(d.getDate() + delta);
|
|
|
|
|
|
const iso = d.toISOString().slice(0, 10);
|
|
|
|
|
|
if (iso > todayISO()) return; // don't step past today
|
|
|
|
|
|
curDate = iso;
|
|
|
|
|
|
fetchDay();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- fetch + render ----
|
Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21)
* Compress API responses and revalidate static assets instead of re-downloading
- Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x.
- Serve pages/assets with Cache-Control: no-cache instead of no-store, so
browsers revalidate via the ETag/Last-Modified that FileResponse and
StaticFiles already emit. Unchanged assets now cost an empty 304 rather
than a full transfer on every page navigation, while deploys still show
up immediately.
* Persist derived responses in SQLite so grading is computed once per cell, not per request
New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet
records — finished grade/calendar/day/forecast payloads and reverse-geocode
labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms)
becomes a ~5ms database read that survives restarts and is shared across views.
Raw parquet stays the source of truth; the store is a pure accelerator (every
reader falls back to recomputing on a miss, and deleting the db is a safe reset).
Freshness is token-driven, not clock-driven: each cached payload is validated by
a token encoding what it was computed from (payload schema version, the archive
record's end date, the recent-fetch stamp). The existing freshness drivers are
untouched — get_history still tops up the tail hourly and get_recent_forecast
still refetches hourly — and tokens are derived from what they return, so cached
payloads expire exactly when their inputs change. The same tokens double as weak
ETags: If-None-Match answers with an empty 304 without touching the payload.
- backend/store.py: derived-payload + revgeo tables, thread-local WAL conns,
every helper fail-soft.
- app.py: endpoints split into pure payload builders + HTTP/caching shells; the
in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store).
- climate.py: revgeo persisted through the store; recent_stamp() and
load_cached_history() (no-network read) helpers.
- backend/migrate.py + make migrate: idempotent, resumable backfill of the store
from existing parquet caches (default calendar span + latest-day detail +
revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather.
- grid.from_id(): rebuild a cell from its cache filename (migrate tooling).
* Add /api/v2/cell: one bundle carrying every view's payload
GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months)
and day (today) payloads in one response, each the exact payload its per-view
endpoint returns — built by the same builders and cached under the same
derived-store keys/tokens — paired with the etag that endpoint would emit. The
frontend can warm all views with a single request, seed its per-view cache from
the slices, and later revalidate each view individually with If-None-Match. The
bundle's own etag combines the slices', so an unchanged bundle is an empty 304.
prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard
guarantee: it never spends weather-API quota. A cell with no cached archive
answers 204, and only the history-derived slices (calendar + latest-day detail)
are built. At most one Nominatim lookup for a never-labeled cell.
* Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch
The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota,
which multi-year calendar payloads regularly blew through) to IndexedDB, with an
in-memory map in front. Entries carry the server's ETag, so anything stale
revalidates conditionally — unchanged data costs an empty 304 and a re-stamp,
never a re-transfer. On network failure the stale copy is served over an error.
getJSON gains an optional onUpdate callback opting into stale-while-revalidate:
the three views (weekly, day, calendar) now render a cached copy immediately —
spinners are delayed 150ms so warm loads never flash-blank — and repaint only if
background revalidation finds changed data. New data shows up the moment it
exists instead of waiting out a TTL.
Cross-view prefetch collapses from one request per view to a single /api/v2/cell
bundle, whose slices (exact per-view payloads + their etags) are seeded under the
URLs each view actually requests; the bundle call itself is conditional via a
remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side
with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one
possible Nominatim lookup each), so tapping nearby lands on already-graded data.
Legacy tg:* storage entries are cleared once; cache entries untouched for two
weeks are pruned on page load.
2026-07-11 07:31:28 +00:00
|
|
|
|
let daySeq = 0; // supersedes an in-flight load when the user picks a new spot/date
|
|
|
|
|
|
|
2026-07-11 00:29:47 +00:00
|
|
|
|
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;
|
Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21)
* Compress API responses and revalidate static assets instead of re-downloading
- Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x.
- Serve pages/assets with Cache-Control: no-cache instead of no-store, so
browsers revalidate via the ETag/Last-Modified that FileResponse and
StaticFiles already emit. Unchanged assets now cost an empty 304 rather
than a full transfer on every page navigation, while deploys still show
up immediately.
* Persist derived responses in SQLite so grading is computed once per cell, not per request
New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet
records — finished grade/calendar/day/forecast payloads and reverse-geocode
labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms)
becomes a ~5ms database read that survives restarts and is shared across views.
Raw parquet stays the source of truth; the store is a pure accelerator (every
reader falls back to recomputing on a miss, and deleting the db is a safe reset).
Freshness is token-driven, not clock-driven: each cached payload is validated by
a token encoding what it was computed from (payload schema version, the archive
record's end date, the recent-fetch stamp). The existing freshness drivers are
untouched — get_history still tops up the tail hourly and get_recent_forecast
still refetches hourly — and tokens are derived from what they return, so cached
payloads expire exactly when their inputs change. The same tokens double as weak
ETags: If-None-Match answers with an empty 304 without touching the payload.
- backend/store.py: derived-payload + revgeo tables, thread-local WAL conns,
every helper fail-soft.
- app.py: endpoints split into pure payload builders + HTTP/caching shells; the
in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store).
- climate.py: revgeo persisted through the store; recent_stamp() and
load_cached_history() (no-network read) helpers.
- backend/migrate.py + make migrate: idempotent, resumable backfill of the store
from existing parquet caches (default calendar span + latest-day detail +
revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather.
- grid.from_id(): rebuild a cell from its cache filename (migrate tooling).
* Add /api/v2/cell: one bundle carrying every view's payload
GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months)
and day (today) payloads in one response, each the exact payload its per-view
endpoint returns — built by the same builders and cached under the same
derived-store keys/tokens — paired with the etag that endpoint would emit. The
frontend can warm all views with a single request, seed its per-view cache from
the slices, and later revalidate each view individually with If-None-Match. The
bundle's own etag combines the slices', so an unchanged bundle is an empty 304.
prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard
guarantee: it never spends weather-API quota. A cell with no cached archive
answers 204, and only the history-derived slices (calendar + latest-day detail)
are built. At most one Nominatim lookup for a never-labeled cell.
* Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch
The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota,
which multi-year calendar payloads regularly blew through) to IndexedDB, with an
in-memory map in front. Entries carry the server's ETag, so anything stale
revalidates conditionally — unchanged data costs an empty 304 and a re-stamp,
never a re-transfer. On network failure the stale copy is served over an error.
getJSON gains an optional onUpdate callback opting into stale-while-revalidate:
the three views (weekly, day, calendar) now render a cached copy immediately —
spinners are delayed 150ms so warm loads never flash-blank — and repaint only if
background revalidation finds changed data. New data shows up the moment it
exists instead of waiting out a TTL.
Cross-view prefetch collapses from one request per view to a single /api/v2/cell
bundle, whose slices (exact per-view payloads + their etags) are seeded under the
URLs each view actually requests; the bundle call itself is conditional via a
remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side
with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one
possible Nominatim lookup each), so tapping nearby lands on already-graded data.
Legacy tg:* storage entries are cleared once; cache entries untouched for two
weeks are pruned on page load.
2026-07-11 07:31:28 +00:00
|
|
|
|
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 = `<p class="spinner">Building the percentile breakdown…</p>`;
|
|
|
|
|
|
dayBody.innerHTML = "";
|
|
|
|
|
|
}, 150);
|
2026-07-11 00:29:47 +00:00
|
|
|
|
let data;
|
|
|
|
|
|
try {
|
Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21)
* Compress API responses and revalidate static assets instead of re-downloading
- Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x.
- Serve pages/assets with Cache-Control: no-cache instead of no-store, so
browsers revalidate via the ETag/Last-Modified that FileResponse and
StaticFiles already emit. Unchanged assets now cost an empty 304 rather
than a full transfer on every page navigation, while deploys still show
up immediately.
* Persist derived responses in SQLite so grading is computed once per cell, not per request
New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet
records — finished grade/calendar/day/forecast payloads and reverse-geocode
labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms)
becomes a ~5ms database read that survives restarts and is shared across views.
Raw parquet stays the source of truth; the store is a pure accelerator (every
reader falls back to recomputing on a miss, and deleting the db is a safe reset).
Freshness is token-driven, not clock-driven: each cached payload is validated by
a token encoding what it was computed from (payload schema version, the archive
record's end date, the recent-fetch stamp). The existing freshness drivers are
untouched — get_history still tops up the tail hourly and get_recent_forecast
still refetches hourly — and tokens are derived from what they return, so cached
payloads expire exactly when their inputs change. The same tokens double as weak
ETags: If-None-Match answers with an empty 304 without touching the payload.
- backend/store.py: derived-payload + revgeo tables, thread-local WAL conns,
every helper fail-soft.
- app.py: endpoints split into pure payload builders + HTTP/caching shells; the
in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store).
- climate.py: revgeo persisted through the store; recent_stamp() and
load_cached_history() (no-network read) helpers.
- backend/migrate.py + make migrate: idempotent, resumable backfill of the store
from existing parquet caches (default calendar span + latest-day detail +
revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather.
- grid.from_id(): rebuild a cell from its cache filename (migrate tooling).
* Add /api/v2/cell: one bundle carrying every view's payload
GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months)
and day (today) payloads in one response, each the exact payload its per-view
endpoint returns — built by the same builders and cached under the same
derived-store keys/tokens — paired with the etag that endpoint would emit. The
frontend can warm all views with a single request, seed its per-view cache from
the slices, and later revalidate each view individually with If-None-Match. The
bundle's own etag combines the slices', so an unchanged bundle is an empty 304.
prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard
guarantee: it never spends weather-API quota. A cell with no cached archive
answers 204, and only the history-derived slices (calendar + latest-day detail)
are built. At most one Nominatim lookup for a never-labeled cell.
* Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch
The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota,
which multi-year calendar payloads regularly blew through) to IndexedDB, with an
in-memory map in front. Entries carry the server's ETag, so anything stale
revalidates conditionally — unchanged data costs an empty 304 and a re-stamp,
never a re-transfer. On network failure the stale copy is served over an error.
getJSON gains an optional onUpdate callback opting into stale-while-revalidate:
the three views (weekly, day, calendar) now render a cached copy immediately —
spinners are delayed 150ms so warm loads never flash-blank — and repaint only if
background revalidation finds changed data. New data shows up the moment it
exists instead of waiting out a TTL.
Cross-view prefetch collapses from one request per view to a single /api/v2/cell
bundle, whose slices (exact per-view payloads + their etags) are seeded under the
URLs each view actually requests; the bundle call itself is conditional via a
remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side
with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one
possible Nominatim lookup each), so tapping nearby lands on already-graded data.
Legacy tg:* storage entries are cleared once; cache entries untouched for two
weeks are pruned on page load.
2026-07-11 07:31:28 +00:00
|
|
|
|
// 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); });
|
2026-07-11 00:29:47 +00:00
|
|
|
|
} catch (err) {
|
Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21)
* Compress API responses and revalidate static assets instead of re-downloading
- Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x.
- Serve pages/assets with Cache-Control: no-cache instead of no-store, so
browsers revalidate via the ETag/Last-Modified that FileResponse and
StaticFiles already emit. Unchanged assets now cost an empty 304 rather
than a full transfer on every page navigation, while deploys still show
up immediately.
* Persist derived responses in SQLite so grading is computed once per cell, not per request
New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet
records — finished grade/calendar/day/forecast payloads and reverse-geocode
labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms)
becomes a ~5ms database read that survives restarts and is shared across views.
Raw parquet stays the source of truth; the store is a pure accelerator (every
reader falls back to recomputing on a miss, and deleting the db is a safe reset).
Freshness is token-driven, not clock-driven: each cached payload is validated by
a token encoding what it was computed from (payload schema version, the archive
record's end date, the recent-fetch stamp). The existing freshness drivers are
untouched — get_history still tops up the tail hourly and get_recent_forecast
still refetches hourly — and tokens are derived from what they return, so cached
payloads expire exactly when their inputs change. The same tokens double as weak
ETags: If-None-Match answers with an empty 304 without touching the payload.
- backend/store.py: derived-payload + revgeo tables, thread-local WAL conns,
every helper fail-soft.
- app.py: endpoints split into pure payload builders + HTTP/caching shells; the
in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store).
- climate.py: revgeo persisted through the store; recent_stamp() and
load_cached_history() (no-network read) helpers.
- backend/migrate.py + make migrate: idempotent, resumable backfill of the store
from existing parquet caches (default calendar span + latest-day detail +
revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather.
- grid.from_id(): rebuild a cell from its cache filename (migrate tooling).
* Add /api/v2/cell: one bundle carrying every view's payload
GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months)
and day (today) payloads in one response, each the exact payload its per-view
endpoint returns — built by the same builders and cached under the same
derived-store keys/tokens — paired with the etag that endpoint would emit. The
frontend can warm all views with a single request, seed its per-view cache from
the slices, and later revalidate each view individually with If-None-Match. The
bundle's own etag combines the slices', so an unchanged bundle is an empty 304.
prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard
guarantee: it never spends weather-API quota. A cell with no cached archive
answers 204, and only the history-derived slices (calendar + latest-day detail)
are built. At most one Nominatim lookup for a never-labeled cell.
* Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch
The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota,
which multi-year calendar payloads regularly blew through) to IndexedDB, with an
in-memory map in front. Entries carry the server's ETag, so anything stale
revalidates conditionally — unchanged data costs an empty 304 and a re-stamp,
never a re-transfer. On network failure the stale copy is served over an error.
getJSON gains an optional onUpdate callback opting into stale-while-revalidate:
the three views (weekly, day, calendar) now render a cached copy immediately —
spinners are delayed 150ms so warm loads never flash-blank — and repaint only if
background revalidation finds changed data. New data shows up the moment it
exists instead of waiting out a TTL.
Cross-view prefetch collapses from one request per view to a single /api/v2/cell
bundle, whose slices (exact per-view payloads + their etags) are seeded under the
URLs each view actually requests; the bundle call itself is conditional via a
remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side
with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one
possible Nominatim lookup each), so tapping nearby lands on already-graded data.
Legacy tg:* storage entries are cleared once; cache entries untouched for two
weeks are pruned on page load.
2026-07-11 07:31:28 +00:00
|
|
|
|
clearTimeout(spin);
|
|
|
|
|
|
if (seq !== daySeq) return;
|
2026-07-11 00:29:47 +00:00
|
|
|
|
dayHead.innerHTML = `<p class="error">${err.message}</p>`;
|
Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21)
* Compress API responses and revalidate static assets instead of re-downloading
- Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x.
- Serve pages/assets with Cache-Control: no-cache instead of no-store, so
browsers revalidate via the ETag/Last-Modified that FileResponse and
StaticFiles already emit. Unchanged assets now cost an empty 304 rather
than a full transfer on every page navigation, while deploys still show
up immediately.
* Persist derived responses in SQLite so grading is computed once per cell, not per request
New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet
records — finished grade/calendar/day/forecast payloads and reverse-geocode
labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms)
becomes a ~5ms database read that survives restarts and is shared across views.
Raw parquet stays the source of truth; the store is a pure accelerator (every
reader falls back to recomputing on a miss, and deleting the db is a safe reset).
Freshness is token-driven, not clock-driven: each cached payload is validated by
a token encoding what it was computed from (payload schema version, the archive
record's end date, the recent-fetch stamp). The existing freshness drivers are
untouched — get_history still tops up the tail hourly and get_recent_forecast
still refetches hourly — and tokens are derived from what they return, so cached
payloads expire exactly when their inputs change. The same tokens double as weak
ETags: If-None-Match answers with an empty 304 without touching the payload.
- backend/store.py: derived-payload + revgeo tables, thread-local WAL conns,
every helper fail-soft.
- app.py: endpoints split into pure payload builders + HTTP/caching shells; the
in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store).
- climate.py: revgeo persisted through the store; recent_stamp() and
load_cached_history() (no-network read) helpers.
- backend/migrate.py + make migrate: idempotent, resumable backfill of the store
from existing parquet caches (default calendar span + latest-day detail +
revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather.
- grid.from_id(): rebuild a cell from its cache filename (migrate tooling).
* Add /api/v2/cell: one bundle carrying every view's payload
GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months)
and day (today) payloads in one response, each the exact payload its per-view
endpoint returns — built by the same builders and cached under the same
derived-store keys/tokens — paired with the etag that endpoint would emit. The
frontend can warm all views with a single request, seed its per-view cache from
the slices, and later revalidate each view individually with If-None-Match. The
bundle's own etag combines the slices', so an unchanged bundle is an empty 304.
prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard
guarantee: it never spends weather-API quota. A cell with no cached archive
answers 204, and only the history-derived slices (calendar + latest-day detail)
are built. At most one Nominatim lookup for a never-labeled cell.
* Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch
The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota,
which multi-year calendar payloads regularly blew through) to IndexedDB, with an
in-memory map in front. Entries carry the server's ETag, so anything stale
revalidates conditionally — unchanged data costs an empty 304 and a re-stamp,
never a re-transfer. On network failure the stale copy is served over an error.
getJSON gains an optional onUpdate callback opting into stale-while-revalidate:
the three views (weekly, day, calendar) now render a cached copy immediately —
spinners are delayed 150ms so warm loads never flash-blank — and repaint only if
background revalidation finds changed data. New data shows up the moment it
exists instead of waiting out a TTL.
Cross-view prefetch collapses from one request per view to a single /api/v2/cell
bundle, whose slices (exact per-view payloads + their etags) are seeded under the
URLs each view actually requests; the bundle call itself is conditional via a
remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side
with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one
possible Nominatim lookup each), so tapping nearby lands on already-graded data.
Legacy tg:* storage entries are cleared once; cache entries untouched for two
weeks are pruned on page load.
2026-07-11 07:31:28 +00:00
|
|
|
|
dayBody.innerHTML = "";
|
2026-07-11 00:29:47 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21)
* Compress API responses and revalidate static assets instead of re-downloading
- Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x.
- Serve pages/assets with Cache-Control: no-cache instead of no-store, so
browsers revalidate via the ETag/Last-Modified that FileResponse and
StaticFiles already emit. Unchanged assets now cost an empty 304 rather
than a full transfer on every page navigation, while deploys still show
up immediately.
* Persist derived responses in SQLite so grading is computed once per cell, not per request
New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet
records — finished grade/calendar/day/forecast payloads and reverse-geocode
labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms)
becomes a ~5ms database read that survives restarts and is shared across views.
Raw parquet stays the source of truth; the store is a pure accelerator (every
reader falls back to recomputing on a miss, and deleting the db is a safe reset).
Freshness is token-driven, not clock-driven: each cached payload is validated by
a token encoding what it was computed from (payload schema version, the archive
record's end date, the recent-fetch stamp). The existing freshness drivers are
untouched — get_history still tops up the tail hourly and get_recent_forecast
still refetches hourly — and tokens are derived from what they return, so cached
payloads expire exactly when their inputs change. The same tokens double as weak
ETags: If-None-Match answers with an empty 304 without touching the payload.
- backend/store.py: derived-payload + revgeo tables, thread-local WAL conns,
every helper fail-soft.
- app.py: endpoints split into pure payload builders + HTTP/caching shells; the
in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store).
- climate.py: revgeo persisted through the store; recent_stamp() and
load_cached_history() (no-network read) helpers.
- backend/migrate.py + make migrate: idempotent, resumable backfill of the store
from existing parquet caches (default calendar span + latest-day detail +
revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather.
- grid.from_id(): rebuild a cell from its cache filename (migrate tooling).
* Add /api/v2/cell: one bundle carrying every view's payload
GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months)
and day (today) payloads in one response, each the exact payload its per-view
endpoint returns — built by the same builders and cached under the same
derived-store keys/tokens — paired with the etag that endpoint would emit. The
frontend can warm all views with a single request, seed its per-view cache from
the slices, and later revalidate each view individually with If-None-Match. The
bundle's own etag combines the slices', so an unchanged bundle is an empty 304.
prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard
guarantee: it never spends weather-API quota. A cell with no cached archive
answers 204, and only the history-derived slices (calendar + latest-day detail)
are built. At most one Nominatim lookup for a never-labeled cell.
* Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch
The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota,
which multi-year calendar payloads regularly blew through) to IndexedDB, with an
in-memory map in front. Entries carry the server's ETag, so anything stale
revalidates conditionally — unchanged data costs an empty 304 and a re-stamp,
never a re-transfer. On network failure the stale copy is served over an error.
getJSON gains an optional onUpdate callback opting into stale-while-revalidate:
the three views (weekly, day, calendar) now render a cached copy immediately —
spinners are delayed 150ms so warm loads never flash-blank — and repaint only if
background revalidation finds changed data. New data shows up the moment it
exists instead of waiting out a TTL.
Cross-view prefetch collapses from one request per view to a single /api/v2/cell
bundle, whose slices (exact per-view payloads + their etags) are seeded under the
URLs each view actually requests; the bundle call itself is conditional via a
remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side
with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one
possible Nominatim lookup each), so tapping nearby lands on already-graded data.
Legacy tg:* storage entries are cleared once; cache entries untouched for two
weeks are pruned on page load.
2026-07-11 07:31:28 +00:00
|
|
|
|
clearTimeout(spin);
|
|
|
|
|
|
if (seq !== daySeq) return;
|
|
|
|
|
|
finishDay(data);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function finishDay(data) {
|
2026-07-11 00:29:47 +00:00
|
|
|
|
latest = data.latest;
|
|
|
|
|
|
curDate = data.detail.date;
|
|
|
|
|
|
dateInput.value = curDate;
|
|
|
|
|
|
// Allow navigating up to today (recent days beyond the archive come from the
|
|
|
|
|
|
// forecast bundle), not just the latest fully-archived day.
|
|
|
|
|
|
dateInput.max = todayISO();
|
|
|
|
|
|
document.getElementById("next-day").disabled = curDate >= todayISO();
|
|
|
|
|
|
window.Thermograph.saveLastLocation(selected.lat, selected.lon, curDate);
|
|
|
|
|
|
render(data);
|
|
|
|
|
|
window.Thermograph.prefetchViews(selected.lat, selected.lon, "day"); // warm weekly/calendar/forecast
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function render(data) {
|
|
|
|
|
|
const d = data.detail;
|
|
|
|
|
|
const cell = data.cell;
|
|
|
|
|
|
const place = data.place || `${cell.center_lat.toFixed(3)}, ${cell.center_lon.toFixed(3)}`;
|
|
|
|
|
|
locLabel.textContent = place;
|
|
|
|
|
|
locLabel.hidden = false;
|
|
|
|
|
|
findBtn.innerHTML = `${window.LocationPicker.PIN_ICON} <span>Change location</span>`;
|
|
|
|
|
|
const dt = new Date(d.date + "T00:00:00");
|
|
|
|
|
|
const nice = dt.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" });
|
|
|
|
|
|
const yrs = d.year_range ? `${d.year_range[0]}–${d.year_range[1]}` : "—";
|
|
|
|
|
|
// Weather summary from the observed high + precip (omitted for days with no obs yet).
|
|
|
|
|
|
const th = d.metrics.tmax.obs ? d.metrics.tmax.obs.value : null;
|
|
|
|
|
|
const pr = d.metrics.precip.obs ? d.metrics.precip.obs.value : null;
|
|
|
|
|
|
const wt = th != null || pr != null ? weatherType(th, pr) : null;
|
|
|
|
|
|
dayHead.innerHTML = `
|
|
|
|
|
|
<h2>${nice}</h2>
|
|
|
|
|
|
${wt ? `<p class="day-weather">${wt.icon} ${wt.text}</p>` : ""}
|
2026-07-11 07:10:46 +00:00
|
|
|
|
<p class="meta">${place}
|
2026-07-11 07:02:19 +00:00
|
|
|
|
· ${d.n_samples.toLocaleString()} days around this date (${yrs})</p>`;
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
dayBody.innerHTML = [
|
|
|
|
|
|
ladderCard("Daily high", d.metrics.tmax, fmtTemp, "temp"),
|
|
|
|
|
|
ladderCard("Daily low", d.metrics.tmin, fmtTemp, "temp"),
|
|
|
|
|
|
ladderCard("Feels like", d.metrics.feels, fmtTemp, "temp"),
|
|
|
|
|
|
ladderCard("Humidity", d.metrics.humid, fmtHumid, "temp"),
|
|
|
|
|
|
ladderCard("Wind speed", d.metrics.wind, fmtWind, "temp"),
|
|
|
|
|
|
ladderCard("Wind gust", d.metrics.gust, fmtWind, "temp"),
|
|
|
|
|
|
ladderCard("Precipitation", d.metrics.precip, fmtPrecip, "precip"),
|
|
|
|
|
|
].join("");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// One metric card: an observed summary line + the tier ladder (highest tier on
|
|
|
|
|
|
// top). The tier the observed value falls into is highlighted with its value.
|
|
|
|
|
|
function ladderCard(title, m, fmt, kind) {
|
|
|
|
|
|
if (!m || !m.ladder) return "";
|
|
|
|
|
|
const obs = m.obs;
|
|
|
|
|
|
const activeClass = obs ? obs.class : null;
|
|
|
|
|
|
|
|
|
|
|
|
let summary;
|
|
|
|
|
|
if (obs) {
|
|
|
|
|
|
const pct = obs.percentile == null ? "" : ` · ${ord(obs.percentile)} pct`;
|
|
|
|
|
|
summary = `<span class="day-obs" style="--cat:${tierColor(obs.class)}">${fmt(obs.value)}</span>
|
|
|
|
|
|
<span class="day-obs-meta">${obs.grade}${pct}</span>`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
summary = `<span class="day-obs-meta">No observation for this day yet</span>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Left: a per-metric summary. Right: the bold, right-aligned Historical Range.
|
|
|
|
|
|
const noteLeft = kind === "temp"
|
|
|
|
|
|
? `median ${fmt(m.ladder.median)}`
|
|
|
|
|
|
: `${m.ladder.rain_days.toLocaleString()} rain days · dry ${m.ladder.dry_pct}%`;
|
|
|
|
|
|
const note = `<span class="ladder-note-left">${noteLeft}</span>` +
|
|
|
|
|
|
`<span class="ladder-range">Historical Range ${fmt(m.ladder.min)}–${fmt(m.ladder.max)}</span>`;
|
|
|
|
|
|
|
|
|
|
|
|
const rows = m.ladder.tiers.map((t) => {
|
|
|
|
|
|
const active = t.c === activeClass ? " active" : "";
|
|
|
|
|
|
// Dry has no rain percentile — leave that column blank and show the threshold
|
|
|
|
|
|
// as the value. Other tiers show their percentile range and value range.
|
|
|
|
|
|
let val, pct = t.range;
|
|
|
|
|
|
if (t.c === "dry") { val = t.range; pct = ""; }
|
|
|
|
|
|
else if (t.hi == null) val = `> ${fmt(t.lo)}`; // top tier: strictly beyond p99
|
|
|
|
|
|
else if (t.lo == null) val = `< ${fmt(t.hi)}`; // bottom tier: strictly below p1
|
|
|
|
|
|
else val = `${fmt(t.lo)}–${fmt(t.hi)}`;
|
|
|
|
|
|
const marker = t.c === activeClass && obs
|
|
|
|
|
|
? `<span class="ladder-mark">◀ ${fmt(obs.value)}</span>` : "";
|
|
|
|
|
|
return `<div class="ladder-row${active}">
|
|
|
|
|
|
<span class="ladder-sw" style="background:${tierColor(t.c)}"></span>
|
|
|
|
|
|
<span class="ladder-label" style="--cat:${tierColor(t.c)}">${t.label}</span>
|
|
|
|
|
|
<span class="ladder-pct">${pct}</span>
|
|
|
|
|
|
<span class="ladder-val">${val}</span>
|
|
|
|
|
|
<span class="ladder-mk">${marker}</span>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}).join("");
|
|
|
|
|
|
|
|
|
|
|
|
return `<section class="ladder-card">
|
|
|
|
|
|
<div class="ladder-head">
|
|
|
|
|
|
<h3>${title}</h3>
|
|
|
|
|
|
<div class="ladder-summary">${summary}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="ladder-note">${note}</p>
|
|
|
|
|
|
<div class="ladder">${rows}</div>
|
|
|
|
|
|
</section>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- restore (hash from a shared link, else the last place you looked at) ----
|
|
|
|
|
|
(function restore() {
|
|
|
|
|
|
const loc = window.Thermograph.loadLastLocation();
|
|
|
|
|
|
if (loc) {
|
|
|
|
|
|
selected = { lat: loc.lat, lon: loc.lon };
|
|
|
|
|
|
curDate = loc.date || todayISO(); // default to today when no date is given
|
|
|
|
|
|
fetchDay();
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|