Two fixes to how measurements read.
Rain goes back to millimetres, rendered as whole numbers — that is how rainfall
is reported, and a tenth of a millimetre is below what the source resolves.
Inches keep two decimals, since a whole inch of rain is a lot to round to.
The distribution strip's low-high span moves from inside each bar to just under
the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min
17°" pill clipped — it had already been shrunk to 8px to cope, and on a
high-rainfall city with longer values it lost both ends of the label, because the
text is centred and overflow-hidden. Above the bar it has the full column width
and needs no scrim to stay legible over nine tier colours.
The span is now bare numbers ("17-62"), since the average directly above it
carries the unit; repeating " g/m³" on both ends is what made it too wide in the
first place. It uses the same precision as that average, or the two lines
disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero
so precip's ten columns still fit a phone.
With nothing inside the bars, the height floor drops from 30px to 14px: it only
has to keep a non-empty bucket visible now, so the heights encode frequency more
honestly. Category names get 2px of side padding, which stops long neighbours
("Light-Mod" beside "Moderate") reading as one word.
Verified at 390/412/1920 in both themes across all four calendar metrics and the
compare strips, in imperial and metric: nothing clips.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
Precipitation on a °C page now reads "4.90 cm" where it read "49.0 mm".
Both units keep two decimals. cm and inches are only 2.54x apart, so cm needs the
same resolution inches have — at one decimal every light-rain day would collapse
to 0.0 or 0.1 cm, and the source itself is hundredths of an inch.
Ingest is untouched: climate.py still converts the upstream mm to inches, which
remains the stored unit and the value carried in data-precip-in.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
Defaulting climate pages to the city's temperature convention left every page
half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now
drives every measure — picking °C also gives mm and km/h.
Absolute humidity stays g/m³ in both systems; there is no imperial unit for it
anyone would recognise, so converting it would only make the page worse.
units.js becomes the single source for all of it. The formatting logic existed in
three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set
inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks
and tooltip — which is why the units drifted apart in the first place. shared.js
now re-exports from units.js so its importers are unchanged, and chart.js carries
a per-series unit `kind` in place of sniffing for a "°" suffix, which could not
have extended to three unit families.
Server-rendered pages gain the carrier they were missing: precipitation and wind
now render as <span class="precip" data-precip-in> / <span class="wind"
data-wind-mph>, the same contract temperature already had, so climate.js can
repaint them on toggle and the attribute stays imperial as the source of truth.
Precision follows the unit: millimetres get one decimal where inches get two.
0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision
the source doesn't have. Wind rounds half-up to match Math.round, as temperature
already does.
The weekly table's wind and rain cells are bare numbers to keep the grid narrow,
so their row labels now carry the unit and rebuild per render. Static copy that
names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong
for °C readers since the country defaults landed — is marked up with
data-unit-label and painted from the same source.
The chart keeps its imperial domain; only labels convert, so point positions and
the fan geometry are untouched.
Push and email bodies are still imperial-only (notify.py): they are composed
server-side with no client to repaint them and no per-user unit stored, which is
the same limitation temperature already has there. Left for a follow-up.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
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.
The min–max range inside each distribution bar packed onto one line
(e.g. -20–-16°), where the range dash collides with the minus signs and
becomes unreadable for negative temperatures. Split it into two labelled
rows — Max on top, Min below — so each value stands alone. The per-tier
average above each bar is unchanged.
Shared by the Compare distribution and the Calendar totals strip. Bump
the bar-height floor 26→30px so the two-line label never clips.
- Season filter: add a "Full year" select-all row above the four seasons (checked at
12 months, clears to none, indeterminate when partial). One shared change in
seasonFilterDropdown/applySeasonMonthChange/syncSeasonChecks covers both pages; the
summary already reads "All year" at 12.
- Metric selector rides the filter sheet on phones: initFilterSheet gains a sheetOnly
option that relocates given controls into the sheet at the 640px breakpoint and
restores them inline on desktop (matchMedia; reparenting keeps listeners). Compare
passes its "Distribution by" control, calendar its "Color the calendar by" block.
- Compare comfort/tolerance slider moved out of the params panel into a standalone
full-width card above the results, so it's always visible instead of buried in the
mobile pill sheet. The params sheet now holds basis + season + date range.
Frontend-only; no backend or payload change.
- Fix the mobile width blow-out: the distribution grid item (.cmp-dist-row) had no
min-width:0, so each row expanded to the 9–10-column connected bar chart's
min-content and widened the whole page (zoom-out, clipped filter sheet). Add
grid-template-columns: minmax(0,1fr) + min-width:0 so the strip scrolls inside its
own .ct-strip, and tighten .ct-col to 34px on phones.
- Add a Load/Refresh button inside the compare filter sheet's date-range block, wired
to the same refresh()/isDirty(); editing the range on mobile no longer needs the
sheet closed. The existing button stays in the controls (loads added places).
- Default date range on both pages is now January six years back → the present. On
the calendar this is an explicit chunked range (the months=24 path is server-capped
at ~2yr).
- Names now include country: reverse_geocode appends it to the place label; revgeo
cache key gets a v2 prefix and PAYLOAD_VER bumps to p2 so labels/payloads
repopulate.
- Location names read as a hierarchy: compact chips show just the lead segment (full
name in the title/aria), and each ranked card shows the lead segment bold over a
muted "city · region · country" line.
Frontend + a small backend name/cache change; no schema migration.
Compare gains a time-of-year filter it never had; the calendar's flat 4-season
filter is upgraded to the same model so both pages filter time identically.
- Shared season/month model in shared.js: seasons are the primary, color-coded
selector, the 12 months a nested suboption, backed by one Set of month indices.
seasonFilterDropdown/syncSeasonChecks/applySeasonMonthChange/seasonSummaryText
plus month<->bitmask helpers, reused by both pages.
- Compare: filter gates r.date's month into computeStats and the distribution;
instant re-rank, no refetch. Persisted (cmpMonths bitmask) and carried in the
URL hash (m=, omitted when all 12). Empty-selection hint when a filter excludes
every day.
- Calendar: checkedSeasons -> checkedMonths, applied in the render loop; persisted
as calMonths. Season checkbox is checked/indeterminate by how many of its months
are on.
- Mobile filter sheet (filtersheet.js): a subtle floating pill slides the page's
filter panel up as a bottom sheet, reachable from anywhere on the page, on both
compare and calendar. Desktop keeps the inline panel unchanged. Respects
reduced-motion and the home-indicator inset.
Frontend-only; no backend or payload change.
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.
* Compare: comfort range slicer + per-location climate distribution
Replace the comfort-point + band sliders with a single dual-handle range
slicer: a day hits comfort when its judged temperature lands in [lo, hi];
below lo is colder, above hi is warmer. State is the range in °F, so old
c/t links and stored prefs migrate to a range. Ranking, average miss, and
the typical-miss figure are all computed against the range.
Add a climate-distribution section below the ranked cards, independent of
the comfort filter: its own metric selector (High/Low/Feels/Humid/Wind/
Gust/Precip/Dry streak) plus a share/count toggle render one Record-Low→
Record-High distribution strip per location, mirroring the calendar.
Extract the calendar's category bucketing and distribution strip into
shared metricBuckets/distStrip so both pages share one implementation;
calendar output is unchanged.
* Carry the calendar's wet/dry scale-group logic into the shared distribution helpers
* Carry the precip strip's per-tier label hook into the shared distribution helper
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).
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.