10 KiB
5. Frontend deep dive
The SSR + static-asset service: server-rendered crawlable pages, the interactive tool's SPA shells, and every static asset. No climate data, no database, no compute — everything comes from the backend's content API over HTTP.
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,format.py,content_loader.py,paths.py,templates/*.html.j2) are the superseded original implementation, kept in-tree as the reference the port was made from. Nothing deploys them, and they hold duplicate copies of a couple of contracts (api_client.py'sAPI_VERSIONpin,format.py'sF_COUNTRIES) — keep those in step if you run them locally. Whether to delete them is an open question; raise it rather than doing it in passing.
Shape of the Go service
main.go config → content client → glossary/pages → mux →
listen → graceful shutdown
internal/config/ every env var the service reads, with the same names,
defaults and required/optional split as the Python
internal/contentapi/ the backend /content/* client
internal/content/ the SSR page handlers, template funcmap, SEO helpers
internal/contentdata/ glossary.yaml / pages.yaml loader
internal/format/ unit-aware °C/°F formatting + band names
internal/handlers/ SPA shells + static serving
internal/render/ html/template over an embed.FS + ETag helpers
internal/render/templates/*.tmpl the page templates (embedded in the binary)
Dependencies: stdlib plus gopkg.in/yaml.v3 only. The committed SSR copy
in frontend/content/*.yaml is shared with the rest of the repo and uses
block/folded scalars, so a real YAML parser is genuinely required.
Templates are embedded in the binary; static/ and content/ are read from
disk relative to the working directory (frontend/ locally, /app in the
image). That's why you build in server/ and run from frontend/.
Fail-loud boot
main.go exits at startup on bad configuration or bad content: a missing
backend URL, a garbage TTL, malformed glossary/pages YAML. The Python raised at
import for the same cases, and the reasoning is identical — a bad content edit
should break the boot, not silently 500 on the first request.
The one deliberate exception is the IndexNow key: boot tries the key fetch once, catches failure, and falls back to a lazy per-request lookup. Boot must survive an unreachable backend — asynchronous FE/BE deploys depend on it. Don't make it fatal.
Routes
Everything below sits under THERMOGRAPH_BASE (default /thermograph; the
image sets /).
| Path | Kind |
|---|---|
/ |
SSR homepage |
/climate |
SSR climate hub |
/climate/{slug} |
SSR per-city page |
/climate/{slug}/records |
SSR all-time records |
/climate/{slug}/{month} |
SSR per-month page |
/glossary, /glossary/{term} |
SSR |
/about, /privacy |
SSR |
/robots.txt, /sitemap.xml |
generated |
/{indexnow-key}.txt |
the IndexNow ownership file |
/calendar, /day, /score, /compare, /legend, /alerts |
SPA shells |
/healthz |
liveness, I/O-free (does not prove the backend is reachable) |
| everything else | static assets |
The static mount is registered last so the explicit routes win.
The backend owns the page metadata, not this service. page_title,
canonical_path, breadcrumb and jsonld come from
backend/api/content_payloads.py. Don't recompute them here.
SPA shells and __ORIGIN__
The shell HTML files in static/ carry an __ORIGIN__ placeholder for the
link-preview/Open Graph tags — preview crawlers need absolute URLs and the host
differs between LAN and prod. The substituted HTML and its ETag are memoized
per origin, not recomputed per request.
Origin resolution prefers X-Forwarded-Host over Host. That matters when the
request arrives through the backend's catch-all proxy fallback: without it, the
page would advertise the internal hop's own address as canonical.
The content client
internal/contentapi is a careful piece of code and worth reading in full
before you change caching behaviour. It has four properties, all load-bearing:
- TTL cache (
THERMOGRAPH_SSR_CACHE_TTL, default 600s). Climate data changes at most hourly, so without this a page-view burst on one city costs one backend round trip per request. This is in addition to the backend's derived-store/ETag caching — this one saves the network hop, the backend's saves the recompute. - Bounded LRU (2048 entries).
city()andcity_records()fold the browser-facingorigininto the cache key, andoriginis client-controlled viaHost/X-Forwarded-Host. An unbounded map there is a cheap memory-exhaustion vector. - Per-key single-flight. Without it, N concurrent requests missing the same key (cold start, or right after TTL expiry on a hot city) each fire their own backend call — a thundering herd.
- Origin forwarding. The original browser-facing origin is passed through as
Host/X-Forwarded-Protoso the backend builds JSON-LDurlfields against the public origin rather than the internalhttp://backend:8137address.
Static assets
Hand-written, no build step, no bundler, no framework.
| File | Role |
|---|---|
app.js |
the main tool: map, search, results, inline SVG chart |
calendar.js / day.js / score.js / compare.js |
the SPA views |
cache.js |
IndexedDB cache + the /cell bundle prefetch + If-None-Match revalidation |
account.js |
auth; pins API_VERSION and the uv(path) helper |
shared.js |
shared helpers; pctOrd() mirrors the backend's pct_ordinal() |
units.js |
F_REGIONS — must match the backend's F_COUNTRIES |
chart.js, mappicker.js, filtersheet.js, nav.js, push-client.js, ios-install.js, digest.js, climate.js, subscriptions.js |
the rest |
style.css |
the single hand-written stylesheet — every design token lives here |
sw.js, manifest.webmanifest, icons |
PWA |
cache.js is where the ETag contract actually lands: it fetches
/api/v2/cell once per view-set, slices it, and later revalidates each view
with If-None-Match. The slice→view map must track the backend's payload
shape. See contracts.
Static responses are served with Cache-Control: public, max-age=300 — short
on purpose, because asset filenames are not content-hashed. It's a
revalidation window, not an immutable cache.
The design system
frontend/DESIGN.md is the source of truth and
wins over any general design guidance. The essentials:
- Never hardcode a colour. Every colour is a CSS custom property in
style.css's:root, with a@media (prefers-color-scheme: light)override right below. Alwaysvar(--token). - Grade palettes encode meaning, not decoration. Temperature is a 9-step
diverging, colourblind-safe scale (
--rec-cold…--normalgreen midpoint …--rec-hot). Precipitation is--dryplus--wet-1…9. Keep the order and midpoints intact. - Inter is not self-hosted. It renders where the OS has it and falls back to
system-ui. Adding a webfont link is a deliberate decision, not a tweak — it's a network dependency on every page. - Inputs are
font-size: 16pxminimum. Smaller text makes iOS zoom on focus. Hard rule. - Mobile-first. The base stylesheet is the phone layout; wider screens layer
on via
min-width. Primary breakpoint 640/641px. The content column widens in real steps at 1680 / 2400 / 3400px — it does not stay a centred 1200px strip. - Metric order is always
Precip · High · Low, everywhere. - Dark is the default, light comes from
prefers-color-scheme. Every change must look right in both. Honourprefers-reduced-motion. - Charts are bespoke inline SVG. No canvas, no charting library.
Verifying a visual change
Validate at 390 / 800 / 1920 / 2560 / 3840px in both light and dark. These straddle every breakpoint above.
cd frontend
.venv/bin/pip install -q -r tools/requirements.txt
.venv/bin/python -m playwright install chromium
.venv/bin/python tools/shoot.py # the full matrix
.venv/bin/python tools/shoot.py index --width 390 --scheme dark # while iterating
PNGs land in .screenshots/ (gitignored) as {page}@{width}-{scheme}.png.
Point it elsewhere with SHOTS_BASE. Chrome DevTools MCP is the other good
option here — a real browser on your own machine.
Tests
cd frontend/server && go build ./... && go vet ./... && go test ./...
The Go tests read two directories via a fixed relative path: the committed
golden fixtures (frontend/tests/fixtures/*.json — the same set the Python
golden-diff comparison used) and the SSR copy (frontend/content/*.yaml).
frontend/Dockerfile's builder stage copies them to container-root paths to
preserve that relationship, and runs gofmt -l + go vet + go test before
building. A failing Go test therefore fails the image build, which is how CI
catches it — there is no separate frontend test step in the workflows.
The Python tiers still exist and still run locally:
cd frontend
make test-unit # hermetic: SSR rendering fed committed fixtures
make test-integration # pulls + runs the real backend image; local only
make capture-fixtures # refresh tests/fixtures/*.json from a live backend
Treat make capture-fixtures as the shared operation: those fixtures feed the
Go tests too.
The image
python:3.12-slim → gone. The frontend image is:
- a
golang:1.26builder that runsgofmt -l/go vet/go test, then builds a staticCGO_ENABLED=0 -trimpath -ldflags="-s -w"binary; - an
alpine:3.22final stage with bash (the Swarm stack bind-mounts a bash entrypoint shim over the image's entrypoint) and curl (the healthcheck); - uid 10001, matching the backend image — that's the uid infra provisions readable secrets for. Don't change it.
static/andcontent/copied to/app, read-only at runtime.
There is no WORKERS knob any more: uvicorn needed a process count, the Go
server handles concurrency in one process.
Next: Cross-service contracts.