thermograph/frontend/README.md
Emi Griffith b2b56bdc1a frontend: fix the three inconsistencies the onboarding guide found
1. CLAUDE.md and README.md described the superseded Python service. Both now
   describe server/ (Go), say plainly that the Python files at that level are
   the original the port was made from, and drop CLAUDE.md's claim that
   `make test-unit` is "the tier CI runs" — CI's only frontend check is the
   Dockerfile builder stage's gofmt + vet + go test.

2. static/units.js's F_REGIONS was guarded by nothing, despite three source
   comments claiming "a test asserts all three stay identical": the only check
   compared the Go set against the backend's Python. TestFCountriesMatchesUnitsJS
   now diffs the browser copy both directions.

   That backend cross-check also skips in CI — the image build context is
   frontend/, so backend/ is unreachable from the builder stage, which is the
   only place CI runs these tests. static/ IS in the context, so the Dockerfile
   copies units.js into the builder and the new assertion runs during the image
   build. Verified by mutating units.js and confirming the build fails.

3. Both docker-compose.test.yml files defaulted to the retired
   emi/thermograph-backend/app path, and the frontend harness pinned the
   split-era v0.0.2-split-ci tag. Path corrected in both. Rather than swap one
   hardcoded pin for another, backend-for-tests.sh now derives the tag from the
   checkout — sha-<12hex of `git log -1 -- backend/`>, the same domain-keyed
   rule build-push.yml and deploy.yml use — and compose requires the variable
   so a stale pin cannot creep back in.

Verified: backend 429 passed/8 skipped; frontend go vet clean and all packages
ok; frontend image builds; `make backend-up` pulls and serves on the derived
tag; shellcheck zero findings across the tree.

Unrelated pre-existing issue noted in the docs, not fixed here:
`make test-integration` fails 7/16 with 503 against a cold throwaway backend
(empty database, nothing warm). Reproduced identically on the old image, so it
predates this change.

Claude-Session: https://claude.ai/code/session_01AfXqHrxCJLs2D7hpQkiUiJ
2026-07-25 11:43:22 -07:00

6.3 KiB

Thermograph frontend

The server-rendered content pages, the interactive-tool SPA shells, and every static asset for Thermograph — grades recent local 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 assets that call the backend's API from the browser. It builds its own image (emi/thermograph/frontend) and deploys independently of the backend.

See CLAUDE.md for the agent-facing detail on the deploy contract and the cross-service contracts, and 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.

server/                    the service (module thermograph/frontend, Go 1.26)
  main.go                  config + mux + static + graceful shutdown
  internal/config/         every env var it reads
  internal/contentapi/     backend /content/* client: TTL cache, bounded LRU,
                           per-key single-flight, origin forwarding
  internal/content/        SSR page handlers, template funcmap, SEO
  internal/contentdata/    glossary.yaml / pages.yaml loader (fail-loud)
  internal/format/         unit-aware °C/°F formatting + band names
  internal/handlers/       SPA shells + static serving
  internal/render/         html/template over embed.FS + ETag helpers
  internal/render/templates/   the page templates (embedded, *.tmpl)
static/                    every static asset: the interactive tool's JS
                           (app.js, calendar.js, day.js, score.js, compare.js,
                           account.js, cache.js, shared.js, units.js,
                           mappicker.js, …), the SPA-shell HTML pages,
                           style.css (all design tokens — see DESIGN.md),
                           icons/favicons, manifest.webmanifest, sw.js
content/                   structured SSR copy: glossary.yaml, pages.yaml
tests/fixtures/            golden payloads — consumed by the GO tests as well
                           as the Python tiers
tests/{unit,integration}/  the Python tiers (local only; CI runs neither)
tools/shoot.py             the screenshot sweep for visual verification
Dockerfile                 golang builder (gofmt + vet + test) → alpine runtime

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.

Talking to the backend

All data comes from the backend's content API over HTTP — this process holds nothing. Two env vars control the topology:

  • THERMOGRAPH_API_BASE_INTERNAL (required) — where this process reaches the backend server-side, e.g. http://backend:8137 in compose, or http://127.0.0.1:8137 for a local backend. Boot fails loudly if unset: a missing backend URL should break the boot, not silently 500 on the first request.
  • THERMOGRAPH_API_BASE_PUBLIC (optional) — the backend's browser-facing origin, used only when frontend and backend are served cross-origin. Left empty (the default), asset/API references stay relative and same-origin, which is 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 (static/account.js) is an HttpOnly session cookie. It uses credentials: "include" (not the fetch default) specifically so the cookie still rides along if this script is ever loaded cross-origin — but the backend must actually be configured to accept credentialed cross-origin requests (CORS + SameSite) for that case to work. In the default same-origin deployment this is a non-issue. Don't assume a fully decoupled, independently-hosted frontend "just works" for logged-in features without checking that configuration first.

Build & run

Build in server/, run from here — static/ and content/ resolve relative to the working directory, while templates are embedded in the binary:

cd server && go build -o thermograph-frontend .
cd ..
THERMOGRAPH_API_BASE_INTERNAL=http://127.0.0.1:8137 \
THERMOGRAPH_BASE=/thermograph \
  ./server/thermograph-frontend

Or build the image directly:

docker build -t thermograph-frontend .
docker run -p 8080:8080 -e THERMOGRAPH_API_BASE_INTERNAL=http://backend:8137 thermograph-frontend

The image healthchecks GET /healthz (liveness only — it does no I/O, so it stays cheap; it does not prove the backend is reachable).

Test

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:

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

  • CLAUDE.md — agent-facing instructions: build/verify, layout, API-version pinning, and the /cell/ETag, pctOrd() and Fahrenheit-set contracts.
  • server/README.md — the Go port's own notes.
  • DESIGN.md — the visual system (tokens, components, breakpoints) and tools/shoot.py visual verification.
  • docs/onboarding/ — the developer onboarding path for the whole monorepo.