181 lines
10 KiB
Markdown
181 lines
10 KiB
Markdown
# 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
|
|
`dev`→`main`→`release` 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:
|
|
|
|
```bash
|
|
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-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-Match`** — `static/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.
|