Commit graph

4 commits

Author SHA1 Message Date
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
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
e01d2e0eb8 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
Emi Griffith
8d90dcb456 Initial commit: app +
VPS deploy pipeline
2026-07-10 17:29:47 -07:00