2026-07-11 00:29:47 +00:00
// Thermograph calendar subpage — two years of daily grades as a month-grid heatmap.
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
// Talks to the v2 API (/api/v2/calendar, /api/v2/geocode).
import { loadLastLocation , saveLastLocation , locHash } from "./nav.js" ;
// The tooltip reads fmtTemp live, so a unit switch shows on the next hover.
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
import { fmtTemp , onUnitChange } from "./units.js" ;
2026-07-22 18:50:00 +00:00
import { uv } from "./account.js" ; // header sign-in entry + notification bell, and the API-version resolver
Extract the chart library and the chunked streaming fetch (#49)
app.js carried the whole SVG chart implementation (~380 lines: palette,
percentile fans, the shared line-series renderer, per-metric wrappers,
pointer hover) alongside its page logic. That moves to chart.js: the
drawing dimensions live there as live-bound module state behind
setChartWidth(); attachChartHover takes the day list as a parameter
instead of reaching into page globals. app.js keeps buildChart and the
PNG export — page orchestration — and drops to ~500 lines. Any page can
now plot a metric series (e.g. a history sparkline on the day page).
calendar.js's hand-rolled streaming loader — bounded-parallel pool,
token cancellation, stale-while-revalidate re-merge — becomes
cache.js chunkedFetch(urls, {ttl, concurrency, isCurrent, onResult});
fetchCalendar keeps only its intent (chunk URLs, day merge, contiguous-
prefix rendering). Same pool semantics, verbatim.
Verified: node --check; 108 backend tests; headless-Chromium smoke on
all five pages against live data (chart renders + metric toggle,
calendar grid streams + metric switch, ladders, compare, legend).
2026-07-11 20:33:06 +00:00
import { TTL , chunkedFetch , prefetchViews } from "./cache.js" ;
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
import { initFindButton , setFindLabel } from "./mappicker.js" ;
Render every percentile through one rule (#186)
The homepage strip floored percentiles into 1-99 while the Day page rendered
"100th pct" for the same reading. Rather than teach the Day page a second copy
of the rule, put it in grading.pct_ordinal() and have every surface defer to it:
the Day page, the calendar tooltip, the chart labels, the city pages and the
strip.
Two bugs fell out of unifying them:
- The city pages rendered `{{ c.percentile }}th pct` against a float, so all
~1000 of them showed "97.1th pct", "66.4th pct", "16.5th pct" — and would say
"1th"/"2th"/"3th" for the low tail. Live in prod.
- Python's round() is half-to-even and JavaScript's Math.round is half-up, so
16.5 gave "16th" server-side and "17th" client-side. The Python helper uses
floor(x + 0.5) to match the frontend exactly; a cross-language check over every
.5 boundary now agrees on all of them.
The frontend mirrors the rule as pctOrd() in shared.js, replacing the bare ord()
at all five percentile call sites (day.js, calendar.js, chart.js x2, app.js).
ord() stays as the general ordinal helper it always was.
2026-07-18 09:08:28 +00:00
import { TIER _COLORS , PRECIP _COLORS , SCALE _TEMP , SCALE _RAIN , drynessColor , pctOrd ,
2026-07-24 23:20:16 +00:00
placeLabel , fmtPrecip , fmtPrecipTier , fmtWind , fmtHumid , MONTHS , isoOfDate , monthStart , monthEnd ,
2026-07-11 22:33:38 +00:00
CHUNK _MONTHS , buildChunks , clickOpensPicker , metricBuckets , distStrip ,
2026-07-12 05:33:09 +00:00
seasonFilterDropdown , seasonSummaryText , syncSeasonChecks ,
applySeasonMonthChange , initSeasonExpand , allMonths , monthsToMask , maskToMonths ,
2026-07-22 19:08:57 +00:00
tierKeySegs , GUIDE _LINK , weatherType as wxType , DRY _CAP } from "./shared.js" ;
2026-07-12 05:33:09 +00:00
import { initFilterSheet } from "./filtersheet.js" ;
2026-07-11 00:29:47 +00:00
// The diverging-temperature-scale metrics (colored exactly like High/Low), with a
// display name for the key + tooltip and the formatter for their values.
const TEMP _METRIC _INFO = {
tmax : { name : "High" , label : "daily high" , fmt : fmtTemp } ,
tmin : { name : "Low" , label : "daily low" , fmt : fmtTemp } ,
2026-07-11 02:58:56 +00:00
feels : { name : "Feels" , label : "feels like (apparent temperature)" , fmt : fmtTemp } ,
Show a metric's unit once atop its distribution strip (#195)
The distribution strip printed each bar's average with its full unit inline, so
humidity read "4.0 g/m³" in a ~38px phone column and clipped on every bar —
temperature never did, because it shows a bare "40°". Wind ("5 mph") and precip
were heading the same way.
The unit now appears once, top-right of the strip, and every bar value is bare:
"4.0" under a "g/m³" label, "5" under "mph", "0.00" under "in". It reacts to the
unit toggle like the values do (°F↔°C, mm↔in, mph↔km/h). Temperature keeps its
degree glyph on the value since it is iconic and never clipped.
With the unit no longer on the humidity label, its calendar title drops the
"(g/m³ of water vapor)" parenthetical that would otherwise repeat it — every
metric's title is unitless now, the strip's own label carries it.
Also fixes a pre-existing crash: calendar.js referenced DRY_CAP without importing
it from shared.js, so selecting the dry-streak metric threw and left its legend
unrendered. Exported it.
Verified at 390 and 1440 in light and dark, imperial and metric, across all five
calendar metrics and the compare strips: no label clips, no console errors.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 22:30:31 +00:00
// The unit lives in the strip's own top-right label now, so the metric name
// doesn't repeat it (every other metric's title is unitless too).
humid : { name : "Humid" , label : "absolute humidity" , fmt : fmtHumid } ,
2026-07-11 00:29:47 +00:00
wind : { name : "Wind" , label : "wind speed" , fmt : fmtWind } ,
gust : { name : "Gust" , label : "wind gust" , fmt : fmtWind } ,
} ;
const IC _POINTER = ` <svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/></svg> ` ;
// The filterable day-type vocabulary (the weather filter's two checkbox groups).
// Keys match what weatherType() reports for each day.
const FEEL _WORDS = [
[ "scorching" , "Scorching" ] , [ "hot" , "Hot" ] , [ "warm" , "Warm" ] , [ "mild" , "Mild" ] ,
[ "cool" , "Cool" ] , [ "cold" , "Cold" ] , [ "frigid" , "Frigid" ] ,
] ;
const SKY _WORDS = [
[ "clear" , "Clear" ] , [ "dry" , "Dry spell" ] , [ "drizzle" , "Light rain" ] ,
[ "rain" , "Rain" ] , [ "downpour" , "Downpour" ] , [ "snow" , "Snow" ] ,
] ;
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
// The shared weather summary, adapted to the calendar's compact day records
// (dsr included, so a long-dry no-rain day reads "dry" rather than "clear").
const weatherType = ( rec ) =>
2026-07-24 23:20:16 +00:00
wxType ( rec . tmax && rec . tmax . v , rec . precip && rec . precip . v , rec . dsr , rec . precip && rec . precip . c ) ;
2026-07-11 00:29:47 +00:00
let selected = null ; // {lat, lon}
let data = null ; // last /api/v2/calendar response
// Coloring metric. Persisted so a refresh keeps your selection.
let metric = localStorage . getItem ( "thermograph:calMetric" ) || "tmax" ; // tmax|tmin|feels|wind|gust|precip|dsr
2026-07-12 05:33:09 +00:00
// Time-of-year filter: only months that are checked (AND whose year is checked) are
// shown. Seasons are the primary, color-coded selector and months a nested suboption,
// but the single source of truth is this Set of month indices (0=Jan … 11=Dec); a
// season rolls up its three months. Southern-hemisphere labels flip (handled by the
// shared helpers via `southern`). Persisted so a refresh keeps your selection.
function loadCalMonths ( ) {
try { const m = maskToMonths ( localStorage . getItem ( "thermograph:calMonths" ) ) ; if ( m && m . size ) return m ; } catch ( e ) { }
return allMonths ( ) ;
}
let checkedMonths = loadCalMonths ( ) ;
const southern = ( ) => ! ! ( selected && selected . lat < 0 ) ;
const persistCalMonths = ( ) => { try { localStorage . setItem ( "thermograph:calMonths" , monthsToMask ( checkedMonths ) ) ; } catch ( e ) { } } ;
2026-07-11 00:29:47 +00:00
let checkedYears = null ; // reset to all available on each fetch
// Weather filter (day feel × sky). Unlike seasons/years it filters DAYS, not
// months: a day must match a checked feel AND a checked sky, or it's dimmed on
// the grid (and left out of the category totals) — a "find days like this" tool.
let checkedFeels = new Set ( FEEL _WORDS . map ( ( w ) => w [ 0 ] ) ) ;
let checkedSkies = new Set ( SKY _WORDS . map ( ( w ) => w [ 0 ] ) ) ;
const weatherFilterActive = ( ) =>
checkedFeels . size < FEEL _WORDS . length || checkedSkies . size < SKY _WORDS . length ;
function weatherPass ( rec ) {
const wt = weatherType ( rec ) ;
return ( ! wt . feel || checkedFeels . has ( wt . feel ) ) && checkedSkies . has ( wt . sky ) ;
}
2026-07-12 06:32:10 +00:00
// Custom date range (ISO strings). Defaults to January six years back → today; the
// frontend splits any span into ~2-year server requests (see fetchCalendar), so a
// 6-year default loads as a few chunks rather than the old server-capped last-24.
const defaultCalStart = ( ) => isoOfDate ( new Date ( new Date ( ) . getFullYear ( ) - 6 , 0 , 1 ) ) ;
const defaultCalEnd = ( ) => isoOfDate ( new Date ( ) ) ;
let rangeStart = defaultCalStart ( ) , rangeEnd = defaultCalEnd ( ) ;
2026-07-11 00:29:47 +00:00
// The full requested span (drives the filters + pickers); data.range tracks only
// the portion graded so far, which grows as chunks arrive.
let reqStart = null , reqEnd = null ;
// Bumped on every new fetch so an in-flight chunked load abandons itself when a
// newer selection/range supersedes it.
let fetchToken = 0 ;
// The last range the user explicitly picked, persisted so a page refresh (or a
// return visit) keeps the same dates instead of snapping back to the last 2 years.
const CAL _RANGE _KEY = "thermograph:calRange" ;
function saveCalRange ( s , e ) {
try {
if ( s && e ) localStorage . setItem ( CAL _RANGE _KEY , JSON . stringify ( { start : s , end : e } ) ) ;
else localStorage . removeItem ( CAL _RANGE _KEY ) ;
} catch ( err ) { }
}
function loadCalRange ( ) {
try {
const o = JSON . parse ( localStorage . getItem ( CAL _RANGE _KEY ) ) ;
if ( o && o . start && o . end ) return o ;
} catch ( err ) { }
return null ;
}
// The shared "selected day" (thermograph:loc.date) that the calendar last aligned
// its To-month to. Lets restore tell a plain refresh (day unchanged since we set
// it) apart from a real change made on the Weekly/Day view — needed because a
// future To-month clamps the shared day to today, so day-month ≠ To-month.
const CAL _SYNC _KEY = "thermograph:calDateSync" ;
function getCalSync ( ) { try { return localStorage . getItem ( CAL _SYNC _KEY ) ; } catch ( e ) { return null ; } }
function setCalSync ( d ) { try { d ? localStorage . setItem ( CAL _SYNC _KEY , d ) : localStorage . removeItem ( CAL _SYNC _KEY ) ; } catch ( e ) { } }
// Day-open interaction state (see attachHover). Module-scoped so the "tap outside
// to dismiss" listener can be registered once, not re-added on every re-render.
let armedCell = null , lastPointer = "mouse" ;
const placeholder = document . getElementById ( "cal-placeholder" ) ;
const calHead = document . getElementById ( "cal-head" ) ;
const calEl = document . getElementById ( "calendar" ) ;
const keyEl = document . getElementById ( "cal-key" ) ;
// A click-activated (touch) tooltip stays up until something that isn't a day is
// tapped: tapping another day moves it (that cell's own handler re-shows it),
// while tapping empty space, a header, the key, or anywhere off the grid dismisses
// it and cancels a pending "second tap to open". Registered once (not per re-render).
document . addEventListener ( "pointerdown" , ( e ) => {
if ( ! e . target . closest ( ".cal-cell.filled" ) ) {
document . getElementById ( "cal-tip" ) . hidden = true ;
armedCell = null ;
}
} ) ;
2026-07-11 19:26:42 +00:00
// Mouse only: hovering off the grid hides the preview. On touch the tooltip is
// click-activated and must persist until a non-day tap (the handler above).
// Registered once — #calendar persists across re-renders (only its cells are
// replaced), so registering inside attachHover would stack a copy per render.
calEl . addEventListener ( "pointerleave" , ( e ) => {
if ( e . pointerType === "mouse" ) document . getElementById ( "cal-tip" ) . hidden = true ;
} ) ;
2026-07-11 00:29:47 +00:00
// ---- 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" ) ;
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
initFindButton ( findBtn , "Find a location" ,
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
( ) => selected , ( lat , lon ) => selectLocation ( lat , lon ) ) ;
2026-07-11 00:29:47 +00:00
// ---- metric toggle ----
2026-07-12 07:45:12 +00:00
// An inline selector (above the grid) plus a synced copy in the filter sheet; the sheet
// copy mirrors the inline 8 buttons (cloned). Either drives the shared `metric`.
document . getElementById ( "metric-toggle-sheet" ) . innerHTML =
document . getElementById ( "metric-toggle" ) . innerHTML ;
const syncColorButtons = ( ) =>
document . querySelectorAll ( "#metric-toggle button, #metric-toggle-sheet button" )
. forEach ( ( b ) => b . classList . toggle ( "active" , b . dataset . metric === metric ) ) ;
function setColorMetric ( m ) {
metric = m ;
2026-07-11 00:29:47 +00:00
try { localStorage . setItem ( "thermograph:calMetric" , metric ) ; } catch ( err ) { }
2026-07-12 07:45:12 +00:00
syncColorButtons ( ) ;
2026-07-11 00:29:47 +00:00
renderCalendar ( ) ;
renderKey ( ) ;
2026-07-12 07:45:12 +00:00
}
syncColorButtons ( ) ; // reflect the restored metric on both (the HTML defaults to High)
for ( const id of [ "metric-toggle" , "metric-toggle-sheet" ] ) {
document . getElementById ( id ) . addEventListener ( "click" , ( e ) => {
const btn = e . target . closest ( "button[data-metric]" ) ;
if ( btn ) setColorMetric ( btn . dataset . metric ) ;
} ) ;
}
2026-07-11 00:29:47 +00:00
2026-07-11 01:58:40 +00:00
// ---- category totals: share vs. count toggle ----
// The strip normally prints each category's share; ticking "Show count" swaps every
// figure for the raw day count. State is persisted and applied by renderTotals; the
// checkbox lives inside the (re-rendered) totals block, so the listener is delegated
// on the stable #cal-totals container and re-renders from the last shown days.
let showCount = false ;
try { showCount = localStorage . getItem ( "thermograph:calShowCount" ) === "1" ; } catch ( e ) { }
let lastVisible = null ;
document . getElementById ( "cal-totals" ) . addEventListener ( "change" , ( e ) => {
if ( ! e . target . closest ( "#ct-count-cb" ) ) return ;
showCount = e . target . checked ;
try { localStorage . setItem ( "thermograph:calShowCount" , showCount ? "1" : "0" ) ; } catch ( err ) { }
if ( lastVisible ) renderTotals ( lastVisible ) ;
} ) ;
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
// The totals chart now prints each category's average + range, so it follows the
// °C/°F toggle (the grid itself stays tier-colored and reads temps live in tooltips).
onUnitChange ( ( ) => { if ( lastVisible ) renderTotals ( lastVisible ) ; } ) ;
2026-07-11 01:58:40 +00:00
2026-07-11 00:29:47 +00:00
// ---- season / year filters ----
const filtersEl = document . getElementById ( "cal-filters" ) ;
const seasonYearEl = document . getElementById ( "cal-seasonyear" ) ;
function calYears ( ) {
// Years span the full requested range, not just the portion graded so far, so
// the Years dropdown is complete from the first chunk on.
const sy = new Date ( ( reqStart || data . range . start ) + "T00:00:00" ) . getFullYear ( ) ;
const ey = new Date ( ( reqEnd || data . range . end ) + "T00:00:00" ) . getFullYear ( ) ;
const out = [ ] ; for ( let y = sy ; y <= ey ; y ++ ) out . push ( y ) ;
return out ;
}
let filterYears = [ ] ; // the year set behind the Years dropdown (for its summary)
// A collapsed summary of a multi-select: "All" when everything's on, "None" when
2026-07-12 05:33:09 +00:00
// nothing is, otherwise the picked labels joined. (Seasons/months use the shared
// seasonSummaryText.)
2026-07-11 00:29:47 +00:00
function yearSummaryText ( ) {
const n = checkedYears ? checkedYears . size : 0 ;
if ( ! filterYears . length || n === filterYears . length ) return "All years" ;
if ( n === 0 ) return "None" ;
return filterYears . filter ( ( y ) => checkedYears . has ( y ) ) . join ( ", " ) ;
}
function weatherSummaryText ( ) {
const allF = checkedFeels . size === FEEL _WORDS . length ;
const allS = checkedSkies . size === SKY _WORDS . length ;
if ( allF && allS ) return "All days" ;
if ( checkedFeels . size === 0 || checkedSkies . size === 0 ) return "None" ;
const f = allF ? "Any feel" : FEEL _WORDS . filter ( ( w ) => checkedFeels . has ( w [ 0 ] ) ) . map ( ( w ) => w [ 1 ] ) . join ( ", " ) ;
const s = allS ? "any sky" : SKY _WORDS . filter ( ( w ) => checkedSkies . has ( w [ 0 ] ) ) . map ( ( w ) => w [ 1 ] ) . join ( ", " ) ;
return ` ${ f } · ${ s } ` ;
}
function updateFilterSummaries ( ) {
const s = seasonYearEl . querySelector ( '[data-dd="season"] .cal-dd-sum' ) ;
const y = seasonYearEl . querySelector ( '[data-dd="year"] .cal-dd-sum' ) ;
const w = seasonYearEl . querySelector ( '[data-dd="weather"] .cal-dd-sum' ) ;
2026-07-12 05:33:09 +00:00
if ( s ) s . textContent = seasonSummaryText ( checkedMonths , { southern : southern ( ) } ) ;
2026-07-11 00:29:47 +00:00
if ( y ) y . textContent = yearSummaryText ( ) ;
if ( w ) w . textContent = weatherSummaryText ( ) ;
}
// One filter as a compact dropdown: a summary button that expands to the checkbox
// list. Uses <details> so it needs no extra open/close wiring.
const filterDropdown = ( dd , label , attr , items , summary ) => `
< details class = "cal-dd" data - dd = "${dd}" >
< summary >
< span class = "cal-filter-label" > $ { label } < / s p a n >
< span class = "cal-dd-sum" > $ { summary } < / s p a n >
< span class = "cal-dd-caret" aria - hidden = "true" > ▾ < / s p a n >
< / s u m m a r y >
< div class = "cal-dd-panel" >
$ { items . map ( ( [ val , lab , on ] ) =>
` <label class="cal-dd-opt"><input type="checkbox" data- ${ attr } =" ${ val } " ${ on ? " checked" : "" } > ${ lab } </label> ` ) . join ( "" ) }
< / d i v >
< / d e t a i l s > ` ;
// The weather filter: one dropdown, two checkbox groups (day feel + sky), so a
// pick like "Mild or Warm · Clear" finds exactly the days you'd love. Sits after
// (below) the season/year dropdowns and spans the full filter row.
const weatherDropdown = ( ) => `
< details class = "cal-dd cal-weather" data - dd = "weather" >
< summary >
< span class = "cal-filter-label" > Weather < / s p a n >
< span class = "cal-dd-sum" > $ { weatherSummaryText ( ) } < / s p a n >
< span class = "cal-dd-caret" aria - hidden = "true" > ▾ < / s p a n >
< / s u m m a r y >
< div class = "cal-dd-panel" >
Remove em-dashes from site copy and tighten the prose (#206)
Replace em-dashes in user-facing copy across the server-rendered pages,
static frontend views, and the strings the app injects at runtime, using
colons, commas, parentheses or full stops as the context wants. Data
placeholder glyphs (a lone "—" for a missing reading) are left alone,
since a hyphen there reads as a minus sign in temperature columns.
Also tighten the high-visibility surfaces (home hero and meta, about,
privacy, city and records ledes, glossary blurbs) toward a plainer,
more direct voice while keeping every factual claim intact.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 01:48:33 +00:00
< div class = "cal-dd-group" > Day feel , from the daily high < / d i v >
2026-07-11 00:29:47 +00:00
$ { FEEL _WORDS . map ( ( [ val , lab ] ) =>
` <label class="cal-dd-opt"><input type="checkbox" data-wfeel=" ${ val } " ${ checkedFeels . has ( val ) ? " checked" : "" } > ${ lab } </label> ` ) . join ( "" ) }
< div class = "cal-dd-group" > Sky < / d i v >
$ { SKY _WORDS . map ( ( [ val , lab ] ) =>
` <label class="cal-dd-opt"><input type="checkbox" data-wsky=" ${ val } " ${ checkedSkies . has ( val ) ? " checked" : "" } > ${ lab } </label> ` ) . join ( "" ) }
< / d i v >
< / d e t a i l s > ` ;
function renderFilters ( ) {
const years = calYears ( ) ;
checkedYears = new Set ( years ) ; // reset to all available whenever the range changes
filterYears = years ;
filtersEl . hidden = false ;
// The month/year range picker stays as static HTML (keeps its listeners); only the
// season/year/weather dropdowns are rebuilt here, since the year set depends on
// the span (the weather sets survive the rebuild — they're module state).
seasonYearEl . innerHTML =
2026-07-12 05:33:09 +00:00
seasonFilterDropdown ( checkedMonths , { southern : southern ( ) } ) +
2026-07-11 00:29:47 +00:00
filterDropdown ( "year" , "Years" , "year" ,
years . map ( ( y ) => [ y , y , checkedYears . has ( y ) ] ) , yearSummaryText ( ) ) +
weatherDropdown ( ) ;
2026-07-12 05:33:09 +00:00
syncSeasonChecks ( seasonYearEl , checkedMonths ) ; // set the season indeterminate state
2026-07-11 00:29:47 +00:00
}
filtersEl . addEventListener ( "change" , ( e ) => {
const cb = e . target . closest ( "input[type=checkbox]" ) ;
if ( ! cb ) return ;
2026-07-12 05:33:09 +00:00
if ( applySeasonMonthChange ( cb , checkedMonths ) ) { // a season or month toggle
syncSeasonChecks ( seasonYearEl , checkedMonths ) ;
persistCalMonths ( ) ;
if ( calSheet ) calSheet . setActive ( checkedMonths . size < 12 ) ;
2026-07-11 00:29:47 +00:00
} else if ( cb . dataset . year ) {
const y = + cb . dataset . year ;
cb . checked ? checkedYears . add ( y ) : checkedYears . delete ( y ) ;
} else if ( cb . dataset . wfeel ) {
cb . checked ? checkedFeels . add ( cb . dataset . wfeel ) : checkedFeels . delete ( cb . dataset . wfeel ) ;
} else if ( cb . dataset . wsky ) {
cb . checked ? checkedSkies . add ( cb . dataset . wsky ) : checkedSkies . delete ( cb . dataset . wsky ) ;
}
updateFilterSummaries ( ) ;
renderCalendar ( ) ;
} ) ;
2026-07-12 05:33:09 +00:00
// Wire the per-season "Months" expand toggles (delegated on the stable container),
// and the mobile floating pill that slides the whole filter panel up as a sheet.
initSeasonExpand ( seasonYearEl ) ;
2026-07-12 07:45:12 +00:00
const calSheet = initFilterSheet ( { panel : filtersEl , label : "Filters" } ) ;
2026-07-12 05:33:09 +00:00
calSheet . setActive ( checkedMonths . size < 12 ) ;
2026-07-11 00:29:47 +00:00
// Close any open filter dropdown when tapping outside it (native <details> stays
// open otherwise). Doesn't touch the day tooltip's own outside-tap handler.
document . addEventListener ( "pointerdown" , ( e ) => {
seasonYearEl . querySelectorAll ( "details.cal-dd[open]" ) . forEach ( ( d ) => {
if ( ! d . contains ( e . target ) ) d . open = false ;
} ) ;
} , true ) ;
// ---- date-range picker (month + year only) ----
// The inputs are <input type="month"> ("YYYY-MM"); a picked month is expanded to a
// full-month ISO span so every displayed month is shown in full (no partial weeks).
const startInput = document . getElementById ( "range-start" ) ;
const endInput = document . getElementById ( "range-end" ) ;
const refreshBtn = document . getElementById ( "range-refresh" ) ;
// Selectable bounds: 1970 through the current year. Fixed (not derived from the
// loaded span) so the To field always reaches the present; the actual data starts
// ~1980 and ends a few days ago, and out-of-range picks are clamped server-side.
startInput . min = endInput . min = "1970-01" ;
startInput . max = endInput . max = ` ${ new Date ( ) . getFullYear ( ) } -12 ` ;
// Editing a date doesn't fetch — it reveals the Refresh button so the user
// confirms before a (possibly multi-request) load kicks off.
function markRangeDirty ( ) { refreshBtn . hidden = false ; }
startInput . addEventListener ( "change" , markRangeDirty ) ;
endInput . addEventListener ( "change" , markRangeDirty ) ;
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
clickOpensPicker ( startInput , endInput ) ; // whole-field tap opens the month picker
2026-07-11 18:47:52 +00:00
2026-07-11 00:29:47 +00:00
// Confirm: read the pickers into the active range and (re)load.
function applyRange ( ) {
let s = startInput . value ? monthStart ( startInput . value ) : null ;
let e = endInput . value ? monthEnd ( endInput . value ) : null ;
if ( s && e && s > e ) { const t = s ; s = e ; e = t ; } // keep start ≤ end
rangeStart = s ; rangeEnd = e ;
saveCalRange ( s , e ) ; // keep these dates across refreshes
syncDayToOtherViews ( ) ; // push the To-month to Weekly/Day
refreshBtn . hidden = true ;
if ( selected ) fetchCalendar ( ) ;
}
refreshBtn . addEventListener ( "click" , applyRange ) ;
// A To-Date change updates the day the Weekly/Day views open on: the 1st of the
// To month, or today if that month is still in the future (no data ahead of today).
function syncDayToOtherViews ( ) {
if ( ! selected || ! rangeEnd ) return ;
const toFirst = rangeEnd . slice ( 0 , 7 ) + "-01" ;
const todayIso = isoOfDate ( new Date ( ) ) ;
const day = toFirst > todayIso ? todayIso : toFirst ;
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
saveLastLocation ( selected . lat , selected . lon , day ) ;
2026-07-11 00:29:47 +00:00
setCalSync ( day ) ;
}
// ---- fetch + render ----
function selectLocation ( lat , lon ) {
selected = { lat , lon } ;
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 ( lat , lon ) ) ;
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
saveLastLocation ( lat , lon ) ; // keep date for the other views
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
// Show the raw coordinates immediately; they're replaced with the resolved
// neighborhood + city name once the first calendar chunk lands.
locLabel . textContent = ` ${ lat . toFixed ( 3 ) } , ${ lon . toFixed ( 3 ) } ` ;
locLabel . hidden = false ;
2026-07-11 00:29:47 +00:00
fetchCalendar ( ) ;
}
// Up to this many chunk requests are in flight at once. The server grades every
// chunk against one shared, already-cached history record, so a few parallel
// requests cut wall-clock time on long spans without extra upstream fetches.
const CHUNK _CONCURRENCY = 4 ;
async function fetchCalendar ( ) {
if ( ! selected ) return ;
const token = ++ fetchToken ; // supersede any in-flight chunked load
placeholder . hidden = true ;
refreshBtn . hidden = true ;
calHead . hidden = false ;
data = null ;
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
// Delay the spinner: cache-served chunks render in a few ms, so clearing the
// grid immediately would just flash-blank a warm load. A genuinely slow
// (network) load still gets the spinner and a cleared grid.
const spin = setTimeout ( ( ) => {
if ( token !== fetchToken || data ) return ;
calHead . innerHTML = ` <p class="spinner">Grading daily weather across the range…</p> ` ;
calEl . innerHTML = "" ;
keyEl . innerHTML = "" ;
} , 150 ) ;
2026-07-11 00:29:47 +00:00
const q = ` lat= ${ selected . lat } &lon= ${ selected . lon } ` ;
// A custom span is split into ≤2-year requests; the default (no range) is one
// last-24-months request that also discovers the latest available date.
const chunks = ( rangeStart && rangeEnd ) ? buildChunks ( rangeStart , rangeEnd ) : [ null ] ;
const dayMap = new Map ( ) ;
const results = new Array ( chunks . length ) ; // per-chunk response (or {error}), by index
let donePrefix = 0 ; // chunks [0, donePrefix) are all loaded
// Set the location, pickers, and filters once — off whichever chunk lands first
// (cell/place are identical across chunks; the requested span drives the rest).
const initOnce = ( d ) => {
if ( data ) return ;
data = d ;
reqStart = rangeStart || d . range . start ;
reqEnd = rangeEnd || d . range . end ;
startInput . value = reqStart . slice ( 0 , 7 ) ; // "YYYY-MM" for <input type=month>
endInput . value = reqEnd . slice ( 0 , 7 ) ;
document . getElementById ( "metric-block" ) . hidden = false ;
2026-07-22 19:08:57 +00:00
locLabel . textContent = placeLabel ( d ) ;
2026-07-11 00:29:47 +00:00
locLabel . hidden = false ;
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
setFindLabel ( findBtn , "Change location" ) ;
2026-07-11 00:29:47 +00:00
renderFilters ( ) ;
} ;
// Advance the visible span over the contiguous run of loaded chunks from the
// oldest, so the grid fills top-down with no gaps even when chunks finish out of
// order. A failed chunk halts the visible prefix at its position.
const advance = ( ) => {
while ( donePrefix < chunks . length && results [ donePrefix ] && ! results [ donePrefix ] . error ) donePrefix ++ ;
if ( donePrefix === 0 || ! data ) return ;
data . days = [ ... dayMap . values ( ) ] ;
data . range = { start : results [ 0 ] . range . start , end : results [ donePrefix - 1 ] . range . end } ;
renderHead ( chunks . length - donePrefix , true ) ;
renderCalendar ( ) ;
renderKey ( ) ;
} ;
Extract the chart library and the chunked streaming fetch (#49)
app.js carried the whole SVG chart implementation (~380 lines: palette,
percentile fans, the shared line-series renderer, per-metric wrappers,
pointer hover) alongside its page logic. That moves to chart.js: the
drawing dimensions live there as live-bound module state behind
setChartWidth(); attachChartHover takes the day list as a parameter
instead of reaching into page globals. app.js keeps buildChart and the
PNG export — page orchestration — and drops to ~500 lines. Any page can
now plot a metric series (e.g. a history sparkline on the day page).
calendar.js's hand-rolled streaming loader — bounded-parallel pool,
token cancellation, stale-while-revalidate re-merge — becomes
cache.js chunkedFetch(urls, {ttl, concurrency, isCurrent, onResult});
fetchCalendar keeps only its intent (chunk URLs, day merge, contiguous-
prefix rendering). Same pool semantics, verbatim.
Verified: node --check; 108 backend tests; headless-Chromium smoke on
all five pages against live data (chart renders + metric toggle,
calendar grid streams + metric switch, ladders, compare, legend).
2026-07-11 20:33:06 +00:00
const urls = chunks . map ( ( ch ) => ch
2026-07-22 18:50:00 +00:00
? uv ( ` calendar? ${ q } &start= ${ ch . start } &end= ${ ch . end } ` )
: uv ( ` calendar? ${ q } &months=24 ` ) ) ;
Extract the chart library and the chunked streaming fetch (#49)
app.js carried the whole SVG chart implementation (~380 lines: palette,
percentile fans, the shared line-series renderer, per-metric wrappers,
pointer hover) alongside its page logic. That moves to chart.js: the
drawing dimensions live there as live-bound module state behind
setChartWidth(); attachChartHover takes the day list as a parameter
instead of reaching into page globals. app.js keeps buildChart and the
PNG export — page orchestration — and drops to ~500 lines. Any page can
now plot a metric series (e.g. a history sparkline on the day page).
calendar.js's hand-rolled streaming loader — bounded-parallel pool,
token cancellation, stale-while-revalidate re-merge — becomes
cache.js chunkedFetch(urls, {ttl, concurrency, isCurrent, onResult});
fetchCalendar keeps only its intent (chunk URLs, day merge, contiguous-
prefix rendering). Same pool semantics, verbatim.
Verified: node --check; 108 backend tests; headless-Chromium smoke on
all five pages against live data (chart renders + metric toggle,
calendar grid streams + metric switch, ladders, compare, legend).
2026-07-11 20:33:06 +00:00
// Bounded-parallel streaming load (see cache.js). Stale-while-revalidate
// updates re-merge that chunk's days and repaint — the cell/place metadata is
// identical across versions, and initOnce self-guards after the first chunk.
await chunkedFetch ( urls , {
ttl : TTL . calendar ,
concurrency : CHUNK _CONCURRENCY ,
isCurrent : ( ) => token === fetchToken ,
onResult : ( i , d ) => {
2026-07-11 00:29:47 +00:00
results [ i ] = d ;
Extract the chart library and the chunked streaming fetch (#49)
app.js carried the whole SVG chart implementation (~380 lines: palette,
percentile fans, the shared line-series renderer, per-metric wrappers,
pointer hover) alongside its page logic. That moves to chart.js: the
drawing dimensions live there as live-bound module state behind
setChartWidth(); attachChartHover takes the day list as a parameter
instead of reaching into page globals. app.js keeps buildChart and the
PNG export — page orchestration — and drops to ~500 lines. Any page can
now plot a metric series (e.g. a history sparkline on the day page).
calendar.js's hand-rolled streaming loader — bounded-parallel pool,
token cancellation, stale-while-revalidate re-merge — becomes
cache.js chunkedFetch(urls, {ttl, concurrency, isCurrent, onResult});
fetchCalendar keeps only its intent (chunk URLs, day merge, contiguous-
prefix rendering). Same pool semantics, verbatim.
Verified: node --check; 108 backend tests; headless-Chromium smoke on
all five pages against live data (chart renders + metric toggle,
calendar grid streams + metric switch, ladders, compare, legend).
2026-07-11 20:33:06 +00:00
if ( ! d . error ) {
for ( const x of d . days ) dayMap . set ( x . date , x ) ;
initOnce ( d ) ;
2026-07-11 00:29:47 +00:00
}
Extract the chart library and the chunked streaming fetch (#49)
app.js carried the whole SVG chart implementation (~380 lines: palette,
percentile fans, the shared line-series renderer, per-metric wrappers,
pointer hover) alongside its page logic. That moves to chart.js: the
drawing dimensions live there as live-bound module state behind
setChartWidth(); attachChartHover takes the day list as a parameter
instead of reaching into page globals. app.js keeps buildChart and the
PNG export — page orchestration — and drops to ~500 lines. Any page can
now plot a metric series (e.g. a history sparkline on the day page).
calendar.js's hand-rolled streaming loader — bounded-parallel pool,
token cancellation, stale-while-revalidate re-merge — becomes
cache.js chunkedFetch(urls, {ttl, concurrency, isCurrent, onResult});
fetchCalendar keeps only its intent (chunk URLs, day merge, contiguous-
prefix rendering). Same pool semantics, verbatim.
Verified: node --check; 108 backend tests; headless-Chromium smoke on
all five pages against live data (chart renders + metric toggle,
calendar grid streams + metric switch, ladders, compare, legend).
2026-07-11 20:33:06 +00:00
advance ( ) ;
} ,
2026-07-11 00:29:47 +00:00
} ) ;
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 ) ;
2026-07-11 00:29:47 +00:00
if ( token !== fetchToken ) return ; // superseded while loading
if ( ! data ) { // every chunk failed
const firstErr = results . find ( ( r ) => r && r . error ) ;
calHead . innerHTML = ` <p class="error"> ${ firstErr ? firstErr . error . message : "Failed to load." } </p> ` ;
return ;
}
renderHead ( chunks . length - donePrefix , false ) ; // final head (flags any gap)
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
prefetchViews ( selected . lat , selected . lon , [ "calendar" ] ) ; // warm weekly/day/forecast
2026-07-11 00:29:47 +00:00
}
// Header summary. While `loading`, `remaining` > 0 shows a "loading N more blocks"
// note; once loading has finished, a leftover `remaining` flags chunks that failed.
function renderHead ( remaining , loading ) {
2026-07-22 19:08:57 +00:00
const place = placeLabel ( data ) ;
2026-07-11 00:29:47 +00:00
const years = ( ( new Date ( data . range . end ) - new Date ( data . range . start ) ) / 3.15576 e10 ) ;
let note = "" ;
if ( remaining > 0 && loading ) {
note = ` · <span class="cal-loading">loading ${ remaining } more block ${ remaining === 1 ? "" : "s" } …</span> ` ;
} else if ( remaining > 0 ) {
note = ` · <span class="error"> ${ remaining } block ${ remaining === 1 ? "" : "s" } failed to load</span> ` ;
}
calHead . innerHTML = `
< h2 > $ { place } < / h 2 >
< p class = "meta" > $ { data . range . start } → $ { data . range . end } · $ { data . days . length . toLocaleString ( ) } days graded
2026-07-11 07:10:46 +00:00
( ~ $ { years . toFixed ( 1 ) } yr ) $ { note } < / p >
2026-07-11 00:29:47 +00:00
< p class = "cal-hint" > $ { IC _POINTER } Tap or click any day for its weather and grades < / p > ` ;
}
function renderCalendar ( ) {
if ( ! data ) return ;
const byDate = new Map ( data . days . map ( ( d ) => [ d . date , d ] ) ) ;
const start = new Date ( data . range . start + "T00:00:00" ) ;
const end = new Date ( data . range . end + "T00:00:00" ) ;
let y = start . getFullYear ( ) , m = start . getMonth ( ) ;
const endY = end . getFullYear ( ) , endM = end . getMonth ( ) ;
let html = "" ;
const wActive = weatherFilterActive ( ) ;
const visible = [ ] ; // every graded day in a shown month: {rec, pass}
while ( y < endY || ( y === endY && m <= endM ) ) {
// Only render months that are fully inside the graded range — never a partial
// boundary month showing a stray day or two. The range is month-based, so a
// clipped edge month (e.g. data starting mid-June) is dropped entirely.
const mFirst = new Date ( y , m , 1 ) , mLast = new Date ( y , m + 1 , 0 ) ;
if ( mFirst < start || mLast > end ) {
m ++ ; if ( m > 11 ) { m = 0 ; y ++ ; }
continue ;
}
2026-07-12 05:33:09 +00:00
// Time-of-year / year filters: skip months whose month or year is unchecked.
if ( ! checkedMonths . has ( m ) || ( checkedYears && ! checkedYears . has ( y ) ) ) {
2026-07-11 00:29:47 +00:00
m ++ ; if ( m > 11 ) { m = 0 ; y ++ ; }
continue ;
}
const lead = new Date ( y , m , 1 ) . getDay ( ) ; // 0 = Sunday
const dim = new Date ( y , m + 1 , 0 ) . getDate ( ) ;
let cells = "" ;
for ( let i = 0 ; i < lead ; i ++ ) cells += ` <div class="cal-cell empty"></div> ` ;
for ( let day = 1 ; day <= dim ; day ++ ) {
const iso = ` ${ y } - ${ String ( m + 1 ) . padStart ( 2 , "0" ) } - ${ String ( day ) . padStart ( 2 , "0" ) } ` ;
const rec = byDate . get ( iso ) ;
let bg = "" , pass = true ;
if ( rec ) {
if ( metric === "dsr" ) {
bg = drynessColor ( rec . dsr ) ;
} else if ( metric === "precip" ) {
const g = rec . precip ;
// Dry days take their dry-streak color; rainy days take the blue ramp.
bg = ! g ? "" : g . c === "dry" ? drynessColor ( rec . dsr ) : PRECIP _COLORS [ g . c ] ;
} else {
2026-07-11 02:58:56 +00:00
const g = rec [ metric ] ;
2026-07-11 00:29:47 +00:00
bg = g ? TIER _COLORS [ g . c ] : "" ;
}
pass = ! wActive || weatherPass ( rec ) ;
visible . push ( { rec , pass } ) ;
}
// Days with no graded record (outside the range) render transparent, not as a
// grey tile — so every displayed month reads as clean color with no dead squares.
const num = ` <span class="cal-daynum"> ${ day } </span> ` ;
cells += rec
? ` <div class="cal-cell filled ${ pass ? "" : " dim" } " data-date=" ${ iso } " ${ bg ? ` style="background: ${ bg } " ` : "" } > ${ num } </div> `
: ` <div class="cal-cell empty"> ${ num } </div> ` ;
}
html += ` <div class="cal-month"><h4> ${ MONTHS [ m ] } ${ y } </h4>
< div class = "cal-dows" > < span > S < / s p a n > < s p a n > M < / s p a n > < s p a n > T < / s p a n > < s p a n > W < / s p a n > < s p a n > T < / s p a n > < s p a n > F < / s p a n > < s p a n > S < / s p a n > < / d i v >
< div class = "cal-grid" > $ { cells } < / d i v > < / d i v > ` ;
m ++ ; if ( m > 11 ) { m = 0 ; y ++ ; }
}
2026-07-12 05:33:09 +00:00
calEl . innerHTML = html || ` <p class="muted">No months match the selected months/years.</p> ` ;
2026-07-22 19:10:48 +00:00
renderCalInsights ( ) ;
2026-07-11 00:29:47 +00:00
renderTotals ( visible ) ;
attachHover ( byDate ) ;
}
2026-07-22 19:10:48 +00:00
const FULL _MONTHS = [ "January" , "February" , "March" , "April" , "May" , "June" ,
"July" , "August" , "September" , "October" , "November" , "December" ] ;
// Plain-language climatology insights for the currently selected month(s), built
// from `data.months_climate` (per-month rain/sun frequencies + typical highs over
// the full history). Includes a cross-month "Nx more likely to rain" comparison
// against the driest month of the year — the numbers travelers actually plan on.
function renderCalInsights ( ) {
const el = document . getElementById ( "cal-insights" ) ;
const mc = data && data . months _climate ;
const sel = mc ? [ ... checkedMonths ] . sort ( ( a , b ) => a - b ) : [ ] ; // 0-based indices
const entries = sel . map ( ( mi ) => mc [ String ( mi + 1 ) ] ) . filter ( Boolean ) ;
if ( ! entries . length ) { el . hidden = true ; el . innerHTML = "" ; return ; }
const avg = ( a ) => ( a . length ? a . reduce ( ( s , x ) => s + x , 0 ) / a . length : null ) ;
const tmax = avg ( entries . map ( ( e ) => e . tmax _med ) . filter ( ( v ) => v != null ) ) ;
const rainFreq = avg ( entries . map ( ( e ) => e . rain _freq ) . filter ( ( v ) => v != null ) ) ;
const sunFreq = avg ( entries . map ( ( e ) => e . sun _freq ) . filter ( ( v ) => v != null ) ) ;
const rainyDays = entries . map ( ( e ) => e . rainy _days ) . filter ( ( v ) => v != null )
. reduce ( ( s , x ) => s + x , 0 ) ;
const label = seasonSummaryText ( checkedMonths , { southern : southern ( ) } ) ;
const lines = [ ] ;
if ( tmax != null ) {
lines . push ( ` <li><span class="ins-emoji" aria-hidden="true">🌡️</span> Typical highs near <b> ${ fmtTemp ( tmax , true ) } </b>.</li> ` ) ;
}
if ( sunFreq != null ) {
const pct = Math . round ( sunFreq * 100 ) ;
const emoji = sunFreq >= 0.6 ? "☀️" : sunFreq >= 0.3 ? "⛅" : "☁️" ;
lines . push ( ` <li><span class="ins-emoji" aria-hidden="true"> ${ emoji } </span> Sunny about <b> ${ pct } %</b> of days.</li> ` ) ;
}
if ( rainFreq != null ) {
let emoji , advice ;
if ( rainFreq < 0.15 ) {
emoji = "🌤️" ; advice = "mostly dry — you can probably leave the umbrella at home." ;
} else if ( rainFreq < 0.4 ) {
emoji = "🌦️" ; advice = "pack a compact umbrella just in case." ;
} else {
emoji = "🌧️" ; advice = "bring your favorite umbrella or raincoat!" ;
}
lines . push ( ` <li class="ins-rain"><span class="ins-emoji" aria-hidden="true"> ${ emoji } </span> About <b> ${ Math . round ( rainyDays ) } </b> rainy days across ${ label } — ${ advice } </li> ` ) ;
// Cross-month comparison: selected months vs the driest single month of the
// year (skip when the whole year is selected, or the multiple is trivial).
if ( checkedMonths . size < 12 ) {
let driestK = null , driestFreq = Infinity ;
for ( const [ k , e ] of Object . entries ( mc ) ) {
if ( e . rain _freq != null && e . rain _freq < driestFreq ) { driestFreq = e . rain _freq ; driestK = k ; }
}
if ( driestK && driestFreq > 0 ) {
const mult = rainFreq / driestFreq ;
if ( mult >= 1.5 ) {
const dName = FULL _MONTHS [ + driestK - 1 ] ;
lines . push ( ` <li><span class="ins-emoji" aria-hidden="true">📊</span> You're about <b> ${ mult . toFixed ( mult < 10 ? 1 : 0 ) } × </b> more likely to see rain than in ${ dName } .</li> ` ) ;
}
}
}
}
if ( ! lines . length ) { el . hidden = true ; el . innerHTML = "" ; return ; }
el . innerHTML = ` <p class="insight-lead">Historical weather for ${ label } </p>
< ul class = "insight-list" > $ { lines . join ( "" ) } < / u l > ` ;
el . hidden = false ;
}
2026-07-11 00:29:47 +00:00
// ---- category totals ----
// "What share of the shown days lands in each category?" for the current metric,
// rendered above the grid as a stacked share bar + per-category percentages.
// When the weather filter is narrowing, only matching days are totaled (and the
// title says how many matched) — so it doubles as a "days you'd love" counter.
function renderTotals ( visible ) {
const totEl = document . getElementById ( "cal-totals" ) ;
2026-07-11 01:58:40 +00:00
lastVisible = visible ; // remembered so the "Show count" toggle can re-render in place
2026-07-11 00:29:47 +00:00
if ( ! visible . length ) { totEl . hidden = true ; totEl . innerHTML = "" ; return ; }
const wActive = weatherFilterActive ( ) ;
const days = wActive ? visible . filter ( ( v ) => v . pass ) . map ( ( v ) => v . rec ) : visible . map ( ( v ) => v . rec ) ;
2026-07-11 22:33:38 +00:00
const title = metric === "dsr" ? "dry streak"
: metric === "precip" ? "rain intensity"
: ( TEMP _METRIC _INFO [ metric ] || { } ) . label || "value" ;
// Category distribution (low→high) for the metric; the bucketing and the strip
// visual — the wet/dry scale-group split and the per-tier label hook — are shared
// with the compare page. Category names live in the color key below the grid too.
const buckets = metricBuckets ( days , metric ) ;
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
const total = buckets . reduce ( ( n , b ) => n + b . n , 0 ) ;
2026-07-11 22:33:38 +00:00
2026-07-11 00:29:47 +00:00
const head = wActive
Remove em-dashes from site copy and tighten the prose (#206)
Replace em-dashes in user-facing copy across the server-rendered pages,
static frontend views, and the strings the app injects at runtime, using
colons, commas, parentheses or full stops as the context wants. Data
placeholder glyphs (a lone "—" for a missing reading) are left alone,
since a hyphen there reads as a minus sign in temperature columns.
Also tighten the high-visibility surfaces (home hero and meta, about,
privacy, city and records ledes, glossary blurbs) toward a plainer,
more direct voice while keeping every factual claim intact.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 01:48:33 +00:00
? ` ${ days . length . toLocaleString ( ) } of ${ visible . length . toLocaleString ( ) } shown days match the weather filter, by ${ title } : `
: ` ${ visible . length . toLocaleString ( ) } days shown, by ${ title } : ` ;
2026-07-11 22:33:38 +00:00
// The strip prints each category's share by default, or the raw day count when the
// "Show count" checkbox (state: showCount) is ticked.
2026-07-11 01:58:40 +00:00
const countToggle =
` <label class="ct-count-toggle"><input type="checkbox" id="ct-count-cb" ` +
` ${ showCount ? " checked" : "" } /> Show count</label> ` ;
const header = ` <div class="ct-head"><div class="section-title"> ${ head } </div> ${ countToggle } </div> ` ;
2026-07-11 22:33:38 +00:00
totEl . innerHTML = total
? ` ${ header } \n ${ distStrip ( buckets , showCount ) } `
: ` ${ header } <p class="muted">No matching days.</p> ` ;
2026-07-11 00:29:47 +00:00
totEl . hidden = false ;
}
function renderKey ( ) {
if ( metric === "dsr" ) {
const steps = [ [ "Rain" , 0 ] , [ "1 day" , 1 ] , [ "4" , 4 ] , [ "7" , 7 ] , [ "10" , 10 ] , [ "14+" , 14 ] ] ;
2026-07-22 19:08:57 +00:00
const segs = tierKeySegs ( steps . map ( ( [ label , d ] ) => ( { color : drynessColor ( d ) , label } ) ) ) ;
Remove em-dashes from site copy and tighten the prose (#206)
Replace em-dashes in user-facing copy across the server-rendered pages,
static frontend views, and the strings the app injects at runtime, using
colons, commas, parentheses or full stops as the context wants. Data
placeholder glyphs (a lone "—" for a missing reading) are left alone,
since a hyphen there reads as a minus sign in temperature columns.
Also tighten the high-visibility surfaces (home hero and meta, about,
privacy, city and records ledes, glossary blurbs) toward a plainer,
more direct voice while keeping every factual claim intact.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 01:48:33 +00:00
keyEl . innerHTML = ` <div class="section-title">Days since last rain: dry streak (caps at ${ DRY _CAP } days)</div>
2026-07-11 00:29:47 +00:00
< div class = "tier-key" > $ { segs } < / d i v > $ { G U I D E _ L I N K } ` ;
return ;
}
if ( metric === "precip" ) {
2026-07-22 19:08:57 +00:00
const rain = tierKeySegs ( [ ... SCALE _RAIN ] . reverse ( ) . map ( ( t ) =>
( { color : PRECIP _COLORS [ t . c ] , label : t . label } ) ) ) ;
const dry = tierKeySegs ( [ [ "≤1 day" , 1 ] , [ "7" , 7 ] , [ "14+" , 14 ] ] . map ( ( [ label , d ] ) =>
( { color : drynessColor ( d ) , label } ) ) ) ;
2026-07-11 00:29:47 +00:00
keyEl . innerHTML = ` <div class="section-title">Rain intensity · light → heavy</div>
< div class = "tier-key" > $ { rain } < / d i v >
< div class = "section-title" style = "margin-top:10px" > Dry days · days since rain < / d i v >
< div class = "tier-key" > $ { dry } < / d i v > $ { G U I D E _ L I N K } ` ;
return ;
}
const label = ( TEMP _METRIC _INFO [ metric ] || { } ) . label || "value" ;
// Highest tier first (Near Record hot → Near Record cold).
2026-07-22 19:08:57 +00:00
const segs = tierKeySegs ( [ ... SCALE _TEMP ] . reverse ( ) . map ( ( t ) =>
( { color : TIER _COLORS [ t . c ] , label : t . label } ) ) ) ;
2026-07-11 00:29:47 +00:00
keyEl . innerHTML = ` <div class="section-title">Coloring by ${ label } · low → high vs local normal</div>
< div class = "tier-key" > $ { segs } < / d i v > $ { G U I D E _ L I N K } ` ;
}
// ---- tooltip ----
function attachHover ( byDate ) {
const tip = document . getElementById ( "cal-tip" ) ;
// The row for the metric currently being viewed is bolded and moved to the top,
// so it's easy to match a cell's color to the stat it represents. Its category
// (grade) text is tinted with the very color that represents that day's value —
// bold + saturated for the active metric, lifted/dimmed for the rest (see CSS).
// Each metric is one row of the mini grid: name · value(+pct) · category.
2026-07-11 02:58:56 +00:00
// `activeKey` is the row to bold: the metric currently being viewed.
2026-07-11 00:29:47 +00:00
let activeKey = metric ;
const line = ( key , name , g , fmt , color ) => {
const rc = key === activeKey ? " tt-r-active" : "" ;
if ( ! g ) return ` <span class="tt-name ${ rc } "> ${ name } </span><span class="tt-val ${ rc } ">—</span><span class="tt-cat ${ rc } "></span> ` ;
Render every percentile through one rule (#186)
The homepage strip floored percentiles into 1-99 while the Day page rendered
"100th pct" for the same reading. Rather than teach the Day page a second copy
of the rule, put it in grading.pct_ordinal() and have every surface defer to it:
the Day page, the calendar tooltip, the chart labels, the city pages and the
strip.
Two bugs fell out of unifying them:
- The city pages rendered `{{ c.percentile }}th pct` against a float, so all
~1000 of them showed "97.1th pct", "66.4th pct", "16.5th pct" — and would say
"1th"/"2th"/"3th" for the low tail. Live in prod.
- Python's round() is half-to-even and JavaScript's Math.round is half-up, so
16.5 gave "16th" server-side and "17th" client-side. The Python helper uses
floor(x + 0.5) to match the frontend exactly; a cross-language check over every
.5 boundary now agrees on all of them.
The frontend mirrors the rule as pctOrd() in shared.js, replacing the bare ord()
at all five percentile call sites (day.js, calendar.js, chart.js x2, app.js).
ord() stays as the general ordinal helper it always was.
2026-07-18 09:08:28 +00:00
const pct = g . pct == null ? "" : ` <span class="tt-pct"> · ${ pctOrd ( g . pct ) } pct</span> ` ;
2026-07-11 00:29:47 +00:00
const catCls = key === activeKey ? "tt-cat tt-cat-active" : "tt-cat" ;
const style = color ? ` style="--cat: ${ color } " ` : "" ;
return ` <span class="tt-name ${ rc } "> ${ name } </span><span class="tt-val ${ rc } "> ${ fmt ( g . v ) } ${ pct } </span><span class=" ${ catCls } ${ rc } " ${ style } > ${ g . g } </span> ` ;
} ;
const show = ( cell , e ) => {
const rec = byDate . get ( cell . dataset . date ) ;
if ( ! rec ) return ;
2026-07-11 02:58:56 +00:00
activeKey = metric ;
2026-07-11 00:29:47 +00:00
const dt = new Date ( rec . date + "T00:00:00" ) ;
// Color each metric the same way the calendar cell would be colored for it.
const tempColor = ( g ) => ( g ? TIER _COLORS [ g . c ] : "" ) ;
const precipColor = ! rec . precip ? "" : rec . precip . c === "dry" ? drynessColor ( rec . dsr ) : PRECIP _COLORS [ rec . precip . c ] ;
const dsrStr = rec . dsr == null ? null
: rec . dsr === 0 ? "Rain today"
: ` ${ rec . dsr } day ${ rec . dsr === 1 ? "" : "s" } since rain ` ;
const rows = [
[ "tmax" , line ( "tmax" , "High" , rec . tmax , fmtTemp , tempColor ( rec . tmax ) ) ] ,
[ "tmin" , line ( "tmin" , "Low" , rec . tmin , fmtTemp , tempColor ( rec . tmin ) ) ] ,
2026-07-11 02:58:56 +00:00
[ "feels" , line ( "feels" , "Feels" , rec . feels , fmtTemp , tempColor ( rec . feels ) ) ] ,
2026-07-11 00:29:47 +00:00
[ "humid" , line ( "humid" , "Humid" , rec . humid , fmtHumid , tempColor ( rec . humid ) ) ] ,
[ "wind" , line ( "wind" , "Wind" , rec . wind , fmtWind , tempColor ( rec . wind ) ) ] ,
[ "gust" , line ( "gust" , "Gust" , rec . gust , fmtWind , tempColor ( rec . gust ) ) ] ,
2026-07-24 23:20:16 +00:00
[ "precip" , line ( "precip" , "Precip" , rec . precip , ( v ) => fmtPrecipTier ( v , rec . precip && rec . precip . c ) , precipColor ) ] ,
2026-07-11 00:29:47 +00:00
] ;
if ( dsrStr ) {
const rc = metric === "dsr" ? " tt-r-active" : "" ;
const catCls = metric === "dsr" ? "tt-cat tt-cat-active" : "tt-cat" ;
// No separate percentile — let the streak text span the value + category columns.
rows . push ( [ "dsr" , ` <span class="tt-name ${ rc } ">Dry streak</span><span class=" ${ catCls } tt-span2 ${ rc } " style="--cat: ${ drynessColor ( rec . dsr ) } "> ${ dsrStr } </span> ` ] ) ;
}
// active metric first, the rest keep their order
rows . sort ( ( a , b ) => ( b [ 0 ] === activeKey ) - ( a [ 0 ] === activeKey ) ) ;
const wt = weatherType ( rec ) ;
tip . innerHTML = ` <div class="tt-date"> ${ dt . toLocaleDateString ( undefined , { weekday : "short" , year : "numeric" , month : "short" , day : "numeric" } )}</div>
< div class = "tt-type" > $ { wt . icon } $ { wt . text } < / d i v >
< div class = "tt-grid" > $ { rows . map ( ( r ) => r [ 1 ] ) . join ( "" ) } < / d i v > ` ;
tip . hidden = false ;
const pad = 14 , w = tip . offsetWidth || 170 , h = tip . offsetHeight || 70 ;
let x = e . clientX + pad , ty = e . clientY + pad ;
if ( x + w > window . innerWidth ) x = e . clientX - w - pad ;
if ( ty + h > window . innerHeight ) ty = e . clientY - h - pad ;
// Keep it on-screen even when flipped past an edge (narrow phone screens).
x = Math . max ( pad , Math . min ( x , window . innerWidth - w - pad ) ) ;
ty = Math . max ( pad , Math . min ( ty , window . innerHeight - h - pad ) ) ;
tip . style . left = x + "px" ;
tip . style . top = ty + "px" ;
} ;
// Clicking a day opens its full percentile breakdown. On a mouse, one click
// navigates (the tooltip is just a hover preview). On touch there's no hover, so
// the first tap only raises the tooltip and a second tap on the same day confirms.
const openDay = ( cell ) => {
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
location . href = ` day ${ locHash ( selected . lat , selected . lon , cell . dataset . date ) } ` ;
2026-07-11 00:29:47 +00:00
} ;
calEl . querySelectorAll ( ".cal-cell.filled" ) . forEach ( ( c ) => {
c . addEventListener ( "pointerenter" , ( e ) => { if ( e . pointerType === "mouse" ) show ( c , e ) ; } ) ;
c . addEventListener ( "pointerdown" , ( e ) => { lastPointer = e . pointerType ; show ( c , e ) ; } ) ;
c . addEventListener ( "click" , ( ) => {
if ( lastPointer === "mouse" ) return openDay ( c ) ;
if ( armedCell === c ) openDay ( c ) ; // second tap on the previewed day → open
else armedCell = c ; // first tap only showed the tooltip
} ) ;
} ) ;
}
// ---- restore (hash from a shared link, else the last place you looked at) ----
( function restore ( ) {
Convert the frontend to ES modules; split nav.js by concern (#48)
The frontend was classic scripts sharing one global scope, with a
load-order contract enforced only by comments (leaflet -> nav ->
shared -> mappicker -> page) and hand-rolled window.Thermograph /
window.LocationPicker namespaces. Page scripts now import what they use;
the dependency graph replaces the ordering contract, and no app globals
remain (Leaflet stays a classic script / global L, loaded first).
nav.js had grown four concerns; it's now three single-purpose modules:
- nav.js: last-location memory + header view-links + locHash.
- units.js: the °F/°C toggle and unit-aware formatting. The compare
special case is gone — pages that want the toggle import units.js;
compare simply doesn't.
- cache.js: the IndexedDB response cache, SWR getJSON, bundle-seeded
view prefetch and neighbor warming. prefetchViews(lat, lon, ownViews)
now takes the calling page's own view names instead of a page-identity
map (VIEW_OWN) — adding a page no longer means editing this module.
The slice->URL map stays here as bundle-contract knowledge.
frontend/package.json ({"type": "module"}) makes CI's node --check
parse the files as modules.
Verified: node --check on all files as modules; 108 backend tests;
headless-Chromium smoke across all five pages against live data — zero
console/page errors, all render assertions pass (cards, chart, calendar
grid + metric switch, ladders, compare ranking, legend scales).
2026-07-11 20:28:33 +00:00
const loc = loadLastLocation ( ) ;
2026-07-11 00:29:47 +00:00
if ( ! loc ) return ;
const saved = loadCalRange ( ) ;
const sel = loc . date ; // the shared selected day (from the hash or last-visited store)
if ( sel && sel !== getCalSync ( ) ) {
// The selected day changed on another view → align the To-month to it. Keep the
// saved From unless the new To is at/before it, in which case pull From 2 years
// back so the window stays valid (start ≤ end). Month/year granularity only.
const d = new Date ( sel + "T00:00:00" ) ;
const y = d . getFullYear ( ) , m = d . getMonth ( ) ;
rangeEnd = isoOfDate ( new Date ( y , m + 1 , 0 ) ) ; // last day of the To month
const toFirst = ` ${ y } - ${ String ( m + 1 ) . padStart ( 2 , "0" ) } -01 ` ;
const savedFrom = saved && saved . start ? saved . start : null ;
rangeStart = ( savedFrom && toFirst > savedFrom )
? savedFrom // To after From → keep From
2026-07-12 06:32:10 +00:00
: isoOfDate ( new Date ( y - 6 , m , 1 ) ) ; // To ≤ From (or none) → same month, 6yr back
2026-07-11 00:29:47 +00:00
saveCalRange ( rangeStart , rangeEnd ) ;
setCalSync ( sel ) ;
} else if ( saved ) {
rangeStart = saved . start ; rangeEnd = saved . end ; // refresh / in-sync → keep range
}
selectLocation ( loc . lat , loc . lon ) ;
} ) ( ) ;