thermograph/frontend/static/day.js

218 lines
9.4 KiB
JavaScript
Raw Normal View History

// 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.
2026-07-11 20:28:33 +00:00
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
import { fmtTemp, onUnitChange } from "./units.js";
import { uv } from "./account.js"; // header sign-in entry + notification bell, and the API-version resolver
2026-07-11 20:28:33 +00:00
import { getJSON, TTL, prefetchViews } from "./cache.js";
import { initFindButton, setFindLabel } from "./mappicker.js";
Make trace-rain days read consistently across every surface Since the dry/rain grading split moved to precip > 0, a sub-0.01" reanalysis "trace" day is graded as a rain tier but its depth rounds to 0.00" / 0 mm, and several surfaces still treated it as dry — a day would read "0.0" · Light rain, N days since rain" at once. Align every consumer of a graded precip day with the > 0 split so a trace day reads as the (very light) rain it was graded to be. - Dry streak: dry_streaks and longest_dry_streak reset on any rain (> 0), not the 0.01" rain-frequency line, so a trace day breaks the streak and its "days since rain" no longer keeps climbing. (The rain_freq climatology stat keeps the 0.01" measurable-rain convention.) - Display: new fmtPrecipTier() prints "trace" for a rain-tier day whose depth rounds to zero, rather than a bone-dry "0.00". Used on the calendar tooltip, the day-page observation + ladder marker, and the recent/forecast table. - weatherType() decides wet/dry from the grade class when it has it (a rain tier means it rained even at trace depth), falling back to depth > 0; callers on the calendar and day page pass the class. - Recent-table and chart precip dots key their dry-vs-rain rendering on the grade class instead of value > 0, so a trace day tints as rain, not dry. Rain chart fan: the precipitation fan still used the pre-split colour map, painting the whole 90-99 rain-day-percentile region one shade and 75-90 a tier too dark. _band_stats emits a p95 mark (additive; unused by the temperature fan) and RAIN_FAN remaps to the eight tiers -- 95-99 Severe, 90-95 Very Heavy, 60-90 Heavy -- with a p95 fallback in pget so an older cached payload still renders.
2026-07-24 23:07:14 +00:00
import { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, pctOrd, fmtPrecip, fmtPrecipTier, fmtWind, fmtHumid,
2026-07-11 20:28:33 +00:00
todayISO, weatherType, placeLabel } from "./shared.js";
// 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] || "");
let selected = null; // {lat, lon}
let curDate = null; // "YYYY-MM-DD" currently shown
let latest = null; // newest date available in the record
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");
2026-07-11 20:28:33 +00:00
initFindButton(findBtn, "Find a location", () => selected,
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47) The page scripts each re-declared the shared presentation layer — the tier color table existed in four places (app.js, calendar.js, day.js, style.css) and the scale label tables in three (plus legend.html's own drifting copy), alongside per-page copies of the dryness ramp, formatters, ord, todayISO, esc, the weather icons/summary, month helpers and the 2-year range chunker. ~240 duplicated lines deleted (net -236 with the new module included). - frontend/shared.js (IIFE, extends window.Thermograph): tier colors read from style.css's :root custom properties at load — the CSS is now the single source of truth; the JS map exists only because inline-SVG work (chart + PNG export) needs literal values, with hex fallbacks for a missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord, todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker, and the weatherType summary (dsr-aware; the day page just omits dsr). - nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in the shared classic-script scope, and a leaked locHash collided with page destructuring (caught by the browser smoke, not by node --check). locHash is now exported and used by all 8 former hand-built hash sites. - mappicker.js: initFindButton/setFindLabel replace the Find-button block each page rebuilt. - legend.html renders its scales from the shared tables, so the guide can no longer drift from what the app shows. Verified: node --check on all JS; 108 backend tests; headless-Chromium smoke over all five pages against live data — no console/page errors, legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle, calendar grid + key + metric switch, day 7 ladders + weather icon, compare seeded rank card.
2026-07-11 20:21:48 +00:00
(lat, lon) => {
Picker map: smaller labels, darker roads; location label coords→place (#27) * Picker map: smaller city labels, darker/bolder roads (split tile layers) Split the CARTO Voyager basemap into two raster layers so label size and road weight are independent: a labels-free base upscaled via tileSize 512 + zoomOffset -1 (bold, prominent roads) plus a labels-only layer at natural size on top (small, crisp city names). The darken/saturate filter now targets the base layer only (.mp-base: brightness .92, contrast 1.12), deepening the roads while leaving label text at full contrast. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc * Location label: show coordinates immediately, then resolve to place name On selecting a location, write the raw coordinates to the location label right away, so the user sees the picked spot instantly instead of a stale/blank label. The existing post-fetch render already overwrites it with the reverse-geocoded "neighborhood, city, region" string (data.place), so the label now reads coords → place name live. - app.js / calendar.js / day.js: set coords in the selection handler before the fetch; the render/initOnce overwrite is unchanged. - compare.js: show coordinates through the pending + loading states (instead of "Locating…") so each chip's coords are replaced live by the place name once scored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 08:35:43 +00:00
selected = { lat, lon };
// Show the raw coordinates immediately; render() replaces them with the
// resolved neighborhood + city name once the day fetch returns.
locLabel.textContent = `${lat.toFixed(3)}, ${lon.toFixed(3)}`;
locLabel.hidden = false;
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
async function fetchDay() {
if (!selected) return;
const dateQ = curDate ? `&date=${curDate}` : "";
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47) The page scripts each re-declared the shared presentation layer — the tier color table existed in four places (app.js, calendar.js, day.js, style.css) and the scale label tables in three (plus legend.html's own drifting copy), alongside per-page copies of the dryness ramp, formatters, ord, todayISO, esc, the weather icons/summary, month helpers and the 2-year range chunker. ~240 duplicated lines deleted (net -236 with the new module included). - frontend/shared.js (IIFE, extends window.Thermograph): tier colors read from style.css's :root custom properties at load — the CSS is now the single source of truth; the JS map exists only because inline-SVG work (chart + PNG export) needs literal values, with hex fallbacks for a missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord, todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker, and the weatherType summary (dsr-aware; the day page just omits dsr). - nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in the shared classic-script scope, and a leaked locHash collided with page destructuring (caught by the browser smoke, not by node --check). locHash is now exported and used by all 8 former hand-built hash sites. - mappicker.js: initFindButton/setFindLabel replace the Find-button block each page rebuilt. - legend.html renders its scales from the shared tables, so the guide can no longer drift from what the app shows. Verified: node --check on all JS; 108 backend tests; headless-Chromium smoke over all five pages against live data — no console/page errors, legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle, calendar grid + key + metric switch, day 7 ladders + weather icon, compare seeded rank card.
2026-07-11 20:21:48 +00:00
history.replaceState(null, "", locHash(selected.lat, selected.lon, 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);
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.
2026-07-11 20:28:33 +00:00
data = await getJSON(
uv(`day?lat=${selected.lat}&lon=${selected.lon}${dateQ}`), TTL.day,
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
false, (upd) => { if (seq === daySeq) finishDay(upd); });
} 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;
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 = "";
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) {
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();
2026-07-11 20:28:33 +00:00
saveLastLocation(selected.lat, selected.lon, curDate);
render(data);
2026-07-11 20:28:33 +00:00
prefetchViews(selected.lat, selected.lon, ["day"]); // warm weekly/calendar/forecast
}
let lastData = null; // most recent /day response, for repainting on a unit switch
2026-07-11 20:28:33 +00:00
onUnitChange(() => { if (lastData) render(lastData); });
function render(data) {
lastData = data;
const d = data.detail;
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47) The page scripts each re-declared the shared presentation layer — the tier color table existed in four places (app.js, calendar.js, day.js, style.css) and the scale label tables in three (plus legend.html's own drifting copy), alongside per-page copies of the dryness ramp, formatters, ord, todayISO, esc, the weather icons/summary, month helpers and the 2-year range chunker. ~240 duplicated lines deleted (net -236 with the new module included). - frontend/shared.js (IIFE, extends window.Thermograph): tier colors read from style.css's :root custom properties at load — the CSS is now the single source of truth; the JS map exists only because inline-SVG work (chart + PNG export) needs literal values, with hex fallbacks for a missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord, todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker, and the weatherType summary (dsr-aware; the day page just omits dsr). - nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in the shared classic-script scope, and a leaked locHash collided with page destructuring (caught by the browser smoke, not by node --check). locHash is now exported and used by all 8 former hand-built hash sites. - mappicker.js: initFindButton/setFindLabel replace the Find-button block each page rebuilt. - legend.html renders its scales from the shared tables, so the guide can no longer drift from what the app shows. Verified: node --check on all JS; 108 backend tests; headless-Chromium smoke over all five pages against live data — no console/page errors, legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle, calendar grid + key + metric switch, day 7 ladders + weather icon, compare seeded rank card.
2026-07-11 20:21:48 +00:00
const place = placeLabel(data);
locLabel.textContent = place;
locLabel.hidden = false;
2026-07-11 20:28:33 +00:00
setFindLabel(findBtn, "Change location");
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;
Make trace-rain days read consistently across every surface Since the dry/rain grading split moved to precip > 0, a sub-0.01" reanalysis "trace" day is graded as a rain tier but its depth rounds to 0.00" / 0 mm, and several surfaces still treated it as dry — a day would read "0.0" · Light rain, N days since rain" at once. Align every consumer of a graded precip day with the > 0 split so a trace day reads as the (very light) rain it was graded to be. - Dry streak: dry_streaks and longest_dry_streak reset on any rain (> 0), not the 0.01" rain-frequency line, so a trace day breaks the streak and its "days since rain" no longer keeps climbing. (The rain_freq climatology stat keeps the 0.01" measurable-rain convention.) - Display: new fmtPrecipTier() prints "trace" for a rain-tier day whose depth rounds to zero, rather than a bone-dry "0.00". Used on the calendar tooltip, the day-page observation + ladder marker, and the recent/forecast table. - weatherType() decides wet/dry from the grade class when it has it (a rain tier means it rained even at trace depth), falling back to depth > 0; callers on the calendar and day page pass the class. - Recent-table and chart precip dots key their dry-vs-rain rendering on the grade class instead of value > 0, so a trace day tints as rain, not dry. Rain chart fan: the precipitation fan still used the pre-split colour map, painting the whole 90-99 rain-day-percentile region one shade and 75-90 a tier too dark. _band_stats emits a p95 mark (additive; unused by the temperature fan) and RAIN_FAN remaps to the eight tiers -- 95-99 Severe, 90-95 Very Heavy, 60-90 Heavy -- with a p95 fallback in pget so an older cached payload still renders.
2026-07-24 23:07:14 +00:00
const prCls = d.metrics.precip.obs ? d.metrics.precip.obs.class : null;
const wt = th != null || pr != null ? weatherType(th, pr, undefined, prCls) : null;
dayHead.innerHTML = `
<h2>${nice}</h2>
${wt ? `<p class="day-weather">${wt.icon} ${wt.text}</p>` : ""}
<p class="meta">${place}
· ${d.n_samples.toLocaleString()} days around this date (${yrs})</p>`;
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("");
}
// Collapse a space-separated unit shared by both bounds ("17.3 g/m³19.9 g/m³"
// → "17.319.9 g/m³") so ranges fit the narrow value column on phones. Attached
// units (76°, 0.25") pass through untouched.
function fmtRange(fmt, lo, hi) {
const a = fmt(lo), b = fmt(hi);
const i = a.lastIndexOf(" ");
if (i > 0 && b.endsWith(a.slice(i))) return `${a.slice(0, i)}${b}`;
return `${a}${b}`;
}
// The unit-less value for the ◀ marker — the unit is already on the range beside
// it and in the card's summary, and dropping it keeps the marker on one line.
const bareVal = (fmt, v) => {
const s = fmt(v), i = s.lastIndexOf(" ");
return i > 0 ? s.slice(0, i) : s;
};
// 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;
Make trace-rain days read consistently across every surface Since the dry/rain grading split moved to precip > 0, a sub-0.01" reanalysis "trace" day is graded as a rain tier but its depth rounds to 0.00" / 0 mm, and several surfaces still treated it as dry — a day would read "0.0" · Light rain, N days since rain" at once. Align every consumer of a graded precip day with the > 0 split so a trace day reads as the (very light) rain it was graded to be. - Dry streak: dry_streaks and longest_dry_streak reset on any rain (> 0), not the 0.01" rain-frequency line, so a trace day breaks the streak and its "days since rain" no longer keeps climbing. (The rain_freq climatology stat keeps the 0.01" measurable-rain convention.) - Display: new fmtPrecipTier() prints "trace" for a rain-tier day whose depth rounds to zero, rather than a bone-dry "0.00". Used on the calendar tooltip, the day-page observation + ladder marker, and the recent/forecast table. - weatherType() decides wet/dry from the grade class when it has it (a rain tier means it rained even at trace depth), falling back to depth > 0; callers on the calendar and day page pass the class. - Recent-table and chart precip dots key their dry-vs-rain rendering on the grade class instead of value > 0, so a trace day tints as rain, not dry. Rain chart fan: the precipitation fan still used the pre-split colour map, painting the whole 90-99 rain-day-percentile region one shade and 75-90 a tier too dark. _band_stats emits a p95 mark (additive; unused by the temperature fan) and RAIN_FAN remaps to the eight tiers -- 95-99 Severe, 90-95 Very Heavy, 60-90 Heavy -- with a p95 fallback in pget so an older cached payload still renders.
2026-07-24 23:07:14 +00:00
// The observed precip depth reads "trace" for a sub-0.01" rain day (rounds to 0
// but is graded a rain tier); ladder tier ranges keep the plain numeric formatter.
const obsFmt = kind === "precip" ? ((v) => fmtPrecipTier(v, activeClass)) : fmt;
let summary;
if (obs) {
const pct = obs.percentile == null ? "" : ` · ${pctOrd(obs.percentile)} pct`;
Make trace-rain days read consistently across every surface Since the dry/rain grading split moved to precip > 0, a sub-0.01" reanalysis "trace" day is graded as a rain tier but its depth rounds to 0.00" / 0 mm, and several surfaces still treated it as dry — a day would read "0.0" · Light rain, N days since rain" at once. Align every consumer of a graded precip day with the > 0 split so a trace day reads as the (very light) rain it was graded to be. - Dry streak: dry_streaks and longest_dry_streak reset on any rain (> 0), not the 0.01" rain-frequency line, so a trace day breaks the streak and its "days since rain" no longer keeps climbing. (The rain_freq climatology stat keeps the 0.01" measurable-rain convention.) - Display: new fmtPrecipTier() prints "trace" for a rain-tier day whose depth rounds to zero, rather than a bone-dry "0.00". Used on the calendar tooltip, the day-page observation + ladder marker, and the recent/forecast table. - weatherType() decides wet/dry from the grade class when it has it (a rain tier means it rained even at trace depth), falling back to depth > 0; callers on the calendar and day page pass the class. - Recent-table and chart precip dots key their dry-vs-rain rendering on the grade class instead of value > 0, so a trace day tints as rain, not dry. Rain chart fan: the precipitation fan still used the pre-split colour map, painting the whole 90-99 rain-day-percentile region one shade and 75-90 a tier too dark. _band_stats emits a p95 mark (additive; unused by the temperature fan) and RAIN_FAN remaps to the eight tiers -- 95-99 Severe, 90-95 Very Heavy, 60-90 Heavy -- with a p95 fallback in pget so an older cached payload still renders.
2026-07-24 23:07:14 +00:00
summary = `<span class="day-obs" style="--cat:${tierColor(obs.class)}">${obsFmt(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 ${fmtRange(fmt, m.ladder.min, 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 = fmtRange(fmt, t.lo, t.hi);
const marker = t.c === activeClass && obs
Make trace-rain days read consistently across every surface Since the dry/rain grading split moved to precip > 0, a sub-0.01" reanalysis "trace" day is graded as a rain tier but its depth rounds to 0.00" / 0 mm, and several surfaces still treated it as dry — a day would read "0.0" · Light rain, N days since rain" at once. Align every consumer of a graded precip day with the > 0 split so a trace day reads as the (very light) rain it was graded to be. - Dry streak: dry_streaks and longest_dry_streak reset on any rain (> 0), not the 0.01" rain-frequency line, so a trace day breaks the streak and its "days since rain" no longer keeps climbing. (The rain_freq climatology stat keeps the 0.01" measurable-rain convention.) - Display: new fmtPrecipTier() prints "trace" for a rain-tier day whose depth rounds to zero, rather than a bone-dry "0.00". Used on the calendar tooltip, the day-page observation + ladder marker, and the recent/forecast table. - weatherType() decides wet/dry from the grade class when it has it (a rain tier means it rained even at trace depth), falling back to depth > 0; callers on the calendar and day page pass the class. - Recent-table and chart precip dots key their dry-vs-rain rendering on the grade class instead of value > 0, so a trace day tints as rain, not dry. Rain chart fan: the precipitation fan still used the pre-split colour map, painting the whole 90-99 rain-day-percentile region one shade and 75-90 a tier too dark. _band_stats emits a p95 mark (additive; unused by the temperature fan) and RAIN_FAN remaps to the eight tiers -- 95-99 Severe, 90-95 Very Heavy, 60-90 Heavy -- with a p95 fallback in pget so an older cached payload still renders.
2026-07-24 23:07:14 +00:00
? `<span class="ladder-mark">◀ ${bareVal(obsFmt, 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() {
2026-07-11 20:28:33 +00:00
const loc = loadLastLocation();
if (loc) {
selected = { lat: loc.lat, lon: loc.lon };
curDate = loc.date || todayISO(); // default to today when no date is given
fetchDay();
}
})();