Commit graph

489 commits

Author SHA1 Message Date
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
bc1aac3424 Compare: temperature markers over slider handles; split range labels (#75)
Make the comfort/tolerance slider self-explanatory and fix its labeling.

- A temperature marker sits above each of the four handles (the band
  borders) — the two comfort bounds and the two tolerance bounds — so the
  numbers read straight off the track instead of a combined caption.
- The header is now two disparate labels, "Comfort lo–hi" and "Tolerance
  tlo–thi", rather than the old "Comfort & tolerance 16–22 · tolerance
  4–27" that mislabeled the comfort range.
- Distribution-bar key: on desktop each category label is positioned at its
  segment's midpoint (ends pinned to the bar edges) so the labels line up
  under the colors they describe; on mobile it stays a plain wrapping row.
2026-07-12 02:23:28 +00:00
Emi Griffith
9f3c2caabc Compare: label every bar segment with a key; drop the colored caption (#73)
The bar showed each segment's share but not what it meant, leaning on the
coloured below/above-comfort text to explain it. Replace that text with a
per-card key directly under the bar: a colour swatch + plain name + share for
every bucket present (Too cold / Cool / Comfortable / Warm / Too hot), in the
same cold→hot order as the bar. Every span is spelled out, so a 1% sliver is
as clear as a 60% block, and small segments no longer need to fit a label
inside them. Bar segments also carry a hover title. The redundant top legend
is removed (each card now names its own colours), and the leftover summary
line keeps just the tolerance share and typical miss.
2026-07-12 01:49:22 +00:00
Emi Griffith
46c5006ce0 Compare: replace diverging bar with a labeled day-distribution bar (#71)
The diverging comfort bar read as noise — half the track sat empty and the
lighter/saturated tolerance split was unlabeled, so the coloured chunks had
no clear meaning. Replace it with one full-width bar that is 100% of the
place's days, split into five temperature buckets ordered cold → hot in the
site's own temperature colours: too cold, cool (within tolerance),
comfortable, warm (within tolerance), too hot. Wide segments print their
share inline, and a legend above the cards names every colour. More blue =
runs cold, more red = runs hot, with no empty space to decode.
2026-07-12 00:49:06 +00:00
Emi Griffith
428dfebe83 Compare: match-score ranking, tolerance range, fix Feels basis (#69)
Ranking by raw comfort% tied extreme places (Death Valley ranked #2 just
because 26% of days happened to land in-band). Replace it with a weighted
score that folds in tolerance days and miss magnitude, add an outer
tolerance range, and fix the Feels basis.

- Match score (0–100): each day scores full credit inside comfort [lo,hi],
  partial credit inside the tolerance range (ramped 1→0 by how far past the
  comfort edge it lands), zero beyond tolerance. Rank by the mean; tie-break
  by smaller typical miss. Death Valley's hot days now score ~0 so it drops
  to last. The score is the card's headline number.
- Tolerance range [tlo, thi] as an outer dual-thumb pair on the same track
  (four handles, tlo ≤ lo ≤ hi ≤ thi), persisted + in the share hash.
  Defaults to comfort widened 10° each side. The diverging bar now splits
  each side into a within-tolerance band (lighter, near centre) and a
  beyond-tolerance band (saturated), and the baseline strip shades the
  tolerance range faintly around the comfort band.
- Feels basis now judges the felt daytime high (fmax) instead of the
  combined `feels` metric, which reports whichever apparent extreme is
  furthest from 65°F — in mild climates that flips to the cold overnight
  low in winter and made comfortable days nearly vanish.
2026-07-12 00:25:36 +00:00
Emi Griffith
b49489e730 Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.

- Diverging comfort bar per place: colder days grow left of a fixed
  centre comfort marker, warmer days grow right, both on one 0–100%
  scale — a cold-skewed place and a hot-skewed one mirror each other and
  compare directly. Replaces the left-anchored stacked bar + 3-up stat
  grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
  one shared °F axis with the comfort range shaded, plus a middle-80%
  (p10–p90) spread, so baselines line up side by side. computeStats now
  also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
  "Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
  dot on pending chips, an inline spinner in the header, and shimmering
  skeleton cards for still-loading places (previously only muted text —
  the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +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
Emi Griffith
c65ee15c0d Raise the precip "Moderate" label further (#65)
-0.85em still grazed the neighbouring Light–Mod/Mod–Heavy labels on mobile;
lift to -1.4em so it sits clearly above the cluster.
2026-07-11 22:33:26 +00:00
Emi Griffith
cfc8cd0b92 Lift crowded "Moderate" label in the precip strip (#61)
On narrow screens the single-word "Moderate" label sits between the two-line
"Light–Mod" and "Mod–Heavy" labels; its centered box is wider than the column
and spilled over their first line. Tag each rain column with its tier key and
nudge the "Moderate" (wet-5) label up into the empty gap above to clear it.
2026-07-11 22:26:29 +00:00
Emi Griffith
f4109f9ede Scope calendar strip percentages to their bar group (#59)
Following the per-group bar heights, the percentage labels now match: a
multi-bar group (rain intensities on precip, streak lengths on dry streak)
shows each bar's share within that group, so its bars read as a distribution.
The lone opposing column (Dry on precip, Rain day on dry streak) keeps its
share of all shown days, which stays meaningful rather than trivially 100%.
2026-07-11 22:11:21 +00:00
Emi Griffith
79d755c16e Scale wet and dry calendar bars independently (#57)
In the calendar category strip, precip (Dry + rain intensity) and dry-streak
(Rain day + dry buckets) mix wet and dry categories. Heights scaled to the
single tallest bucket across both, so a dominant dry-day count flattened the
rain-intensity bars to ~2px and vice versa, hiding the opposing distribution.

Give each bucket a scale group (wet/dry) and size bars to the tallest bucket
within their own group. Temperature tiers share one group, so they scale
together as before.
2026-07-11 14:59:15 -07:00
Emi Griffith
29302624a7 Make the compare page unit-aware so the °F/°C toggle works there (#55)
compare.js never imported units.js, so the header unit toggle was never
built on the compare page and every temperature was stuck in °F. Import
units.js (which builds the toggle) and route the comfort target, comfort
band, and the colder/warmer/typical-miss figures through the unit
formatters, re-rendering on unit change. State stays in °F (the API's
unit); only the displayed numbers convert.

Add fmtDelta for degree *differences* (band, average miss), which scale
by 5/9 without the 32° offset that absolute temperatures use.
2026-07-11 21:48:21 +00:00
Emi Griffith
94dde1ebc6 Merge pull request #52 from griffemi/fix/day-mobile-overflow
Day page: stop metric cards overflowing the viewport on phones
2026-07-11 14:21:16 -07:00
a05809f39d Fix mobile map picker footer cut off by iOS Safari chrome (#53)
On phones the location-picker modal is a full-screen sheet sized with
height: 100vh. On iOS Safari 100vh is the large-viewport height (measured
as if the browser toolbars were retracted), so while the toolbars are
showing the bottom of the sheet — the footer with the 'Use this location'
button — falls behind Safari's chrome and is cut off.

Size the sheet with 100dvh (dynamic viewport height), which tracks the
currently-visible height. The flex map yields the space so the footer
stays on screen. 100vh is kept as a fallback for older engines, and the
footer gains safe-area-inset-bottom padding to clear the home indicator
once the bottom toolbar retracts.
2026-07-11 21:17:04 +00:00
Emi Griffith
e5b1bb0fc2 Day page: stop metric cards overflowing the viewport on phones
The #day-body card grid used bare 1fr columns, whose minimum track size is
the widest card's min-content. The humidity ladder's nowrap value ranges
("17.3 g/m³–19.9 g/m³") pushed that minimum past the viewport on phones —
the page scrolled sideways 21px at 390px wide and 51px at 360px — so the
whole Day view sat off-center.

- Size #day-body columns with minmax(0, 1fr) so card content can never
  widen the page canvas.
- Collapse the unit repeated on both range bounds ("17.3–19.9 g/m³") and
  drop it from the observed ◀ marker; the unit still reads from the card
  summary, median, and historical range.
- Let .ladder-val wrap as a last resort instead of nowrap-widening the row.
- Rebalance the mobile ladder tracks (pct 54→46, marker 48→42) so the
  longest range stays on one line down to ~375px screens; narrower than
  that it wraps cleanly at the unit.
2026-07-11 14:13:05 -07:00
46c90914b3 Warm neighbor cells server-side (/cell?neighbors=1) (#51)
cache.js contained a JavaScript clone of backend/grid.py's snapping math
(its own comment said so) to compute the 8 surrounding cells and fire 8
staggered prefetch requests — grid geometry had two homes, one per
language, plus a client-side guess at Nominatim pacing.

The server now owns it: grid.neighbors(cell) steps one cell width from
the center and re-snaps (adjacent rows have different longitude steps;
poles and the antimeridian handled by snap), and /api/v2/cell grew a
neighbors=1 flag that enqueues those cells for a single background
worker. The warm-only guarantee matches prefetch=1 — a cell with no
cached archive is skipped, so no weather-API quota is ever spent — and
reverse_geocode's own lock paces the at-most-one Nominatim call per
never-labeled cell. Re-enqueues are TTL-deduped; the worker starts from
the lifespan hook, so tests and offline importers never spawn it.

The client now sends its one conditional bundle request with
neighbors=1 (a warm spot costs an empty 304) instead of skipping the
bundle and firing 8 extra requests; the grid-math clone and the
now-unused hasFreshCache are deleted.

Tests (114): grid.neighbors mid-latitude/pole/antimeridian, the
neighbors=1 enqueue + TTL dedupe, _warm_cell materializing the
history-derived store rows, and the never-fetch-upstream guarantee.
Verified with the headless-Chromium smoke across all five pages.
2026-07-11 20:47:31 +00:00
431457132a Warm neighbor cells server-side (/cell?neighbors=1) (#51)
cache.js contained a JavaScript clone of backend/grid.py's snapping math
(its own comment said so) to compute the 8 surrounding cells and fire 8
staggered prefetch requests — grid geometry had two homes, one per
language, plus a client-side guess at Nominatim pacing.

The server now owns it: grid.neighbors(cell) steps one cell width from
the center and re-snaps (adjacent rows have different longitude steps;
poles and the antimeridian handled by snap), and /api/v2/cell grew a
neighbors=1 flag that enqueues those cells for a single background
worker. The warm-only guarantee matches prefetch=1 — a cell with no
cached archive is skipped, so no weather-API quota is ever spent — and
reverse_geocode's own lock paces the at-most-one Nominatim call per
never-labeled cell. Re-enqueues are TTL-deduped; the worker starts from
the lifespan hook, so tests and offline importers never spawn it.

The client now sends its one conditional bundle request with
neighbors=1 (a warm spot costs an empty 304) instead of skipping the
bundle and firing 8 extra requests; the grid-math clone and the
now-unused hasFreshCache are deleted.

Tests (114): grid.neighbors mid-latitude/pole/antimeridian, the
neighbors=1 enqueue + TTL dedupe, _warm_cell materializing the
history-derived store rows, and the never-fetch-upstream guarantee.
Verified with the headless-Chromium smoke across all five pages.
2026-07-11 20:47:31 +00:00
b7bad50e96 CSS: delete dead selectors, one mobile block, shared control recipes (#50)
- Deleted the rules for elements that no longer exist (verified by
  auditing every style.css class selector against all class usage in
  HTML/JS, including dynamically-built names): the pre-modal inline
  search/suggestions, the old two-column .layout + inline #map, the
  .panel fixed-height scroller (+ its now-pointless .panel-full
  neutralizer and 900px escape), .share-btn, the pre-dropdown calendar
  filter chips, .series-toggle, the .grade.danger/.d-rec-* set and the
  bare .legend block. Leaflet's classes stay (styled runtime DOM).
- The four scattered max-width:640px blocks are now one block at the end
  of the sheet, where mobile overrides belong.
- The accent-button recipe (5 copies) and the 16px text-input recipe
  (4 copies) are each one grouped rule; components keep only their
  deltas. The iOS zoom-on-focus rule now has a single documented home.
  (The consolidation also restored the .date-row button rule that the
  dead-search deletion would otherwise have taken with it.)

993 -> 919 lines. Verified pixel-identical: full-page screenshots of all
five pages at 390/800/1280px and the opened picker modal are
byte-for-byte equal before/after (headless Chromium).
2026-07-11 20:42:41 +00:00
591f5ec495 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
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
7aaad17603 Deduplicate the data-layer plumbing in climate.py and grading.py (#46)
climate.py repeated the same four mechanical patterns:
- the Open-Meteo daily params dict (3x) -> _om_daily_params(cell, **window)
- the doy attach line (6x) -> _with_doy
- makedirs + drop-doy + zstd to_parquet (3x) -> _write_cache
- the identical _to_frame/_nasa_to_frame tail (feels-like, valid-day
  filter, doy) -> _finalize_frame

grading.py encoded the tier tables twice — TEMP_BANDS/RAIN_BANDS plus the
hand-aligned _TEMP_LADDER/_RAIN_LADDER ('kept aligned' by comment). The
ladders are now derived from the bands (_ladder_from; verified
byte-identical to the old tables before landing), so tier boundaries have
exactly one definition. grade_range's inline dry-streak walk is replaced
with the existing dry_streaks(); its per-(doy,var) sample cache now also
memoizes the window mask per doy instead of recomputing it once per
metric (9x per day-of-year).

New tests pin the refactor: _to_frame schema/day-filter/missing-series
tolerance, the combined feels-like side selection, NASA unit conversions
and fill-sentinel handling, _om_daily_params windows, and _write_cache
stripping the derived doy column.
2026-07-11 20:05:57 +00:00
b702e019d5 Move the suggestion policy into places.py (#45)
api_suggest carried ~80 lines of ranking policy — the exactness-boosted
scoring, local/upstream blending with dedupe, and the token-respelling
correction loop — all consuming the match: prefix/fuzzy vocabulary that
places.py produces. Producer and consumer now live together:
places.suggest(q, upstream, limit) implements the whole policy with the
upstream geocoder lookup injected as a callable, so places stays free of
the fetch layer and the policy is testable without FastAPI.

The endpoint is the HTTP shim: call places.suggest, map the
nothing-at-all-to-serve failure to a 502. Behavior unchanged.

Policy tests: upstream skipped when the local answer convinces, blend +
dedupe, the exactness boost (a hamlet spelled like the typo can't beat
Seattle), single-typo queries answered by the fuzzy matcher without a
correction, the correction path verified via the upstream probe,
upstream failure degrading to local results, and the terminal
error-propagation case.
2026-07-11 20:00:24 +00:00
7c5b5137f1 Classify weather-fetch failures with a typed exception (#44)
Rate-limit handling crossed the climate→app boundary as prose: climate
raised RuntimeError(cooldown text), and app._weather_fetch_error re-parsed
it by keyword ('429'/'rate-limited'/'daily'/'tomorrow') while also calling
climate's private _is_rate_limit/_rate_limit_reason — and the user-facing
daily-quota copy existed verbatim in both modules.

climate now raises WeatherUnavailable (a RuntimeError subclass carrying
the user-facing text and a daily flag): from _load_history when both
sources fail with no stale cache, and from the forecast fetch on a 429.
limit_message() is the single home of the rate-limit copy; is_rate_limit
is public for the one remaining raw-429 fallback. app maps the typed
error to 503 by isinstance — no message parsing, no private imports.

The burst-limit copy is unified on 'The weather service is rate-limited…'
(the archive path previously said 'weather archive' internally but the
API always rewrote it; user-visible text is unchanged).

Tests: daily-quota 503 carries the 'tomorrow' copy, an unclassified raw
429 still maps to a clean 503, and a genuine fault stays a 502 with the
raw error.
2026-07-11 19:53:46 +00:00
7989f142a1 Single-source cache identity; shared fetch preamble and cache flow (#43)
The derived store's key/token formats existed in three places — each
endpoint, api_cell's slice assembly, and migrate.py — where any drift
would silently split the cache (endpoints missing rows the bundle wrote,
migrate materializing rows nobody reads). They are now defined once in
views.py (grade_key/calendar_key/day_key/forecast_key, history_token/
recent_token/day_token) and consumed everywhere, with a pinning test so
a format change is always deliberate.

The four data endpoints shared two copy-pasted sequences, now helpers:

- _fetch_history: history (+ optional recent bundle) fetch with upstream
  failures mapped to clean HTTP errors and an empty record to 404.
- _cached_response: If-None-Match 304 / token-valid store replay /
  build + persist + serve, with calendar's dont-persist-placeless rule
  as an explicit flag.

Each endpoint is now its audit run + identity + a build callback (~10
lines); api_day's hourly-token special case moved into day_token. New
tests: identity format pins, rate-limit 503 parametrized across all five
data routes, prefetch=1 never touching upstream (cold 204, warm
history-only slices), and calendar's placeless-payload retry behavior.
2026-07-11 19:49:15 +00:00
38a39df6ab Extract payload builders into views.py; load places index at startup (#42)
app.py had grown three co-resident strata: HTTP/caching plumbing, payload
assembly, and search policy. This moves the payload layer — the four
build_* functions, cal_span clamping, hist_end, PAYLOAD_VER, NullRun and
their helpers — into a new views.py with no web dependencies (pure moves,
public names). app.py keeps routing, ETag/derived-store plumbing, and
page serving; migrate.py imports views instead of reaching into app's
privates.

That import previously constructed the whole FastAPI app and — because
places.start_loading() ran at import time — kicked off a background
GeoNames download from an offline batch script. The index load now hangs
off the app's lifespan hook, so it fires when a server starts, not when
the module is imported (tests, migrate).

New tests: cal_span clamping (months-back default, record bounds, 2-year
cap, inversion), builder shapes (grade window ordering, day obs from the
recent bundle, forecast future-only), a regression test that build_day
degrades to climatology when the recent fetch fails, and a subprocess
layering guard that importing views/migrate pulls in neither FastAPI nor
the app module and starts no index download.
2026-07-11 19:43:41 +00:00
4ac5323375 Add backend test suite; gate direct pushes; serialize LAN deploys (#41)
- backend/tests: 74 hermetic tests (no network, no repo data//logs/ writes)
  covering grid snapping/round-trips, grading percentiles/bands/windows/
  dry streaks, the places index (norm, one-edit matchers, search,
  corrections), the derived store (token validity, cache=False, degraded
  mode), and route-level API tests over a faked climate layer — routing,
  validation, ETag/304 revalidation, store replay, the /cell bundle, and
  the v1/v2 aliases. The API tests would have caught the /place
  AttributeError regression.
- requirements-dev.txt + make test (venv prefers uv-pinned 3.12, matching
  deploy-dev.sh — pyarrow wheels stop at 3.12 and some pyenv builds lack
  sqlite).
- CI: extract the build job into a reusable build.yml, add the test run
  and an API health probe (page-only curl can't catch route wiring
  faults); deploy-dev.yml now runs the same build gate before deploying
  direct pushes, which previously deployed with no CI at all.
- Deploys serialize under one dev-lan-deploy concurrency group across
  both workflows (previously per-PR groups could interleave two deploys
  to the same checkout), and are never cancelled mid-restart.
- deploy-dev.sh health check also probes /api/v2/place — best-effort
  externals mean a failure there is a genuine server bug.
2026-07-11 19:37:49 +00:00
a3c22ef6d7 Fix /place 500s, /day rate-limit handling, calendar listener leak, error surfacing (#40)
- /api/v2/place still called grid.in_north_america(), which the worldwide-
  coverage change removed — every request raised AttributeError (500). The
  failure was invisible because compare.js treats the call as best-effort,
  so the instant chip-naming feature was silently dead. Drop the dead guard;
  the lat/lon Query validators already bound the inputs.
- /api/v2/day was the only data route not wrapping get_history in
  _weather_fetch_error, so a rate-limited cold cell returned a raw 500
  instead of the clean 503 the other routes emit.
- calendar: the #calendar pointerleave handler was re-registered inside
  attachHover on every render — and the comfort slider re-renders per input
  tick, stacking dozens of copies. Register it once at module scope next to
  the matching document-level dismiss handler.
- getJSON: check res.ok before parsing the body as JSON and fall back to the
  status line, so a non-JSON error body (a proxy 502 HTML page) reads as
  "Request failed (502 Bad Gateway)" instead of a JSON parse error.
2026-07-11 19:26:42 +00:00
686ac7afaf Fix /place 500s, /day rate-limit handling, calendar listener leak, error surfacing (#40)
- /api/v2/place still called grid.in_north_america(), which the worldwide-
  coverage change removed — every request raised AttributeError (500). The
  failure was invisible because compare.js treats the call as best-effort,
  so the instant chip-naming feature was silently dead. Drop the dead guard;
  the lat/lon Query validators already bound the inputs.
- /api/v2/day was the only data route not wrapping get_history in
  _weather_fetch_error, so a rate-limited cold cell returned a raw 500
  instead of the clean 503 the other routes emit.
- calendar: the #calendar pointerleave handler was re-registered inside
  attachHover on every render — and the comfort slider re-renders per input
  tick, stacking dozens of copies. Register it once at module scope next to
  the matching document-level dismiss handler.
- getJSON: check res.ok before parsing the body as JSON and fall back to the
  status line, so a non-JSON error body (a proxy 502 HTML page) reads as
  "Request failed (502 Bad Gateway)" instead of a JSON parse error.
2026-07-11 19:26:42 +00:00
efd43dcbb5 Center the metric selectors and category-totals strip on desktop (#38)
- Calendar: the 'Color the calendar by' selector shrinks to its tabs and sits
  centered over the month grid beyond phone widths (scoped under .metric-block
  so the Compare basis toggle keeps filling its param cell).
- Calendar: the category-totals strip widens to 960px and centers in the
  content column.
- Weekly: the chart metric switch likewise shrinks to its tabs and centers
  over the trend chart. Phone layouts (full-width 4-per-row toggles) are
  untouched.
2026-07-11 19:04:30 +00:00
c12b5953c0 Large-monitor layouts + compact CTA, comfort-slider fix, month-picker on click (#37)
- Widen the content column in steps at 1680/2400/3400px so 1440p-4K monitors
  get real layout instead of a 1200px strip; header brand aligns with it.
- Calendar: months flow as a responsive multi-column grid (year-at-a-glance
  on desktop, single column on phones); the time-filter panel spans the
  content width as one row (Range | Seasons/Years/Weather) on desktop;
  category-totals strip widened to 720px.
- Day: metric ladder cards lay out 2-up from 1000px (3-up at 4K).
- Weekly: the calendar CTA is a compact button (sub-line removed); the trend
  chart's drawing width now tracks its container (~1.5 CSS px per SVG unit,
  redrawn on resize) so wide screens gain plot room instead of magnification.
- Compare: scope the rank-bar segment colors to .cmp-seg — the bare
  .cmp-comfort rule was painting a green box behind the comfort slider.
- Calendar/Compare From-To month inputs open the native month picker on
  click (input.showPicker, ignored where unsupported).
- CLAUDE.md: require validating design changes at mobile/tablet/1080p/2K/4K.
2026-07-11 18:47:52 +00:00
09e96a2eaf Link previews: Open Graph tags + logo for shared URLs (#35)
Sharing a Thermograph URL (Discord, Slack, iMessage…) now unfurls into a
card with the site name, page title, description, accent color, and logo.

- All five pages get description/theme-color/og:*/twitter:card meta and a
  favicon. og:url/og:image need absolute URLs and crawlers don't run JS, so
  pages carry an __ORIGIN__ placeholder the server fills in per request from
  X-Forwarded-Proto (Caddy) / scheme + the Host header + base path — correct
  on both the LAN dev server and the prod domain without hardcoding either.
- New logo assets: the header's ▚ mark as an app icon (accent quadrants on
  the dark surface) — logo.png (512x512, the og:image) and logo.svg (favicon).
- Page routes render the substitution with a weak ETag + 304 revalidation
  (replacing plain FileResponse) and now answer HEAD, which preview crawlers
  probe with (previously 404).
2026-07-11 15:59:14 +00:00
85e4e08f96 Link previews: Open Graph tags + logo for shared URLs (#35)
Sharing a Thermograph URL (Discord, Slack, iMessage…) now unfurls into a
card with the site name, page title, description, accent color, and logo.

- All five pages get description/theme-color/og:*/twitter:card meta and a
  favicon. og:url/og:image need absolute URLs and crawlers don't run JS, so
  pages carry an __ORIGIN__ placeholder the server fills in per request from
  X-Forwarded-Proto (Caddy) / scheme + the Host header + base path — correct
  on both the LAN dev server and the prod domain without hardcoding either.
- New logo assets: the header's ▚ mark as an app icon (accent quadrants on
  the dark surface) — logo.png (512x512, the og:image) and logo.svg (favicon).
- Page routes render the substitution with a weak ETag + 304 revalidation
  (replacing plain FileResponse) and now answer HEAD, which preview crawlers
  probe with (previously 404).
2026-07-11 15:59:14 +00:00
f149c8fd4f Typo-tolerant location search suggestions (#33)
Add /api/v2/suggest and wire the location picker's search box to it as a
debounced type-ahead: top-5 place suggestions that tolerate a single-letter
typo (substituted, missing, or extra letter, or two adjacent letters swapped)
anywhere in the query, including the first character.

- backend/places.py: local place index built from a GeoNames cities dump
  (downloaded once into data/geonames/, loaded in a background thread; the
  app boots and serves without it). Exact-prefix matches rank first by
  population, then names one edit away; a token vocabulary respells one
  mistyped word against known place-name tokens ("pest seattle" ->
  "west seattle") for retry against the upstream geocoder, which covers
  neighbourhood-level places the dump lacks. THERMOGRAPH_CITIES picks the
  dump (default cities1000).
- /suggest blends local and upstream results by population with an exactness
  boost, so "Seatle" (a Cumbrian hamlet) can't outrank Seattle when the
  query is one edit from the city, while "munchen" still surfaces Munich via
  upstream (the index only knows English names). Upstream lookups are
  memoized and skipped entirely when the index answers convincingly.
- mappicker.js: debounced (250ms) live suggestions with abort + sequence
  guards against stale responses, arrow-key navigation, Enter-picks-highlight,
  Escape dismissing the list before closing the overlay. Submit goes through
  the same typo-tolerant endpoint.
- climate.geocode results now carry population (used for ranking).
2026-07-11 15:36:14 +00:00
e3088720a6 Typo-tolerant location search suggestions (#33)
Add /api/v2/suggest and wire the location picker's search box to it as a
debounced type-ahead: top-5 place suggestions that tolerate a single-letter
typo (substituted, missing, or extra letter, or two adjacent letters swapped)
anywhere in the query, including the first character.

- backend/places.py: local place index built from a GeoNames cities dump
  (downloaded once into data/geonames/, loaded in a background thread; the
  app boots and serves without it). Exact-prefix matches rank first by
  population, then names one edit away; a token vocabulary respells one
  mistyped word against known place-name tokens ("pest seattle" ->
  "west seattle") for retry against the upstream geocoder, which covers
  neighbourhood-level places the dump lacks. THERMOGRAPH_CITIES picks the
  dump (default cities1000).
- /suggest blends local and upstream results by population with an exactness
  boost, so "Seatle" (a Cumbrian hamlet) can't outrank Seattle when the
  query is one edit from the city, while "munchen" still surfaces Munich via
  upstream (the index only knows English names). Upstream lookups are
  memoized and skipped entirely when the index answers convincingly.
- mappicker.js: debounced (250ms) live suggestions with abort + sequence
  guards against stale responses, arrow-key navigation, Enter-picks-highlight,
  Escape dismissing the list before closing the overlay. Submit goes through
  the same typo-tolerant endpoint.
- climate.geocode results now carry population (used for ranking).
2026-07-11 15:36:14 +00:00
e819079bda Worldwide coverage: grade any point on Earth (#32)
Remove the US+Canada bounding box so every endpoint accepts any lat/lon.
The grading pipeline was already global-ready (ERA5 archive, timezone=auto,
day-of-year climatology), so opening it up is mostly deleting the guard —
plus the edge cases that only exist once the whole globe is in play:

- grid.py: snap() wraps longitude into [-180, 180) and clamps latitude, and
  cell centers are normalized so the polar row and the cells straddling the
  antimeridian always report valid coordinates to the weather/geocoding APIs.
  snap() and from_id() now share one _cell() builder, making id round-trips
  exact by construction (verified with a 300k-point global sweep).
- nav.js: neighbor-cell prefetch skips rows past the poles and wraps
  longitudes across the dateline instead of sending out-of-range queries.
- Nominatim reverse geocoding requests accept-language=en so place labels
  render in one script worldwide (matching the forward geocoder).
- mappicker: search suggestions are no longer filtered to US/CA, the
  placeholder and default map view are worldwide.
- calendar: season filter labels flip for southern-hemisphere locations
  (Dec-Feb shows as Summer); the underlying month groups are unchanged, so
  saved filter selections keep meaning the same months.

Verified end-to-end on a scratch server: Tokyo and Sydney grade with real
labels, a Fiji cell on the antimeridian's east edge builds and serves warm
hits from the derived store, and prefetch=1 on a cold cell still answers
204 without spending weather-API quota.
2026-07-11 15:02:28 +00:00
475e0ee7a2 Worldwide coverage: grade any point on Earth (#32)
Remove the US+Canada bounding box so every endpoint accepts any lat/lon.
The grading pipeline was already global-ready (ERA5 archive, timezone=auto,
day-of-year climatology), so opening it up is mostly deleting the guard —
plus the edge cases that only exist once the whole globe is in play:

- grid.py: snap() wraps longitude into [-180, 180) and clamps latitude, and
  cell centers are normalized so the polar row and the cells straddling the
  antimeridian always report valid coordinates to the weather/geocoding APIs.
  snap() and from_id() now share one _cell() builder, making id round-trips
  exact by construction (verified with a 300k-point global sweep).
- nav.js: neighbor-cell prefetch skips rows past the poles and wraps
  longitudes across the dateline instead of sending out-of-range queries.
- Nominatim reverse geocoding requests accept-language=en so place labels
  render in one script worldwide (matching the forward geocoder).
- mappicker: search suggestions are no longer filtered to US/CA, the
  placeholder and default map view are worldwide.
- calendar: season filter labels flip for southern-hemisphere locations
  (Dec-Feb shows as Summer); the underlying month groups are unchanged, so
  saved filter selections keep meaning the same months.

Verified end-to-end on a scratch server: Tokyo and Sydney grade with real
labels, a Fiji cell on the antimeridian's east edge builds and serves warm
hits from the derived store, and prefetch=1 on a cold cell still answers
204 without spending weather-API quota.
2026-07-11 15:02:28 +00:00
36db92022e Weekly view: center the daily-graded window on the target day (#28)
* Pin the °F/°C toggle to the header's top-right on all widths

The unit toggle sat inside .brand after a flex-basis:auto title block, so on
narrow (phone) widths the long tagline claimed the first row and bumped the
toggle onto its own line mid-header. Give the title block flex:1 1 0 with
min-width:0 so it contributes ~nothing to the wrap decision and shrinks
instead — keeping the toggle on the logo's row, top-right, with the tagline
wrapping beneath and the view tabs on their own row below.

* Weekly view: center the daily-graded window on the target day

Reframe the "Daily, graded" chart + table on the weekly view around the
selected target day instead of ending at it:

- Show two weeks of history before the target, the target itself, then up
  to 7 days after it. Days after the target are real observations when they
  are already in the past and the forward forecast when they run into the
  future.
- Mark the target day with a solid orange line on the chart (and an accent
  edge on its table column); draw the dashed "forecast →" divider only where
  the window actually crosses into the future and it is separated from the
  target marker.

Backend: _build_grade now grades a [target-days, target+after] window built
from both the archive and the recent+forecast bundle (the bundle wins per
date; the archive fills older gaps), so any past target renders its
surrounding week. api/grade gains an `after` param (default 7) and the cache
key includes it; the /cell prefetch bundle builds the matching slice.

Frontend: the chart consumes the server-built window directly — the separate
forecast fetch and gap-merge are gone. The target is looked up by date for
the comparison cards (the newest row is now a forecast day), and the graded
table opens centered on the target column.

* Weekly view: extend the after-target window to 14 days

Bump the daily-graded window's after-target span from 7 to 14 days. A target
far enough in the past now shows 14 observed days after it; a recent target
shows its observed days plus the 1-7 available forecast days, and the forecast
naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
2f38fb8b51 Weekly view: center the daily-graded window on the target day (#28)
* Pin the °F/°C toggle to the header's top-right on all widths

The unit toggle sat inside .brand after a flex-basis:auto title block, so on
narrow (phone) widths the long tagline claimed the first row and bumped the
toggle onto its own line mid-header. Give the title block flex:1 1 0 with
min-width:0 so it contributes ~nothing to the wrap decision and shrinks
instead — keeping the toggle on the logo's row, top-right, with the tagline
wrapping beneath and the view tabs on their own row below.

* Weekly view: center the daily-graded window on the target day

Reframe the "Daily, graded" chart + table on the weekly view around the
selected target day instead of ending at it:

- Show two weeks of history before the target, the target itself, then up
  to 7 days after it. Days after the target are real observations when they
  are already in the past and the forward forecast when they run into the
  future.
- Mark the target day with a solid orange line on the chart (and an accent
  edge on its table column); draw the dashed "forecast →" divider only where
  the window actually crosses into the future and it is separated from the
  target marker.

Backend: _build_grade now grades a [target-days, target+after] window built
from both the archive and the recent+forecast bundle (the bundle wins per
date; the archive fills older gaps), so any past target renders its
surrounding week. api/grade gains an `after` param (default 7) and the cache
key includes it; the /cell prefetch bundle builds the matching slice.

Frontend: the chart consumes the server-built window directly — the separate
forecast fetch and gap-merge are gone. The target is looked up by date for
the comparison cards (the newest row is now a forecast day), and the graded
table opens centered on the target column.

* Weekly view: extend the after-target window to 14 days

Bump the daily-graded window's after-target span from 7 to 14 days. A target
far enough in the past now shows 14 observed days after it; a recent target
shows its observed days plus the 1-7 available forecast days, and the forecast
naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
d62c3cd61d Merge pull request #30 from griffemi/feat-compare-place-onadd
Compare: resolve location name immediately on add
2026-07-11 07:51:21 -07:00
bec0af3bcf Merge pull request #30 from griffemi/feat-compare-place-onadd
Compare: resolve location name immediately on add
2026-07-11 07:51:21 -07:00
Emi Griffith
b0f0426b00 Compare: resolve a location's name immediately on add
Add a lightweight GET /api/v2/place?lat=&lon= endpoint that snaps to the grid
cell and returns the reverse-geocoded "neighborhood, city, region" label (the
same string the view endpoints expose as place, cached + throttled). On the
compare page, adding a location now fires this right away so the chip flips from
coordinates to the place name immediately — instead of only resolving when the
full comparison loads on Refresh. Best-effort: the series load still resolves the
name, so a failed/again call is harmless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc
2026-07-11 07:50:48 -07:00
Emi Griffith
608b245598 Compare: resolve a location's name immediately on add
Add a lightweight GET /api/v2/place?lat=&lon= endpoint that snaps to the grid
cell and returns the reverse-geocoded "neighborhood, city, region" label (the
same string the view endpoints expose as place, cached + throttled). On the
compare page, adding a location now fires this right away so the chip flips from
coordinates to the place name immediately — instead of only resolving when the
full comparison loads on Refresh. Best-effort: the series load still resolves the
name, so a failed/again call is harmless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc
2026-07-11 07:50:48 -07:00
7358d9eb48 Fix compare place names (Nominatim burst) + weekly duplicate location name (#29)
Compare was the only page loading several cells at once, so its concurrent
reverse-geocode calls burst past Nominatim's ~1 req/sec limit, got rate-limited,
and cached a null label — leaving those locations stuck on bare coordinates.

- climate.py: serialize + rate-limit reverse_geocode behind a lock (>=1.1s between
  Nominatim calls, re-checking the cache under the lock), so concurrent callers
  resolve reliably instead of bursting.
- store.py: shorten the reverse-geocode miss TTL 1 day -> 1 hour so a transient
  null retries soon; add a `cache` flag to put_payload.
- app.py: don't persist a calendar payload whose place failed to resolve, so the
  coordinates fallback can't stick for the life of the token.

Weekly showed the place name twice (top label beside the Find button AND the
results <h2>). Keep the <h2> (it carries the coords + climatology context) as the
single name display; the top label now only holds the transient coordinates
written on selection and is hidden once the named results render.


Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 09:03:57 +00:00
a1cf72642a Fix compare place names (Nominatim burst) + weekly duplicate location name (#29)
Compare was the only page loading several cells at once, so its concurrent
reverse-geocode calls burst past Nominatim's ~1 req/sec limit, got rate-limited,
and cached a null label — leaving those locations stuck on bare coordinates.

- climate.py: serialize + rate-limit reverse_geocode behind a lock (>=1.1s between
  Nominatim calls, re-checking the cache under the lock), so concurrent callers
  resolve reliably instead of bursting.
- store.py: shorten the reverse-geocode miss TTL 1 day -> 1 hour so a transient
  null retries soon; add a `cache` flag to put_payload.
- app.py: don't persist a calendar payload whose place failed to resolve, so the
  coordinates fallback can't stick for the life of the token.

Weekly showed the place name twice (top label beside the Find button AND the
results <h2>). Keep the <h2> (it carries the coords + climatology context) as the
single name display; the top label now only holds the transient coordinates
written on selection and is hidden once the named results render.


Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 09:03:57 +00:00
052b0a3e26 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
20a4108397 Pin the °F/°C toggle to the header's top-right on all widths (#26)
The unit toggle sat inside .brand after a flex-basis:auto title block, so on
narrow (phone) widths the long tagline claimed the first row and bumped the
toggle onto its own line mid-header. Give the title block flex:1 1 0 with
min-width:0 so it contributes ~nothing to the wrap decision and shrinks
instead — keeping the toggle on the logo's row, top-right, with the tagline
wrapping beneath and the view tabs on their own row below.
2026-07-11 08:23:43 +00:00
25f658b8d3 Merge pull request #25 from griffemi/map-color-boost
Picker map: bigger roads/labels + more color
2026-07-11 01:22:30 -07:00
Emi Griffith
52bfeef8bd Merge remote-tracking branch 'origin/dev' into map-color-boost
# Conflicts:
#	frontend/mappicker.js
#	frontend/style.css
2026-07-11 01:21:50 -07:00