Commit graph

101 commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
Emi Griffith
acaacd492e Picker map: bigger roads/labels, more color (Voyager, upscaled + saturated)
Follow-up to the picker restyle: use the colorful CARTO "Voyager" basemap in
both themes (the monochrome dark-matter tiles read as "no color"), render tiles
one zoom lower at 512px (tileSize 512 + zoomOffset -1, @2x for crispness) so
roads and city labels are noticeably larger, and add a saturate/contrast filter
on the tile pane to push the colors further.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc
2026-07-11 01:19:52 -07:00
34dd577545 Revert Orchid Sunset palette; restyle location-picker map (#24)
* Revert "Merge pull request #22 from griffemi/fem-orchid-palette"

This reverts commit f7d16238f2, reversing
changes made to 34e4fed1f0.

* Restyle location-picker map: themed CARTO basemap + branded pin

Replace the plain gray OpenStreetMap raster in the location picker with a
theme-aware CARTO basemap (no API key): sleek "dark matter" tiles under the
dark theme, colorful "voyager" tiles under the light theme, so the map
matches whichever is active. Swap Leaflet's default blue marker for an
accent-colored teardrop pin (the same glyph the Find button uses), and theme
the zoom controls + attribution to sit in the app's surfaces.

- mappicker.js: theme-aware CARTO tileLayer (retina, CARTO attribution);
  L.divIcon "mp-pin" used for the marker
- style.css: .mp-pin styling, themed .leaflet-bar / attribution, map shadow

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:11:15 +00:00
01e5f48257 Add a °F/°C unit toggle to the header (#23)
A shared units module in nav.js holds the active unit (persisted), converts
Fahrenheit API values for display, and notifies pages to repaint on a switch.
A segmented °F/°C control is injected into the top-right of every header
(left of the view tabs); Weekly, Day and Calendar convert every temperature —
cards, dense timeline, trend chart axis/labels, tooltips and the PNG export —
while wind, humidity and precip stay as-is. Compare keeps its Fahrenheit
comfort slider, so the toggle is hidden there.
2026-07-11 08:10:40 +00:00
Emi Griffith
dade6c3cb4 Restyle location-picker map: themed CARTO basemap + branded pin
Replace the plain gray OpenStreetMap raster in the location picker with a
theme-aware CARTO basemap (no API key): sleek "dark matter" tiles under the
dark theme, colorful "voyager" tiles under the light theme, so the map
matches whichever is active. Swap Leaflet's default blue marker for an
accent-colored teardrop pin (the same glyph the Find button uses), and theme
the zoom controls + attribution to sit in the app's surfaces.

- mappicker.js: theme-aware CARTO tileLayer (retina, CARTO attribution);
  L.divIcon "mp-pin" used for the marker
- style.css: .mp-pin styling, themed .leaflet-bar / attribution, map shadow

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc
2026-07-11 01:10:15 -07:00
Emi Griffith
4b23fda4ec Revert "Merge pull request #22 from griffemi/fem-orchid-palette"
This reverts commit f7d16238f2, reversing
changes made to 34e4fed1f0.
2026-07-11 01:06:30 -07:00
Emi Griffith
6f6870bbf0 Recolor heatmap palette: "Orchid Sunset" (violet → blush → magenta)
Replace the clinical blue→green→red diverging temperature scale, the
green→navy rain ramp, and the orange accent with a warmer, more vibrant
"Orchid Sunset" palette: violet/lavender cool end, soft blush middle,
rose/magenta warm end, hot-pink accent.

Keeps the diverging structure grading relies on (cool reads cold, warm
reads hot) and updates every mirror of the literals so PNG export and the
self-contained subpages stay in sync:
- style.css :root tier + precip vars, accent, cta-cal gradient/shadows,
  dry-metric toggle; light-theme overrides for the near-white middle tiers
  so "Normal" stays legible on white
- app.js TIER_COLORS, chartPalette() fallbacks, dry-streak ramp
- calendar.js TIER_COLORS, PRECIP_COLORS, dry-streak ramp
- day.js TIER_COLORS, PRECIP_COLORS, DRY_COLOR

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc
2026-07-11 00:45:46 -07:00
34e4fed1f0 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
8715a7b464 Metric cards: status to top-right, normal under the value; bold head context (#20)
Move each metric card's grade status into the top-right corner and put the
median on its own 'normal: <value>' line under the big number. Bold the
coordinates, climatology year range and sample-size count in the location
head, and label the sample line 'N days sample size (day ±7)'.

Calendar: drop the tagline and use a compact, roughly half-height header.
2026-07-11 07:20:13 +00:00
c09ec958d6 Stack the location head context top-down; drop sq-mi-cell wording (#19)
The Weekly location card joined coordinates, cell size, climatology range
and sample count into one dot-separated meta line. Stack them as one fact
per line so the important context reads top-down instead of as a run of
tags. Drop the '~N sq mi cell' wording from the Weekly/Day/Calendar meta
lines and the map hint.
2026-07-11 07:10:46 +00:00
313a593a44 Lead location labels with neighbourhood; drop ±7-day window jargon (#18)
Reverse-geocode at zoom 14 and prefer neighbourhood/suburb ahead of the
city so labels read 'Gowanus, New York' when OSM has that detail, falling
back to the city (and county) when it doesn't.

Replace the recurring '±7-day window' phrasing in the UI with plain
language ('a typical day', 'days around this date', 'typical climate').
2026-07-11 00:02:19 -07:00
5faa6b4281 Compare: put full state in the URL and gate reloads behind Refresh (#12)
The whole comparison — locations, comfort target, band, judged temperature
and date range — is now encoded in the URL hash (c/t/b/s/e/loc) and read
back on load, so a link reproduces exactly what's on screen. Comfort, band
and judged-temperature edits still re-rank instantly and update the hash
without a fetch.

Editing the date range or the location set no longer refetches on its own:
it marks the comparison dirty and reveals a single "Refresh comparison"
button next to Add location; the load runs only when it's tapped. Pending
locations show a dashed chip until then. The initial view (from a shared
link, the saved set, or the last-viewed spot) still loads on arrival.
2026-07-11 03:17:17 +00:00
13bc4cf0ac Add comfort-temperature compare view; simplify Feels calendar filter (#9)
Compare page (frontend/compare.{html,js} + /thermograph/compare route):
line up several places over a date range and rank which best matches a
comfort temperature. Per location it pulls the same daily record the
Calendar uses and, per day, takes a chosen temperature (daytime high /
daily mean / overnight low / feels-like) against the comfort target. A
day "hits comfort" when it lands within an adjustable band; otherwise it
counts as colder or warmer and the average miss is tracked. Results are
ranked by comfort-day share (tie-broken by the smaller typical miss),
with a diverging below/comfort/above bar and per-bucket stats. Comfort,
band and judged temperature re-rank instantly client-side; only the
location set or date range trigger a data load (shared calendar cache).

Feels calendar filter: drop the comfort-temperature slider and the
client-side felt-high/felt-low re-pick. The Feels metric now colors by
the server's combined feels-like value like the other metrics, so its
tab matches the rest of the metric selector.
2026-07-10 19:58:56 -07:00
521a131646 Cache: day-fresh reads + prefetch every other view (#8)
Two related client-cache improvements in nav.js:

- Day-of-fetch freshness: every cached API response is now tagged with
  the viewer's local calendar day, and a cache hit requires both the TTL
  AND a same-day tag. A cache stored yesterday is refetched today, so a
  new day's data (and the calendar's "days shown") can't stay hidden
  behind an otherwise-fresh cache across a midnight rollover.

- Cross-view prefetch now covers the Day page (api/v2/day), which it
  previously never warmed, and skips any view that already has a fresh
  same-day cache — so it only fetches the pages actually missing one.


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 19:28:34 -07:00
Emi Griffith
8ff352e236 Calendar totals: label each bar, share/count toggle, plot Normal
Caption every category column with its name (two reserved lines so the
value figures stay aligned whether a name wraps or not), and add a
"Show count" checkbox in the strip header that swaps every share figure
for the raw day count (state persists in localStorage, re-renders in
place via a delegated listener on #cal-totals).

Also plot the neutral "Normal" median category as a real bar instead of
a bare baseline tick, so the strip reads as a full distribution; bar
heights now scale to the tallest category (Normal included).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 19:00:41 -07:00
Emi Griffith
7aeb32d135 Merge remote-tracking branch 'origin/main' into worktree-calendar-status-lines
# Conflicts:
#	frontend/app.js
2026-07-10 18:41:36 -07:00
Emi Griffith
54e053ab46 Weekly timeline: label dry streak instead of a dot
On the precip row of the recent/forecast timeline, dry days now show the
running dry-streak count ("3d" = 3 days since measurable rain), tinted by
the same dryness ramp as the chart, rather than a bare "·".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 18:39:47 -07:00
27a6508b72 Calendar totals strip: wider bars, 0.1%-precision small shares (#5)
Widen the per-category deviation bars (3px→10px) so each reads
clearly, and print sub-1% shares as e.g. "0.4%" instead of "<1%".


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 18:38:34 -07:00
cbef45db13 Merge pull request #4 from griffemi/worktree-calendar-status-lines
Calendar totals: compact per-category status lines
2026-07-10 18:31:30 -07:00
Emi Griffith
d4272e57da Calendar totals: compact per-category status lines
Replace the stacked share bar + labeled percentage chips above the
calendar grid with a compact deviation strip: one thin status-colored
line per category (height scaled to the tallest non-median category),
with each share printed above it in that category's own status color.

The median tier — the neutral "Normal" center of the diverging
temperature scale — draws no line, just a faint baseline tick, so it
reads as the reference the other categories deviate from. Precip and
dry-streak have no natural median, so every category there keeps a line.

Percentage text is lifted toward the theme text color (color-mix) so
even the darkest/lightest tiers stay legible in both light and dark.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 18:28:24 -07:00
Emi Griffith
ede982f610 Weekly page: no-rain visuals adopt the dry-streak look
On the Weekly page, no-rain (0") days now warm with the dry-streak ramp
(tan→red, deepening to a 14-day cap) instead of a flat blue/tan, matching
the Dry chart and calendar's dry-period language:

- Precip trend chart: no-rain day dots colored by drynessColor(dsr).
- "Daily, graded" timeline strip: Rain-row dry cells tinted by dsr via a
  new optional color override on rdCell.

Rain days keep their intensity-tier colors; unknown streaks fall back to
the prior flat color, so nothing regresses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VP23wKmjS2ozk1g5a9g1Z
2026-07-10 18:27:45 -07:00
Emi Griffith
3dce838800 Initial commit: app +
VPS deploy pipeline
2026-07-10 17:29:47 -07:00