Commit graph

8 commits

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