Commit graph

16 commits

Author SHA1 Message Date
Emi Griffith
e105869363 Split Very Heavy into Very Heavy + Severe; make Dry mean zero rain (#216)
Rain intensity gains a Severe tier and Dry becomes strictly no-rain:

- The top half of Very Heavy (95th–99th rain-day percentile) becomes a new
  "Severe" tier; Very Heavy keeps the 90–95 band. Eight rain tiers now — Trace /
  Light / Brisk / Typical / Heavy / Very Heavy / Severe / Extreme — which refill
  the wet-2..wet-9 colour ramp contiguously (no gap), so Heavy/Very Heavy shift
  one shade lighter and Severe takes the second-darkest.
- A day is Dry only when it didn't rain at all; any measurable rain, however
  slight, is at least Trace. _grade_precip splits on > 0 rather than the 0.01"
  threshold (which still governs the separate dry-streak metric).
- The distribution strip drops the range under the Dry column — every dry day is
  zero, so a "0–0" span was noise.

_precip_ladder derives its percentile marks from RAIN_BANDS now, so adding or
splitting a tier can't leave a hard-coded list behind (that was the bug the 95th
mark would have hit). The detail-view ladder derives from the band table as before.

Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-20 05:09:22 +00:00
Emi Griffith
1d32ec6b40 Rename the middle rain tiers and merge Mod–Heavy into Heavy (#214)
The rain-intensity scale drops its two hyphenated compound labels for single
words and loses a tier, going from eight to seven:

  Trace / Light / Brisk / Typical / Heavy / Very Heavy / Extreme

- Light–Mod -> Brisk, Moderate -> Typical (renames only; classes unchanged).
- Mod–Heavy is merged up into Heavy, whose floor drops from the 75th to the 60th
  rain-day percentile, so Heavy now spans 60–90.
- The lightest tier (already the merged Very Light) is renamed Trace.

grading.py RAIN_BANDS and the frontend SCALE_RAIN mirror stay in lockstep, and
the detail-view ladder derives from the table so it follows automatically. The
now-unreferenced wet-6 colour token is kept so any day still cached under that
class renders until the derived store recomputes; the chart's percentile fan also
keeps it for a smooth gradient.

Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-20 04:38:30 +00:00
Emi Griffith
6e41d0b692 Merge the Trace rain tier into Very Light (#212)
The rain-intensity scale had a Trace tier (below the 1st percentile of a place's
rain days) sitting under Very Light — a sliver category that mostly showed 1% and
crowded the distribution strip. Fold it into Very Light, which now bottoms out the
scale at 0, so the lightest measurable rain reads as Very Light.

- grading.py: RAIN_BANDS drops the Trace band; Very Light's floor goes 1 -> 0. The
  detail-view ladder derives from the table, so it follows automatically.
- shared.js: SCALE_RAIN drops the Trace row; Very Light's range becomes "<10".

Eight rain tiers now instead of nine; the strip shows one fewer column. The wet-1
colour token is kept (unreferenced by new gradings) so any day still cached with
the old class renders until the derived store recomputes.

Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-20 04:25:13 +00:00
Emi Griffith
ef61308a9e 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
Emi Griffith
57dfbe109e Report rain in whole millimetres; lift tier spans out of the bars (#192)
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
2026-07-19 19:28:58 +00:00
Emi Griffith
b2612ab1ca Show rain in centimetres rather than millimetres on metric pages (#191)
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
2026-07-19 19:10:24 +00:00
Emi Griffith
b1c6b94765 Follow the unit system for precipitation and wind too (#190)
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
2026-07-19 19:03:41 +00:00
Emi Griffith
086a301387 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
Emi Griffith
3fbaffd876 Distribution bars: split the in-bar range into Max/Min rows (#84)
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.
2026-07-12 18:32:38 +00:00
Emi Griffith
ee9c9b3045 Season full-year option; metric select into mobile sheet; comfort inline on compare (#81)
- 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.
2026-07-12 07:11:21 +00:00
Emi Griffith
f3ba056738 Compare/calendar mobile polish: overflow fix, sheet load button, 6-yr default, country in names (#80)
- 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.
2026-07-12 06:32:10 +00:00
Emi Griffith
d38bf0192c Add a season/month time filter + mobile filter sheet (compare & calendar) (#79)
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.
2026-07-12 05:33:09 +00:00
Emi Griffith
e11fc73f46 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
Emi Griffith
2662fa38e5 Compare: comfort range slicer + per-location climate distribution (#62)
* 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
2026-07-11 22:33:38 +00:00
4d3a5a1053 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
a251b0df23 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