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.pyon the backend ownspage_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 byapp.py's_page()),account.js(auth),cache.js(IndexedDB response cache +/cellbundle prefetch),shared.js(format helpers shared across views).static/style.css— the single hand-written stylesheet; all design tokens live here (seeDESIGN.md).templates/*.html.j2— Jinja templatescontent.pyrenders.content/*.yaml— structured SSR copy (glossary, static-page SEO meta), loaded bycontent_loader.py. Committed here as a starter copy extracted alongside the split; real cross-repo copy vendoring (a pinnedthermograph-copycheckout 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'sdev→main→releasethree-stage promotion and its LANdeploy-dev.ymlpath do not exist in this repo yet — a documented gap, see the cutover plan §4. Don't assume adevbranch here.) .forgejo/workflows/build-push.yml— on push todev/main/releaseor av*.*.*tag: builds THIS repo's ownDockerfileand pushesgit.thermograph.org/emi/thermograph-frontend/app, taggedsha-<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 sharedemi/thermograph/appimage..forgejo/workflows/build.yml— push/PR tomain: 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 outthermograph-backendtoo. Not yet built; flagged, not silently skipped..forgejo/workflows/deploy.yml— push tomain: SSH to beta, runSERVICE=frontend FRONTEND_IMAGE_TAG=sha-<12 hex> /opt/thermograph/deploy/deploy.sh(that script lives inthermograph-infra). Rolls only the frontend container (--no-deps); backend is untouched and deployed independently by its own repo's workflow.deploy.shretries the image pull for ~5 min (Forgejo has no cross-workflowneeds:, so this deploy can race ahead ofbuild-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 torelease: same shape, targets prod via its ownPROD_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 intothermograph-infra/deploy/deploy.sh: it persists each service's live tag indeploy/.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 required — api_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-levelAPI_VERSION(default"v2", overridable viaTHERMOGRAPH_API_VERSION). Every path builder (hub(),sitemap(),indexnow_key(),home(),city(),city_month(),city_records()) reads it. - JS (interactive tool):
static/account.js's exportedAPI_VERSIONconstant and theuv(path)helper — every fetch incache.js,account.jsitself, etc. is built by wrapping the path inuv(...)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
/cellbundle + ETag/If-None-Match—static/cache.jsfetches/api/v2/cellonce per view-set and slices it for calendar/day/score/compare instead of one request per view; conditional refetch is viaIf-None-Matchagainst the storedETag, so an unchanged payload costs an empty 304. The URL map (which slice serves which view) must stay in sync withthermograph-backend's route/payload shape. shared.js'spctOrd()must mirrorthermograph-backend'sdata/grading.py'spct_ordinal()byte-for-byte in behavior: floor a percentile into1..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'sF_COUNTRIES/static/units.js'sF_REGIONS/ backend'sapi/content_payloads.py'sF_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.