thermograph/frontend/server/internal/format/bands.go
Emi Griffith 9eecfc8eef
All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 7s
PR build (required check) / build-backend (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 7s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 1m0s
PR build (required check) / gate (pull_request) Successful in 2s
frontend: rewrite the SSR content service in Go
Ports frontend/ (Jinja2/FastAPI, ~1180 LOC) to Go with html/template.
No climate math, no DB, no auth here -- every route fetches from the
backend's /content/* API, so this is I/O-bound glue with no hard-porting
wall; the risk was always in reproducing the rendering exactly, not the
language.

Verified with a golden-HTML diff, not just unit tests: both the Python
original and the Go rewrite were run against the same committed fixtures
(frontend/tests/fixtures/) and every one of the 11 routes compared
byte-for-byte. The only surviving differences after that process are
insignificant inter-tag whitespace and one attribute where Go's stricter
escaper HTML-encodes an apostrophe Jinja left literal (functionally
identical in every browser) -- confirmed programmatically by normalizing
whitespace and unescaping before diffing, not by eyeballing.

That process caught defects unit tests alone would have missed, because
map[string]any has no compile-time field check:

- Render-context keys were snake_case throughout (content.py's Jinja
  convention, ported verbatim) while the templates -- written
  independently -- read PascalCase fields. A missing map key doesn't
  error in html/template, it silently renders empty, so this was invisible
  in every status code and every "it built" signal: title, meta
  description, canonical URL, OpenGraph tags, the homepage's entire ranked
  list, and the brand-tag/nav-active state were all blank across every
  page. Fixed by renaming every key to match each template's own header
  comment (the authoritative per-page field contract) and, where an
  API struct's exported fields already matched what a template needed
  (contentapi.CityInfo, Crumb, HomeRanked, HubCountry, ...), passing the
  struct straight through instead of hand-rewrapping it in a map --
  removes a whole layer of future drift risk, not just this instance of it.
- Three pages 500'd outright: `.ToolHref` needed a fully-composed href
  string, not the bare "lat,lon" fragment the handlers were building; the
  all-time-records table needed the raw contentapi.AllTimeRecords struct,
  not a re-wrapped map.
- JSON-LD was being double-encoded: `<script type="application/ld+json">`
  is JAVASCRIPT context to html/template's contextual escaper regardless
  of the script's `type` attribute, so a template.HTML-typed value placed
  there gets re-escaped as a quoted JS string instead of emitted raw --
  the entire structured-data payload shipped as a JSON string containing
  JSON, which no crawler would parse as the intended object. Needed
  template.JS instead, the type that actually means "trusted JS source."
  The glossary term page's JSON-LD was simply never built at all (the
  Jinja original assembled it inline in the template rather than through
  content.py's context dict, and that got lost in translation) -- added.
- html/template silently strips literal HTML comments AND JavaScript
  comments from the parsed output (verified in isolation, zero template
  actions involved) -- confirmed as real engine behavior, not a bug in
  either port, so both need a FuncMap function returning template.HTML /
  template.JS respectively to survive parsing rather than a literal
  `<!-- -->` or `//` in the template source.

Packaging: multi-stage Go build, final image alpine (not distroless -- the
Swarm stack's env-entrypoint.sh shim needs bash), 187MB -> 22.6MB. Two
defects caught before they reached a host:
- The Swarm stack overrides `entrypoint:` with no `command:`, which drops
  the image's own CMD entirely (Docker/Swarm semantics, not merged) --
  env-entrypoint.sh then fell through to its hardcoded `exec uvicorn
  app:app` fallback, which doesn't exist in this image. Every deploy
  would have exited 127. Fixed with an explicit `command:` on the stack's
  frontend service, and corrected the shim's stale comment claiming CMD
  passes through automatically.
- `COPY --chown=thermograph` resolves the group by NAME at copy time;
  Alpine's `adduser -S` with no `-G` doesn't create a same-named group, so
  the classic (non-BuildKit) Docker builder -- which this CI runner falls
  back to, since it installs plain `docker.io` with no buildx plugin --
  failed outright. Fixed with an explicit group and numeric --chown.

Verification: go build/vet/test -race clean across all packages; the
Docker image builds and passes its embedded go test step under both
BuildKit and the classic builder; shellcheck 0 findings on the one script
touched; rebased onto current main (the ERA5 lake stack landed on both
main and dev during this work -- confirmed additive, no overlap with
frontend/daemon).
2026-07-23 17:51:31 -07:00

71 lines
3.2 KiB
Go

// Percentile grading bands, ported EXACTLY from backend/data/grading.py —
// the cross-repo display contract both this domain's CLAUDE.md and the
// backend's call out. The backend is the source of truth for the tier names
// and thresholds; an off-by-one on any boundary paints a tier a different
// colour than the label the API returned. A test in bands_test.go re-parses
// the backend's grading.py (when the monorepo checkout is present) and
// asserts these tables have not drifted.
package format
// Band is one percentile tier: Threshold is the tier's LOWER bound; a tier
// spans [lower, next-lower) — lower-inclusive, upper-exclusive — except the
// TOP tier, which is strict (pct > threshold): see BandFor.
type Band struct {
Threshold float64
Label string
Class string
}
// TempBands maps a percentile to (grade label, css class) for temperature.
// Higher percentile = warmer. 9 symmetric tiers around the middle 40-60%
// "Normal", escalating to "Near Record" at both edges. Boundaries sit on
// multiples of 5 (plus the 1/99 record edges). Source of truth:
// backend/data/grading.py TEMP_BANDS.
var TempBands = []Band{
{99, "Near Record", "rec-hot"}, // >99 extreme high (danger)
{90, "Very High", "very-hot"}, // 90-99
{75, "High", "hot"}, // 75-90
{60, "Above Normal", "warm"}, // 60-75
{40, "Normal", "normal"}, // 40-60 (the middle)
{25, "Below Normal", "cool"}, // 25-40
{10, "Low", "cold"}, // 10-25
{1, "Very Low", "very-cold"}, // 1-10
{0, "Near Record", "rec-cold"}, // <1 extreme low (danger)
}
// RainBands grades precipitation among days with ANY rain (> 0) in the
// seasonal window — a "rain percentile". Rain is one-directional (heavier =
// more extreme), so these 8 tiers are sequential light->heavy, using the SAME
// cut points as temperature. Dry days (no rain at all) are handled separately
// (the "dry" class, colored by dry streak in the UI). Same [lower, upper)
// convention as TempBands. The eight tiers fill the wet-2..wet-9 colour ramp
// with no gap. Source of truth: backend/data/grading.py RAIN_BANDS.
var RainBands = []Band{
{99, "Extreme", "wet-9"}, // >99 heaviest rain for the season (darkest)
{95, "Severe", "wet-8"}, // 95-99 (top half of the old Very Heavy)
{90, "Very Heavy", "wet-7"}, // 90-95 (lower half)
{60, "Heavy", "wet-6"}, // 60-90
{40, "Typical", "wet-5"}, // 40-60
{25, "Brisk", "wet-4"}, // 25-40
{10, "Light", "wet-3"}, // 10-25
{0, "Trace", "wet-2"}, // <10 the lightest measurable rain
}
// BandFor places a percentile on a band table — the port of grading.py's
// _band. The top tier is strict (pct > its threshold): "Near Record" high
// means strictly beyond the 99th percentile — the top <1% — mirroring the
// strictly-below-1st bottom tier (its lower neighbor already catches
// pct >= 1). Everything in between stays lower-inclusive, upper-exclusive.
func BandFor(pct float64, bands []Band) (label, class string) {
top := bands[0]
if pct > top.Threshold {
return top.Label, top.Class
}
for _, b := range bands[1:] {
if pct >= b.Threshold {
return b.Label, b.Class
}
}
last := bands[len(bands)-1]
return last.Label, last.Class
}