thermograph/CLAUDE.md

10 KiB

Thermograph frontend — agent instructions

This repo is the SSR + static-asset service split out of the emi/thermograph monorepo (repo-split Stage 7). It has no climate data, no DB, and does no polars/compute work — everything comes from the backend's content API over HTTP. It is one of four sibling repos in thermograph-repos/:

  • thermograph-backend — FastAPI API + grading/scoring/grid compute + DB. This repo's only dependency.
  • thermograph-frontend (this repo) — SSR content pages + the interactive tool's SPA shells + every static asset.
  • thermograph-infra — Terraform, docker-compose*.yml, deploy/ (incl. deploy.sh, the SOPS secrets vault), Caddy config. No app code.
  • thermograph-docs — architecture decision records + operator runbooks. No code.

See MONOREPO-CUTOVER-PLAN.md (one level up, in thermograph-repos/) for the full split status and the remaining gap list before the monorepo can be archived.

What this repo is

  • content.py — server-rendered, crawlable pages (climate hub, per-city, month, records, glossary, about, privacy) + robots.txt + sitemap.xml. Every route fetches its data from the backend's content API (api_client.py) instead of computing in-process; content_payloads.py on the backend owns page_title/canonical_path/breadcrumb/jsonld, not this repo.
  • static/*.js — the interactive tool: app.js (map/search/graded results + inline SVG chart), calendar.js/day.js/score.js/compare.js (SPA shells served by app.py's _page()), account.js (auth), cache.js (IndexedDB response cache + /cell bundle prefetch), shared.js (format helpers shared across views).
  • static/style.css — the single hand-written stylesheet; all design tokens live here (see DESIGN.md).
  • templates/*.html.j2 — Jinja templates content.py renders.
  • content/*.yaml — structured SSR copy (glossary, static-page SEO meta), loaded by content_loader.py. Committed here as a starter copy extracted alongside the split; real cross-repo copy vendoring (a pinned thermograph-copy checkout at build time) is deferred, unbuilt follow-up — don't assume it exists.

Branch/PR + deploy flow

  • Work happens on feature branches; PR into main. (The monorepo's devmainrelease three-stage promotion and its LAN deploy-dev.yml path do not exist in this repo yet — a documented gap, see the cutover plan §4. Don't assume a dev branch here.)
  • .forgejo/workflows/build-push.yml — on push to dev/main/release or a v*.*.* tag: builds THIS repo's own Dockerfile and pushes git.thermograph.org/emi/thermograph-frontend/app, tagged sha-<12 hex> (every push) and the semver tag (tag pushes only). Backend publishes its own separate image the same way — the two are no longer one shared emi/thermograph/app image.
  • .forgejo/workflows/build.yml — push/PR to main: proves the Dockerfile builds. It is a build check only, not a boot/health check — booting the real app crashes at import without a reachable backend (see API-version section below), so a standalone boot check would need to check out thermograph-backend too. Not yet built; flagged, not silently skipped.
  • .forgejo/workflows/deploy.yml — push to main: SSH to beta, run SERVICE=frontend FRONTEND_IMAGE_TAG=sha-<12 hex> /opt/thermograph/deploy/deploy.sh (that script lives in thermograph-infra). Rolls only the frontend container (--no-deps); backend is untouched and deployed independently by its own repo's workflow. deploy.sh retries the image pull for ~5 min (Forgejo has no cross-workflow needs:, so this deploy can race ahead of build-push.yml) and waits for a healthy backend before declaring the roll OK (frontend's boot fetches the IndexNow key from backend — see below).
  • .forgejo/workflows/deploy-prod.yml — push to release: same shape, targets prod via its own PROD_SSH_* secret set (fully separate from beta's, so a beta credential leak can't touch prod). Nothing else deploys to prod; there is no release-triggered Terraform apply from this repo.
  • The SERVICE=frontend + FRONTEND_IMAGE_TAG=sha-<12 hex> pair is the entire contract into thermograph-infra/deploy/deploy.sh: it persists each service's live tag in deploy/.image-tags.env (host-side, untracked) so a frontend-only roll never disturbs backend's currently-running tag, and vice versa.

How to run / test

There is no run.sh/Makefile in this repo yet (the monorepo's make lan-run/make run/make stop/venv targets have no home here — see the cutover plan's gap list). Run directly:

python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
THERMOGRAPH_API_BASE_INTERNAL=http://127.0.0.1:8137 \
THERMOGRAPH_BASE=/thermograph \
  .venv/bin/uvicorn app:app --host 0.0.0.0 --port 8080

THERMOGRAPH_API_BASE_INTERNAL is requiredapi_client.py raises RuntimeError at import if it's unset. Other env vars: THERMOGRAPH_BASE (default /thermograph; the Dockerfile sets / for the deployed clean-root topology), THERMOGRAPH_API_VERSION (default v2, see below), THERMOGRAPH_API_BASE_PUBLIC (browser-facing backend origin for asset URLs when frontend and backend are cross-origin; empty = same-origin, today's default), THERMOGRAPH_SSR_CACHE_TTL (default 600s, the content-API response cache in api_client.py), THERMOGRAPH_GOOGLE_VERIFY/THERMOGRAPH_BING_VERIFY (search-console <meta> tags).

Known gap — tests/conftest.py does not run standalone in this repo. It still assumes the pre-split layout: a sibling backend/ checkout with its own tests/conftest.py (make_history/make_recent) and an api.content_payloads module, neither of which exists here. It was extracted verbatim as reference material for the real fix (a genuine cross-repo contract-test job, or a self-contained set of fixtures owned in this repo) — not yet built. Do not assume pytest tests passes here until that lands; .forgejo/workflows/build.yml only proves the image builds, for the same reason. There is also no requirements-dev.txt in this repo yet (pytest/httpx test deps aren't pinned).

API-version pinning contract

Every backend call in this repo goes through a single pinned constant instead of scattered api/v2/... literals:

  • Python (SSR): api_client.py's module-level API_VERSION (default "v2", overridable via THERMOGRAPH_API_VERSION). Every path builder (hub(), sitemap(), indexnow_key(), home(), city(), city_month(), city_records()) reads it.
  • JS (interactive tool): static/account.js's exported API_VERSION constant and the uv(path) helper — every fetch in cache.js, account.js itself, etc. is built by wrapping the path in uv(...) rather than hardcoding a version.

Bump only in lockstep with a verified backend /api/version check — the backend exposes GET {BASE}/api/version{backend_version, min_frontend, payload_ver} (web/app.py's API_CONTRACT_VERSION/MIN_SUPPORTED_FRONTEND). Before bumping this repo's API_VERSION, confirm the target backend's backend_version actually supports it and its min_frontend doesn't already exclude the version you're moving away from.

A v3 cutover would work like this: backend mounts a new v3 = APIRouter() alongside v2, re-registering only the changed handlers (v2 keeps serving old clients); backend bumps API_CONTRACT_VERSION to "3" in that same PR. Only once that's deployed and verified does this repo bump API_VERSION to "v3" in both api_client.py and account.js — one PR, both pins together, never one without the other. v2 (and the /api, /api/v1 aliases) stay mounted and working until no client depends on them.

The frontend's own boot is resilient to the backend being down: the indexnow_key() fetch in content.py's register() is tried once eagerly (so the common case still serves /<key>.txt as a plain static route), but a failure is caught, logged, and falls back to a lazy per-request lookup instead of crashing boot — asynchronous frontend/backend deploys depend on this (a briefly-unreachable backend at frontend boot must be survivable, not fatal).

Cross-repo contracts this repo depends on

These are live contracts with the backend — a change on either side without the other breaks something silently, not loudly:

  • The /cell bundle + ETag/If-None-Matchstatic/cache.js fetches /api/v2/cell once per view-set and slices it for calendar/day/score/compare instead of one request per view; conditional refetch is via If-None-Match against the stored ETag, so an unchanged payload costs an empty 304. The URL map (which slice serves which view) must stay in sync with thermograph-backend's route/payload shape.
  • shared.js's pctOrd() must mirror thermograph-backend's data/grading.py's pct_ordinal() byte-for-byte in behavior: floor a percentile into 1..99 (never round to 100/0 — "100th percentile" reads as measurement error, not "as extreme as it has ever been"). Every percentile shown anywhere (Day page, calendar tooltip, chart, city pages, homepage strip) goes through one of these two functions; if they diverge, the same reading says two different things on two different surfaces.
  • Unit/region logic (format.py's F_COUNTRIES / static/units.js's F_REGIONS / backend's api/content_payloads.py's F_COUNTRIES) — the set of Fahrenheit-using country codes must stay identical across all three; backend has a test asserting this.

Design & visual verification

Design tokens + conventions are documented in DESIGN.md (source of truth: static/style.css). To see a change rendered, see DESIGN.md's make shots section (tools/shoot.py).

Commits & PRs

Describe only the substance of the change; concise, technical. Never mention AI/Claude/assistants or automated authorship anywhere — no trailers, co-authors, emoji, or "as requested"/"per the agent" narration.