frontend: fix the three inconsistencies the onboarding guide found (#99)
All checks were successful
secrets-guard / encrypted (push) Successful in 16s
shell-lint / shellcheck (push) Successful in 14s
Build + push images (Forgejo registry) / build-push (backend) (push) Successful in 1m24s
Build + push images (Forgejo registry) / build-push (frontend) (push) Successful in 1m23s
Deploy / deploy (backend) (push) Successful in 2m36s
Deploy / deploy (frontend) (push) Successful in 2m49s

This commit is contained in:
emi 2026-07-25 21:11:32 +00:00
parent a23c66741c
commit 18f50ad09b
12 changed files with 404 additions and 202 deletions

View file

@ -38,13 +38,20 @@ for systemd/CI callers. Entry target is `app:app`.
`cache.js` reads `null` cross-origin and never revalidates. Don't change the `cache.js` reads `null` cross-origin and never revalidates. Don't change the
ETag derivation or that list without checking `frontend/static/cache.js`. ETag derivation or that list without checking `frontend/static/cache.js`.
- **`data/grading.py::pct_ordinal()`** is mirrored by `frontend`'s - **`data/grading.py::pct_ordinal()`** is mirrored by `frontend`'s
`shared.js::pctOrd()` — floor a percentile into `1..99`, never 0 or 100. `server/internal/format::PctOrdinal` (SSR) and `static/shared.js::pctOrd()`
(browser) — floor a percentile into `1..99`, never 0 or 100.
`TEMP_BANDS`/`RAIN_BANDS` are the source of truth for tier names and `TEMP_BANDS`/`RAIN_BANDS` are the source of truth for tier names and
thresholds; changing them without the frontend produces tiers drawn in colours thresholds; changing them without the frontend produces tiers drawn in colours
that disagree with the labels the API returns. that disagree with the labels the API returns.
- **Fahrenheit country set**`api/content_payloads.py`'s `F_COUNTRIES` must stay - **Fahrenheit country set**`api/content_payloads.py`'s `F_COUNTRIES` is
identical to `frontend`'s `format.py::F_COUNTRIES` and `static/units.js`'s canonical; `frontend`'s `server/internal/format::FCountries` and
`F_REGIONS`. There is a test asserting this. `static/units.js`'s `F_REGIONS` must stay identical to it. Both are asserted
by tests in `frontend/server/internal/format/format_test.go` — but note the
backend cross-check **skips in CI** (the frontend image's build context is
`frontend/`, so this file is unreachable from the builder stage, which is the
only place CI runs those tests). It fires on a full checkout. The `units.js`
check does run in the image build. `frontend/format.py`'s copy is the
superseded Python service's and is not deployed.
- **`/healthz` and `/api/version` are deliberately I/O-free** so they stay cheap - **`/healthz` and `/api/version` are deliberately I/O-free** so they stay cheap
under tight healthcheck intervals. under tight healthcheck intervals.

View file

@ -22,7 +22,11 @@ services:
backend: backend:
# Defaults to a locally-built `:local` image (scripts/smoke.sh builds it). In CI, # Defaults to a locally-built `:local` image (scripts/smoke.sh builds it). In CI,
# set BACKEND_IMAGE_TAG=sha-<12hex> to smoke the exact published image. # set BACKEND_IMAGE_TAG=sha-<12hex> to smoke the exact published image.
image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph-backend/app}:${BACKEND_IMAGE_TAG:-local} # The path must match what build-push.yml publishes -- emi/thermograph/backend.
# It read emi/thermograph-backend/app (the retired split-era path) until this
# was corrected; harmless for the default `:local` build, wrong the moment
# BACKEND_IMAGE_TAG names a real published tag.
image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph/backend}:${BACKEND_IMAGE_TAG:-local}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy

View file

@ -139,9 +139,19 @@ make backend-up # pulls + runs the published backend image + throwaway db
make backend-down make backend-down
``` ```
The image tag is **derived from your checkout** — `sha-<12hex of git log -1 --
backend/>`, the same domain-keyed rule `build-push.yml` and `deploy.yml` use —
so the harness follows the tree. If those backend commits are still local-only,
no image exists yet and the script says so; pin a published build with
`THERMOGRAPH_BACKEND_TEST_TAG=sha-<12hex>`.
`make test-integration` runs the Python integration tier against that. CI does `make test-integration` runs the Python integration tier against that. CI does
**not** run it — it needs a live backend container. **not** run it — it needs a live backend container.
> Heads-up: against a freshly-booted throwaway backend, that tier currently
> fails 7 of 16 with `503` — the database is empty, so nothing is warm.
> Pre-existing, and unrelated to which image tag you use.
## The daemon ## The daemon
```bash ```bash

View file

@ -8,11 +8,11 @@ HTTP.
> **The live service is Go.** `frontend/server/` builds, tests, ships and runs. > **The live service is Go.** `frontend/server/` builds, tests, ships and runs.
> The Python files one directory up (`app.py`, `content.py`, `api_client.py`, > The Python files one directory up (`app.py`, `content.py`, `api_client.py`,
> `format.py`, `content_loader.py`, `paths.py`, `templates/*.html.j2`) are the > `format.py`, `content_loader.py`, `paths.py`, `templates/*.html.j2`) are the
> superseded original implementation, kept in-tree as the reference the port > superseded original implementation, kept in-tree as the reference the port was
> was made from. `frontend/CLAUDE.md` and `frontend/README.md` still describe > made from. Nothing deploys them, and they hold duplicate copies of a couple of
> the Python service — see [traps](11-traps.md). Everything in those docs about > contracts (`api_client.py`'s `API_VERSION` pin, `format.py`'s `F_COUNTRIES`) —
> **contracts, env vars and design** is still correct; only "which language and > keep those in step if you run them locally. Whether to delete them is an open
> which file" changed. > question; raise it rather than doing it in passing.
## Shape of the Go service ## Shape of the Go service

View file

@ -104,16 +104,23 @@ Fourteen codes: `US PR GU VI AS MP UM BS BZ KY PW FM MH LR`.
| browser | `frontend/static/units.js::F_REGIONS` | | browser | `frontend/static/units.js::F_REGIONS` |
| Python SSR *(not deployed)* | `frontend/format.py::F_COUNTRIES` | | Python SSR *(not deployed)* | `frontend/format.py::F_COUNTRIES` |
`format_test.go::TestFCountriesMatchesBackend` re-parses the backend's Python Two tests in `frontend/server/internal/format/format_test.go` guard this:
source and asserts the Go copy matches — a genuinely good guard.
> ⚠️ **Verified gap.** Several source comments claim "a test asserts all three - `TestFCountriesMatchesBackend` re-parses the backend's Python and asserts the
> stay identical." As of this writing that is not true: no test reads Go copy matches. **This one skips in CI** — the frontend image's build context
> `static/units.js`. All four copies currently *are* identical (checked), but is `frontend/`, so `backend/` is unreachable from the builder stage, and the
> the browser copy is held in step by convention alone. If you touch this set, builder stage is the only place CI runs these tests. Treat it as a
> update `units.js` by hand and diff it yourself. Extending the Go test to checkout-only guard: it fires on your machine and in a full-checkout run, not
> parse `units.js` too would close this properly and is a good first during the image build.
> contribution. - `TestFCountriesMatchesUnitsJS` parses `static/units.js` and diffs both
directions. This one *does* run in the image build — `frontend/Dockerfile`
copies that single file into the builder stage for exactly that reason — so a
divergence fails the build.
The browser copy was unguarded until that second test was added; several source
comments had long claimed "a test asserts all three stay identical" when only
the Go↔backend pair was checked. The Python `frontend/format.py` copy remains
guarded only by the local Python tier, which is fine — it isn't deployed.
## 6. Backend boot must survive an unreachable frontend, and vice versa ## 6. Backend boot must survive an unreachable frontend, and vice versa

View file

@ -12,27 +12,23 @@ Everything below was checked against the tree at the time of writing.
## Stale docs in this repo ## Stale docs in this repo
### `frontend/CLAUDE.md` and `frontend/README.md` describe a service that is not deployed ### ~~`frontend/CLAUDE.md` and `frontend/README.md` describe a service that is not deployed~~ — fixed
**The live frontend is Go** (`frontend/server/`, module `thermograph/frontend`). **Fixed.** Both now describe the Go service (`frontend/server/`), state plainly
Both documents describe the Python FastAPI/Jinja implementation that the Python files at that level are the superseded original, and correct
(`frontend/app.py`, `content.py`, `api_client.py`, `format.py`, `CLAUDE.md`'s claim that `make test-unit` is *"the tier CI runs"* — it isn't.
`templates/*.html.j2`), which was superseded by the Go port and is kept in-tree CI builds the frontend image, and `frontend/Dockerfile`'s builder stage runs
as the reference the port was made from. `gofmt -l` + `go vet` + `go test ./...` before compiling, so a failing **Go**
test is what fails CI. The Python tiers are local-only.
What is still **correct** in them: every contract (API-version pinning, Still worth knowing: the superseded Python files carry **duplicate copies of
`pctOrd`, the `/cell` + ETag relationship, the Fahrenheit set, boot-must-survive- some contracts** — `api_client.py` has a third `API_VERSION` pin and
an-unreachable-backend), every environment variable name and default, and all of `format.py` a fourth `F_COUNTRIES`. Nothing deploys them, but if you run them
`DESIGN.md`. locally, keep them in step or you'll debug a phantom.
What is **wrong**: the language, the file paths, and — notably — Whether to delete them outright is an open question, not a settled one: the
`frontend/CLAUDE.md`'s claim that `make test-unit` is *"the tier CI runs."* It Python test tiers still run, against fixtures the Go tests share. Raise it
isn't any more. CI builds the frontend image, and `frontend/Dockerfile`'s rather than doing it in passing.
builder stage runs `gofmt -l` + `go vet` + `go test ./...` before compiling, so
a failing **Go** test is what fails CI. The Python tiers are local-only now.
Also note `api_client.py` carries a **third** copy of the `API_VERSION` pin.
It isn't deployed, but keep it in step or you'll debug a phantom locally.
### `infra/DEPLOY.md` describes the pre-Swarm, pre-monorepo deploy ### `infra/DEPLOY.md` describes the pre-Swarm, pre-monorepo deploy
@ -108,34 +104,49 @@ at the monorepo, that's the only path. Flagged again in
## Live traps ## Live traps
### The `docker-compose.test.yml` files still pull retired image paths ### ~~The `docker-compose.test.yml` files pull retired image paths~~ — fixed
Both `backend/docker-compose.test.yml` and `frontend/docker-compose.test.yml` **Fixed.** Both now use `emi/thermograph/backend`, the path `build-push.yml`
default `BACKEND_IMAGE_PATH` to **`emi/thermograph-backend/app`** — the actually publishes.
split-era path. The current image is `emi/thermograph/backend`.
- `backend/make smoke` is fine: it builds `:local` and never pulls. The frontend harness had the worse half of this: it also pinned
- `frontend/make backend-up` **does** pull, and pins `THERMOGRAPH_BACKEND_TEST_TAG` to the split-era `v0.0.2-split-ci`. Rather than
`THERMOGRAPH_BACKEND_TEST_TAG` to `v0.0.2-split-ci`. The old packages were swap one hardcoded pin for another, `scripts/backend-for-tests.sh` now
deliberately left in the registry for rollback and GC'd only after burn-in, so **derives** the tag from the checkout — `sha-<12hex of git log -1 -- backend/>`,
this may work, may serve a very old backend, or may 404 depending on when you the same domain-keyed rule `build-push.yml` and `deploy.yml` both use — so the
read this. If `make backend-up` fails or behaves oddly, set the path and tag harness follows the tree instead of drifting behind a pin. `docker-compose.test.yml`
explicitly: requires the variable (`:?`) rather than defaulting it, so a stale pin can't
```bash creep back in silently.
BACKEND_IMAGE_PATH=emi/thermograph/backend \
THERMOGRAPH_BACKEND_TEST_TAG=sha-<12hex> make backend-up
```
### `static/units.js`'s `F_REGIONS` is guarded by nothing If the derived tag has no published image (backend commits that are still
local-only), the script says so and tells you to pin one:
Source comments in three files claim *"a test asserts all three stay ```bash
identical."* The only automated assertion is THERMOGRAPH_BACKEND_TEST_TAG=sha-<12hex> make backend-up
`format_test.go::TestFCountriesMatchesBackend`, which re-parses the backend's ```
Python and compares the **Go** copy. Nothing reads `units.js`.
All four copies currently *are* identical (14 codes, checked). But the browser > Unrelated but adjacent: `make test-integration` against a freshly-booted
copy is held in step by convention alone. Extending the Go test to parse > throwaway backend currently fails 7 of 16 tests with `503`, because nothing
`units.js` too is a good first contribution. > in that empty database is warm. Verified identical on the old image and the
> new one, so it predates these changes and is **not** caused by them. Not
> investigated further here.
### ~~`static/units.js`'s `F_REGIONS` is guarded by nothing~~ — fixed
**Fixed.** `format_test.go::TestFCountriesMatchesUnitsJS` now parses
`static/units.js` and diffs it against the Go set in both directions.
The subtlety worth knowing: the *existing* backend cross-check
(`TestFCountriesMatchesBackend`) **skips in CI**. The frontend image's build
context is `frontend/`, so `backend/` is structurally unreachable from the
builder stage — and the builder stage is the only place CI runs these tests. It
is a checkout-only guard and always was.
The new `units.js` check does not have that problem, because `static/` *is*
inside the context: `frontend/Dockerfile` copies that one file into the builder
stage specifically so the assertion runs during the image build. Verified by
mutating `units.js` and confirming the **image build fails**, not just the local
test run.
### The compose project name is load-bearing ### The compose project name is load-bearing
@ -259,10 +270,10 @@ is one of Starlette's shared sync-threadpool threads.
## If you fix one of these ## If you fix one of these
Docs are cheap to correct and the root `CLAUDE.md` already asks you to fix Docs are cheap to correct, and the root `CLAUDE.md` already asks you to fix
repo-era wording as you touch files near it. If you're in `frontend/` anyway, repo-era wording as you touch files near it. The three entries struck through
bringing `frontend/CLAUDE.md` in line with the Go service would be the single above were fixed in the same change that added this guide; the rest are still
highest-value documentation change available — it's the file an agent reads open, and `infra/DEPLOY.md` is probably the highest-value one left — it
*before* touching that domain. describes a deploy model that has since changed twice.
Back to the [index](README.md). Back to the [index](README.md).

View file

@ -7,65 +7,117 @@ no compute — everything comes from `backend/`'s content API over HTTP.
Read the root `CLAUDE.md` first for the branch model, deploy contract and image Read the root `CLAUDE.md` first for the branch model, deploy contract and image
names. names.
## The live service is Go — `server/`
`server/` (module `thermograph/frontend`, Go 1.26) is what builds, tests, ships
and runs. It is the only thing in the `emi/thermograph/frontend` image.
The Python files at this level — `app.py`, `content.py`, `api_client.py`,
`format.py`, `content_loader.py`, `paths.py`, `templates/*.html.j2` — are the
**superseded original implementation**, kept as the reference the port was made
from. Nothing deploys them. Don't add features there; don't take their file
paths as current. (They still carry duplicate copies of some contracts below —
notably `api_client.py`'s `API_VERSION` pin and `format.py`'s `F_COUNTRIES`. If
you use them locally, keep them in step or you will debug a phantom.)
Deleting them is a live question, not a settled one — the Python test tiers
still run against the fixtures the Go tests share. Raise it rather than doing it
in passing.
## Build & verify ## Build & verify
- `make test` — whole suite (`scripts/test.sh`). - `cd server && go build ./... && go vet ./... && go test ./...` — **the suite
- `make test-unit` — hermetic unit tier: SSR rendering fed committed fixtures, that matters.**
no Docker. **This is the tier CI runs.** - **CI runs it inside `Dockerfile`'s builder stage**, which does `gofmt -l` +
- `make test-integration` — pulls and runs the real backend image and tests the `go vet` + `go test ./...` before compiling. A failing Go test therefore fails
live contract. Local only; CI does not run it. the image build — that is the whole frontend check. There is no separate
frontend test step in any workflow.
- `make test-unit` / `make test-integration` — the **Python** tiers. Local only;
CI runs neither. The unit tier is hermetic (SSR rendering fed committed
fixtures); the integration tier pulls and runs a real backend image.
- `make backend-up` / `make backend-down` — a local backend container for dev. - `make backend-up` / `make backend-down` — a local backend container for dev.
The image tag is derived from this checkout (last commit touching `backend/`,
the same domain-keyed rule build-push and deploy use); override with
`THERMOGRAPH_BACKEND_TEST_TAG=sha-<12hex>`.
- `make capture-fixtures` — refresh `tests/fixtures/*.json` from a live backend. - `make capture-fixtures` — refresh `tests/fixtures/*.json` from a live backend.
**These fixtures feed the Go tests too**, not just the Python ones.
## Running it ## Running it
`THERMOGRAPH_API_BASE_INTERNAL` is **required**`api_client.py` raises at `THERMOGRAPH_API_BASE_INTERNAL` is **required** — the boot fails loudly without
import if unset. it, as the Python raised at import.
Build in `server/`, run from `frontend/`: `static/` and `content/` resolve
relative to the working directory, while templates are embedded in the binary.
```bash ```bash
cd server && go build -o thermograph-frontend .
cd ..
THERMOGRAPH_API_BASE_INTERNAL=http://127.0.0.1:8137 THERMOGRAPH_BASE=/thermograph \ 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 ./server/thermograph-frontend
``` ```
Other env: `THERMOGRAPH_BASE` (default `/thermograph`; the Dockerfile sets `/`), Other env: `THERMOGRAPH_BASE` (default `/thermograph`; the Dockerfile sets `/`),
`THERMOGRAPH_API_VERSION` (default `v2`), `THERMOGRAPH_API_BASE_PUBLIC` `THERMOGRAPH_API_VERSION` (default `v2`), `THERMOGRAPH_API_BASE_PUBLIC`
(browser-facing backend origin when cross-origin; empty = same-origin, today's (browser-facing backend origin when cross-origin; empty = same-origin, today's
default), `THERMOGRAPH_SSR_CACHE_TTL` (default 600s). default), `THERMOGRAPH_SSR_CACHE_TTL` (default 600s),
`THERMOGRAPH_GOOGLE_VERIFY` / `THERMOGRAPH_BING_VERIFY`, `PORT` (default 8080).
Boot is fail-loud on bad **config or content**: a missing backend URL, a garbage
TTL, or malformed `content/*.yaml` all stop the process rather than 500 on the
first request.
## Layout ## Layout
- **`content.py`** — SSR routes (climate hub, city, month, records, glossary, - **`server/internal/content/`** — the SSR page handlers (climate hub, city,
about, privacy) + `robots.txt` + `sitemap.xml`. Each fetches from the backend month, records, glossary, about, privacy) + `robots.txt` + `sitemap.xml`,
via `api_client.py`; the backend's `content_payloads.py` owns `page_title` / plus the template funcmap and SEO helpers. The backend's
`canonical_path` / `breadcrumb` / `jsonld`, not this domain. `content_payloads.py` owns `page_title` / `canonical_path` / `breadcrumb` /
`jsonld`, **not this domain**.
- **`server/internal/contentapi/`** — the backend `/content/*` client: TTL
cache, bounded LRU, per-key single-flight, origin forwarding. All four
properties are load-bearing; read the file before changing caching.
- **`server/internal/render/`** + `render/templates/*.tmpl``html/template`
over an `embed.FS`, plus the ETag helpers. Templates are **embedded**, so a
template change needs a rebuild.
- **`server/internal/{config,format,contentdata,handlers}/`** — env parsing,
unit-aware °C/°F formatting and band names, the glossary/pages loader, and
the SPA-shell + static handlers.
- **`static/*.js`** — `app.js` (map/search/results + inline SVG chart), - **`static/*.js`** — `app.js` (map/search/results + inline SVG chart),
`calendar.js`/`day.js`/`score.js`/`compare.js` (SPA shells), `account.js` `calendar.js`/`day.js`/`score.js`/`compare.js` (SPA shells), `account.js`
(auth), `cache.js` (IndexedDB cache + `/cell` bundle prefetch), `shared.js`. (auth), `cache.js` (IndexedDB cache + `/cell` bundle prefetch), `shared.js`.
- **`static/style.css`** — the single hand-written stylesheet; design tokens live - **`static/style.css`** — the single hand-written stylesheet; design tokens live
here, documented in `DESIGN.md`. here, documented in `DESIGN.md`.
- **`templates/*.html.j2`**, **`content/*.yaml`** (structured SSR copy, loaded by - **`content/*.yaml`** — structured SSR copy, loaded by
`content_loader.py`). `server/internal/contentdata`.
## Contracts with the backend ## Contracts with the backend
- **API version is pinned in exactly two places**`api_client.py`'s - **API version is pinned in exactly two places**`server/internal/config`'s
`API_VERSION` (Python) and `static/account.js`'s exported `API_VERSION` plus the `THERMOGRAPH_API_VERSION` (Go) and `static/account.js`'s exported
`uv(path)` helper (JS). Never hardcode `api/v2/...` anywhere else. Bump both in `API_VERSION` plus the `uv(path)` helper (JS). Never hardcode `api/v2/...`
one PR, and only after the target backend's `/api/version` confirms it serves anywhere else. Bump both in one PR, and only after the target backend's
that version and its `min_frontend` doesn't exclude the one you're leaving. `/api/version` confirms it serves that version and its `min_frontend` doesn't
exclude the one you're leaving.
- **`shared.js::pctOrd()` must mirror `backend`'s `data/grading.py::pct_ordinal()`** - **`shared.js::pctOrd()` must mirror `backend`'s `data/grading.py::pct_ordinal()`**
in behaviour — floor into `1..99`, never 0 or 100. Every percentile on every in behaviour — floor into `1..99`, never 0 or 100 — and so must
surface goes through one of the two; if they diverge, the same reading says two `server/internal/format`'s `PctOrdinal`. Every percentile on every surface
goes through one of the three; if they diverge, the same reading says two
different things in two places. different things in two places.
- **`/cell` bundle + ETag** — `cache.js` fetches `/api/v2/cell` once per view-set - **`/cell` bundle + ETag** — `cache.js` fetches `/api/v2/cell` once per view-set
and slices it, revalidating with `If-None-Match`. The slice→view map must track and slices it, revalidating with `If-None-Match`. The slice→view map must track
the backend's payload shape. the backend's payload shape.
- **Fahrenheit country set**`format.py::F_COUNTRIES` and `static/units.js`'s - **Fahrenheit country set**`server/internal/format`'s `FCountries` and
`F_REGIONS` must stay identical to the backend's `F_COUNTRIES`. `static/units.js`'s `F_REGIONS` must stay identical to the backend's
- **Boot must survive an unreachable backend.** `content.py`'s `register()` tries `F_COUNTRIES`. Both are now asserted by tests in
the IndexNow-key fetch once, catches failure, and falls back to a lazy `server/internal/format/format_test.go`: the backend cross-check is a
per-request lookup. Asynchronous FE/BE deploys depend on this — don't make it checkout-only guard (the image build context is `frontend/`, so `backend/` is
fatal. unreachable there), while the `units.js` check runs in the image build too —
`Dockerfile` copies that one file into the builder stage for exactly that
reason.
- **Boot must survive an unreachable backend.** The IndexNow-key fetch is tried
once at boot, caught, and falls back to a lazy per-request lookup.
Asynchronous FE/BE deploys depend on this — don't make it fatal.
## Commits & PRs ## Commits & PRs

View file

@ -35,6 +35,19 @@ COPY server/ ./
COPY tests/fixtures /tests/fixtures COPY tests/fixtures /tests/fixtures
COPY content /content COPY content /content
# static/units.js is copied for ONE test: internal/format's
# TestFCountriesMatchesUnitsJS, which asserts the browser's F_REGIONS still
# matches the Go/backend Fahrenheit country set. The builder stage is the only
# place CI ever executes these tests, so without this the check would skip in CI
# and only ever run on a developer's checkout. Same three-levels-up relationship
# the test expects (/src/internal/format -> /static/units.js), anchored the same
# way the two directories above are.
#
# The sibling backend cross-check cannot be wired up this way: the build context
# is frontend/, so backend/ is structurally unreachable and that test stays a
# checkout-only guard.
COPY static/units.js /static/units.js
RUN test -z "$(gofmt -l .)" && go vet ./... && go test ./... RUN test -z "$(gofmt -l .)" && go vet ./... && go test ./...
# Static binary: CGO off (no libc dependency on Alpine), -trimpath for # Static binary: CGO off (no libc dependency on Alpine), -trimpath for

View file

@ -2,107 +2,92 @@
The server-rendered content pages, the interactive-tool SPA shells, and every The server-rendered content pages, the interactive-tool SPA shells, and every
static asset for [Thermograph](https://thermograph.org) — grades recent local static asset for [Thermograph](https://thermograph.org) — grades recent local
weather against ~45 years of climate history. This repo holds **no climate weather against ~45 years of climate history. This domain holds **no climate
data and does no compute**; it renders pages and serves the JS/CSS/image data and does no compute**; it renders pages and serves the JS/CSS/image assets
assets that call the backend's API from the browser. It was split out of the that call the backend's API from the browser. It builds its own image
`emi/thermograph` monorepo (`frontend_ssr/` + `frontend/` there) so it can (`emi/thermograph/frontend`) and deploys independently of the backend.
build its own image and deploy independently of the backend. See
[`CLAUDE.md`](CLAUDE.md) for the full agent-facing detail on the split
topology and deploy contract, and
[`thermograph-docs`](../thermograph-docs) (sibling repo) for the
architecture decision record behind the split.
## Layout See [`CLAUDE.md`](CLAUDE.md) for the agent-facing detail on the deploy contract
and the cross-service contracts, and [`DESIGN.md`](DESIGN.md) for the visual
system.
## The service is Go
`server/` is the live implementation. The Python files at this level (`app.py`,
`content.py`, `api_client.py`, `format.py`, `content_loader.py`, `paths.py`,
`templates/*.html.j2`) are the **superseded original**, kept as the reference
the port was made from — nothing deploys them. See `server/README.md` for the
port's own notes.
``` ```
api_client.py HTTP client for the backend's content API (api/content_routes.py server/ the service (module thermograph/frontend, Go 1.26)
on the backend). Fails loud at import if main.go config + mux + static + graceful shutdown
THERMOGRAPH_API_BASE_INTERNAL is unset. TTL-cached (10 min internal/config/ every env var it reads
default) in front of every call. internal/contentapi/ backend /content/* client: TTL cache, bounded LRU,
app.py FastAPI app: /healthz, the interactive tool's SPA-shell per-key single-flight, origin forwarding
routes (calendar/day/score/compare/legend/alerts), and the internal/content/ SSR page handlers, template funcmap, SEO
StaticFiles mount for everything else. Calls internal/contentdata/ glossary.yaml / pages.yaml loader (fail-loud)
content.register(app) for the SSR routes below. internal/format/ unit-aware °C/°F formatting + band names
content.py Server-rendered pages: climate hub, per-city, month, internal/handlers/ SPA shells + static serving
records, glossary, about, privacy, robots.txt, sitemap.xml. internal/render/ html/template over embed.FS + ETag helpers
content_loader.py Loads content/glossary.yaml + content/pages.yaml (SSR copy), internal/render/templates/ the page templates (embedded, *.tmpl)
validated fail-loud at load time. static/ every static asset: the interactive tool's JS
format.py Unit-aware (°C/°F) formatting for the SSR templates, (app.js, calendar.js, day.js, score.js, compare.js,
ContextVar-scoped active unit. account.js, cache.js, shared.js, units.js,
paths.py Canonical filesystem locations (STATIC_DIR, TEMPLATES_DIR, mappicker.js, …), the SPA-shell HTML pages,
CONTENT_DIR), resolved from the repo root. style.css (all design tokens — see DESIGN.md),
templates/ Jinja templates content.py renders (base, home, city, month, icons/favicons, manifest.webmanifest, sw.js
hub, records, glossary, glossary_term, about, privacy). content/ structured SSR copy: glossary.yaml, pages.yaml
static/ Every static asset: the interactive tool's JS tests/fixtures/ golden payloads — consumed by the GO tests as well
(app.js, calendar.js, day.js, score.js, compare.js, as the Python tiers
account.js, cache.js, shared.js, units.js, mappicker.js, …), tests/{unit,integration}/ the Python tiers (local only; CI runs neither)
the SPA-shell HTML pages, style.css (all design tokens — tools/shoot.py the screenshot sweep for visual verification
see DESIGN.md), icons/favicons, manifest.webmanifest, sw.js. Dockerfile golang builder (gofmt + vet + test) → alpine runtime
content/ Structured SSR copy: glossary.yaml, pages.yaml.
tests/ pytest suite — see the KNOWN GAP note below before relying on it.
tools/ shoot.py — the make-shots visual-verify screenshot tool
(see DESIGN.md).
Dockerfile Builds this repo's own image (python:3.12-slim,
uvicorn app:app), independent of the backend's.
``` ```
## How it fits the split Dependencies are **stdlib plus `gopkg.in/yaml.v3`** — the committed SSR copy in
`content/*.yaml` uses block/folded scalars, so a real YAML parser is required.
Four sibling repos replace the old monorepo:
- **`thermograph-backend`** — the FastAPI API, grading/scoring/grid compute,
and DB. This repo's only runtime dependency.
- **`thermograph-frontend`** (this repo).
- **`thermograph-infra`** — Terraform, compose files, `deploy/deploy.sh` (the
script both repos' deploy workflows SSH into), the secrets vault.
- **`thermograph-docs`** — architecture/decision docs and runbooks.
Each app repo now builds and publishes its **own** image
(`git.thermograph.org/emi/thermograph-frontend/app`, tagged by git SHA and
semver) instead of the old shared `emi/thermograph/app` image, and deploys via
its own `.forgejo/workflows/deploy.yml` (main → beta) and `deploy-prod.yml`
(release → prod), each rolling only the `frontend` compose service on the
target VPS.
## Talking to the backend ## Talking to the backend
All data comes from the backend's content API over HTTP All data comes from the backend's content API over HTTP — this process holds
(`api_client.py`) — this process holds nothing in-process. Two env vars nothing. Two env vars control the topology:
control the topology:
- **`THERMOGRAPH_API_BASE_INTERNAL`** (required) — where this process reaches - **`THERMOGRAPH_API_BASE_INTERNAL`** (required) — where this process reaches
the backend server-side, e.g. `http://backend:8137` in compose, or the backend server-side, e.g. `http://backend:8137` in compose, or
`http://127.0.0.1:8137` for a local backend checkout. `api_client.py` raises `http://127.0.0.1:8137` for a local backend. Boot **fails loudly** if unset:
at import if this is unset — a missing backend URL should break boot, not a missing backend URL should break the boot, not silently 500 on the first
silently 500 the first request. request.
- **`THERMOGRAPH_API_BASE_PUBLIC`** (optional) — the backend's - **`THERMOGRAPH_API_BASE_PUBLIC`** (optional) — the backend's browser-facing
browser-facing origin, used only when frontend and backend are served origin, used only when frontend and backend are served cross-origin. Left
cross-origin (e.g. a genuinely separate LAN-dev topology). Left empty empty (the default), asset/API references stay relative and same-origin,
(the default), asset/API references stay relative and same-origin, which is which is today's real deployed topology.
today's real deployed topology.
`THERMOGRAPH_BASE` (default `/thermograph`) must be set identically on both
frontend and backend in every real deployment — it's how both processes agree on
the shared URL prefix (or the deployed clean-root `/`, which the Dockerfile
sets).
**Same-origin cookie-auth caveat:** the interactive tool's auth **Same-origin cookie-auth caveat:** the interactive tool's auth
(`static/account.js`) is an HttpOnly session cookie. It uses (`static/account.js`) is an HttpOnly session cookie. It uses
`credentials: "include"` (not the fetch default) specifically so the cookie `credentials: "include"` (not the fetch default) specifically so the cookie
still rides along if this script is ever loaded cross-origin, but the backend still rides along if this script is ever loaded cross-origin — but the backend
must actually be configured to accept credentialed cross-origin requests must actually be configured to accept credentialed cross-origin requests (CORS +
(CORS + `SameSite`) for that case to work — in the default same-origin `SameSite`) for that case to work. In the default same-origin deployment this is
deployment this is a non-issue. Don't assume a fully decoupled, independently- a non-issue. Don't assume a fully decoupled, independently-hosted frontend "just
hosted frontend "just works" for logged-in features without checking that works" for logged-in features without checking that configuration first.
cookie/CORS configuration first.
`THERMOGRAPH_BASE` (default `/thermograph`) must be set identically on both
frontend and backend in every real deployment — it's how both processes agree
on the shared URL prefix (or the deployed clean-root `/`, which the Dockerfile
sets by default).
## Build & run ## Build & run
Build in `server/`, run from here — `static/` and `content/` resolve relative to
the working directory, while templates are embedded in the binary:
```bash ```bash
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt cd server && go build -o thermograph-frontend .
cd ..
THERMOGRAPH_API_BASE_INTERNAL=http://127.0.0.1:8137 \ THERMOGRAPH_API_BASE_INTERNAL=http://127.0.0.1:8137 \
THERMOGRAPH_BASE=/thermograph \ THERMOGRAPH_BASE=/thermograph \
.venv/bin/uvicorn app:app --host 0.0.0.0 --port 8080 ./server/thermograph-frontend
``` ```
Or build the image directly: Or build the image directly:
@ -115,12 +100,37 @@ docker run -p 8080:8080 -e THERMOGRAPH_API_BASE_INTERNAL=http://backend:8137 the
The image healthchecks `GET /healthz` (liveness only — it does no I/O, so it The image healthchecks `GET /healthz` (liveness only — it does no I/O, so it
stays cheap; it does not prove the backend is reachable). stays cheap; it does not prove the backend is reachable).
## Test
```bash
cd server && go build ./... && go vet ./... && go test ./...
```
`Dockerfile`'s builder stage runs `gofmt -l` + `go vet` + `go test ./...` before
compiling, so **a failing Go test fails the image build** — and that is the
whole frontend check in CI. There is no separate frontend test step in any
workflow.
The Python tiers are local-only:
```bash
make test-unit # hermetic: SSR rendering fed committed fixtures
make test-integration # pulls + runs a real backend image
make backend-up # just the backend container, for dev
make capture-fixtures # refresh tests/fixtures/*.json from a live backend
```
`make backend-up` derives the backend image tag from this checkout (the last
commit touching `backend/` — the same domain-keyed rule build-push and deploy
use); pin one with `THERMOGRAPH_BACKEND_TEST_TAG=sha-<12hex>`.
## More detail ## More detail
- [`CLAUDE.md`](CLAUDE.md) — agent-facing instructions: deploy contract, - [`CLAUDE.md`](CLAUDE.md) — agent-facing instructions: build/verify, layout,
API-version pinning, the cross-repo `/cell`/ETag and `pctOrd()` contracts, API-version pinning, and the `/cell`/ETag, `pctOrd()` and Fahrenheit-set
and the current known test-suite gap. contracts.
- [`server/README.md`](server/README.md) — the Go port's own notes.
- [`DESIGN.md`](DESIGN.md) — the visual system (tokens, components, - [`DESIGN.md`](DESIGN.md) — the visual system (tokens, components,
breakpoints) and `make shots` visual verification. breakpoints) and `tools/shoot.py` visual verification.
- [`thermograph-docs`](../thermograph-docs) — the repo-topology decision - [`docs/onboarding/`](../docs/onboarding/README.md) — the developer onboarding
record this split implements. path for the whole monorepo.

View file

@ -20,9 +20,16 @@ services:
retries: 20 retries: 20
backend: backend:
# The published backend image is pulled (no local build). Default a published tag; # The published backend image is pulled (no local build).
# override THERMOGRAPH_BACKEND_TEST_TAG to pin a specific backend build (e.g. a sha). #
image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph-backend/app}:${THERMOGRAPH_BACKEND_TEST_TAG:-v0.0.2-split-ci} # No default tag on purpose: scripts/backend-for-tests.sh computes one from
# THIS checkout -- sha-<12hex of `git log -1 -- backend/`>, the same
# domain-keyed rule build-push.yml and deploy.yml both use -- so the harness
# tracks the tree instead of drifting behind a hardcoded pin. (It was pinned
# to the split-era v0.0.2-split-ci, under the retired
# emi/thermograph-backend/app path, long after both were superseded.)
# Override THERMOGRAPH_BACKEND_TEST_TAG to pin a specific build.
image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph/backend}:${THERMOGRAPH_BACKEND_TEST_TAG:?set it, or run via scripts/backend-for-tests.sh which derives it}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy

View file

@ -7,8 +7,9 @@
# scripts/backend-for-tests.sh down # stop + remove (incl. the throwaway db volume) # scripts/backend-for-tests.sh down # stop + remove (incl. the throwaway db volume)
# scripts/backend-for-tests.sh url # print the base URL (no lifecycle change) # scripts/backend-for-tests.sh url # print the base URL (no lifecycle change)
# #
# Which backend build: THERMOGRAPH_BACKEND_TEST_TAG (default a published tag; set a # Which backend build: THERMOGRAPH_BACKEND_TEST_TAG. Unset, it is DERIVED from this
# sha-<12hex> to pin one). Host port: BACKEND_TEST_HOST_PORT (default 18137). # checkout (see below) rather than defaulted to a hardcoded tag -- set a sha-<12hex>
# to pin one. Host port: BACKEND_TEST_HOST_PORT (default 18137).
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
@ -17,10 +18,39 @@ export BACKEND_TEST_HOST_PORT="${BACKEND_TEST_HOST_PORT:-18137}"
BASE_URL="http://127.0.0.1:${BACKEND_TEST_HOST_PORT}" BASE_URL="http://127.0.0.1:${BACKEND_TEST_HOST_PORT}"
COMPOSE=(docker compose -f docker-compose.test.yml) COMPOSE=(docker compose -f docker-compose.test.yml)
# Derive the backend image tag the SAME way build-push.yml and deploy.yml do:
# keyed to the last commit that touched backend/, not the branch tip. That is the
# image actually published for this checkout's backend tree, so the harness follows
# the tree instead of drifting behind a pin (this file previously defaulted to the
# split-era v0.0.2-split-ci tag under a retired image path).
#
# Exported before ANY compose call -- docker-compose.test.yml requires the variable
# (`:?`), so `down` and `url` need it set too, not just `up`.
#
# `:/backend/` is a repo-ROOT-relative pathspec: a bare `backend/` would resolve
# against this script's cwd (frontend/) and silently match nothing, yielding an
# empty sha and a confusing error instead of the right tag.
if [ -z "${THERMOGRAPH_BACKEND_TEST_TAG:-}" ]; then
domain_sha="$(git log -1 --format=%H -- ':/backend/' 2>/dev/null || true)"
if [ -z "$domain_sha" ]; then
echo "!! could not derive a backend image tag from git (no backend/ history here?)." >&2
echo " Set THERMOGRAPH_BACKEND_TEST_TAG=sha-<12hex> explicitly." >&2
exit 2
fi
THERMOGRAPH_BACKEND_TEST_TAG="sha-${domain_sha:0:12}"
fi
export THERMOGRAPH_BACKEND_TEST_TAG
case "${1:-up}" in case "${1:-up}" in
up) up)
echo "==> pulling backend image (${THERMOGRAPH_BACKEND_TEST_TAG:-v0.0.2-split-ci})" >&2 echo "==> pulling backend image (${THERMOGRAPH_BACKEND_TEST_TAG})" >&2
"${COMPOSE[@]}" pull backend >&2 if ! "${COMPOSE[@]}" pull backend >&2; then
echo "!! no published backend image for ${THERMOGRAPH_BACKEND_TEST_TAG}." >&2
echo " That tag is keyed to the last commit touching backend/ -- if those" >&2
echo " commits are local-only, build-push.yml has not published it yet." >&2
echo " Pin a published build: THERMOGRAPH_BACKEND_TEST_TAG=sha-<12hex> $0 up" >&2
exit 1
fi
echo "==> starting backend + db" >&2 echo "==> starting backend + db" >&2
"${COMPOSE[@]}" up -d >&2 "${COMPOSE[@]}" up -d >&2
echo "==> waiting for backend /healthz (up to 90s)" >&2 echo "==> waiting for backend /healthz (up to 90s)" >&2

View file

@ -45,42 +45,93 @@ func TestUnitForCountry(t *testing.T) {
} }
} }
// TestFCountriesMatchesBackend re-parses the backend's F_COUNTRIES (the // parseCountryCodes pulls a set of ISO-3166 alpha-2 codes out of `path` by
// source of truth per both CLAUDE.md files) and asserts our set is identical. // matching `outer` (which must capture the literal's body in group 1) and then
// Skips when the monorepo backend checkout isn't present. // scanning that body for quoted codes. Returns nil when the file isn't present,
func TestFCountriesMatchesBackend(t *testing.T) { // so a caller can skip rather than fail in a checkout/build context that does
path := filepath.Join("..", "..", "..", "..", "backend", "api", "content_payloads.py") // not carry it.
func parseCountryCodes(t *testing.T, path string, outer *regexp.Regexp) map[string]bool {
t.Helper()
raw, err := os.ReadFile(path) raw, err := os.ReadFile(path)
if err != nil { if err != nil {
t.Skipf("backend checkout not present (%v) — parity asserted only against the committed copy", err) return nil
} }
m := regexp.MustCompile(`(?s)F_COUNTRIES\s*=\s*frozenset\(\{(.*?)\}\)`).FindSubmatch(raw) m := outer.FindSubmatch(raw)
if m == nil { if m == nil {
t.Fatalf("could not find F_COUNTRIES in %s", path) t.Fatalf("could not find the country-set literal in %s", path)
} }
codes := regexp.MustCompile(`"([A-Z]{2})"`).FindAllSubmatch(m[1], -1) codes := regexp.MustCompile(`"([A-Z]{2})"`).FindAllSubmatch(m[1], -1)
if len(codes) == 0 { if len(codes) == 0 {
t.Fatalf("no codes parsed from %s", path) t.Fatalf("no codes parsed from %s", path)
} }
backend := map[string]bool{} set := map[string]bool{}
for _, c := range codes { for _, c := range codes {
backend[string(c[1])] = true set[string(c[1])] = true
} }
if len(backend) != len(FCountries) { return set
t.Errorf("F_COUNTRIES size mismatch: backend %d vs ours %d", len(backend), len(FCountries)) }
// assertMatchesFCountries diffs a parsed copy against ours, both directions.
func assertMatchesFCountries(t *testing.T, name string, other map[string]bool) {
t.Helper()
if len(other) != len(FCountries) {
t.Errorf("size mismatch: %s %d vs ours %d", name, len(other), len(FCountries))
} }
for code := range backend { for code := range other {
if !FCountries[code] { if !FCountries[code] {
t.Errorf("backend has %q, ours does not", code) t.Errorf("%s has %q, ours does not", name, code)
} }
} }
for code := range FCountries { for code := range FCountries {
if !backend[code] { if !other[code] {
t.Errorf("ours has %q, backend does not", code) t.Errorf("ours has %q, %s does not", code, name)
} }
} }
} }
// TestFCountriesMatchesBackend re-parses the backend's F_COUNTRIES (the
// source of truth per both CLAUDE.md files) and asserts our set is identical.
// Skips when the monorepo backend checkout isn't present — notably inside
// frontend/Dockerfile's builder stage, whose build context is frontend/ only,
// so backend/ is structurally unreachable there. This check is therefore a
// local/CI-checkout guard, not an image-build one.
func TestFCountriesMatchesBackend(t *testing.T) {
path := filepath.Join("..", "..", "..", "..", "backend", "api", "content_payloads.py")
backend := parseCountryCodes(t, path, regexp.MustCompile(`(?s)F_COUNTRIES\s*=\s*frozenset\(\{(.*?)\}\)`))
if backend == nil {
t.Skip("backend checkout not present — parity asserted only against the committed copy")
}
assertMatchesFCountries(t, "backend", backend)
}
// TestFCountriesMatchesUnitsJS asserts the BROWSER copy — static/units.js's
// F_REGIONS — matches too.
//
// This copy was previously guarded by nothing at all, despite comments in
// backend/api/content_payloads.py, frontend/format.py and format.go each
// claiming "a test asserts all three stay identical": the only check compared
// Go against the backend's Python, and nothing read units.js. The sets happened
// to agree, held in step by convention alone. Client-side unit selection
// disagreeing with server-rendered unit selection means the same page shows °C
// in SSR and °F after hydration — a silent, per-country split.
//
// Unlike the backend check above, static/ IS inside the frontend build context,
// and frontend/Dockerfile copies units.js into the builder stage precisely so
// this runs during the image build — the only place CI executes these tests.
func TestFCountriesMatchesUnitsJS(t *testing.T) {
for _, path := range []string{
filepath.Join("..", "..", "..", "static", "units.js"), // repo checkout
filepath.Join("/", "static", "units.js"), // Dockerfile builder stage
} {
js := parseCountryCodes(t, path, regexp.MustCompile(`(?s)F_REGIONS\s*=\s*new Set\(\[(.*?)\]\)`))
if js != nil {
assertMatchesFCountries(t, "static/units.js", js)
return
}
}
t.Skip("static/units.js not reachable from this working directory")
}
func TestTempSpans(t *testing.T) { func TestTempSpans(t *testing.T) {
// Exact rendered bytes — the golden-diff contract. // Exact rendered bytes — the golden-diff contract.
if got := Temp("F", fp(71.1)); got != `<span class="temp" data-temp-f="71.1">71°F</span>` { if got := Temp("F", fp(71.1)); got != `<span class="temp" data-temp-f="71.1">71°F</span>` {