Commit graph

12 commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
3dce838800 Initial commit: app +
VPS deploy pipeline
2026-07-10 17:29:47 -07:00