thermograph/frontend/server/internal/format/format.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

362 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package format is unit-aware display formatting for the SSR templates —
// the Go port of format.py (itself ported from backend/web/content.py,
// unchanged in behavior: still markup-wrapped spans for the templates,
// operating on the raw floats the content API returns).
//
// One deliberate structural change from the Python: format.py scoped the
// active display unit in a ContextVar (`unit_scope`) so template globals
// could read it implicitly. html/template FuncMaps are engine-global and
// parsed once, so an ambient mutable unit would be a data race across
// requests — instead every unit-dependent function here takes the Unit as an
// explicit first argument, and the page handlers thread it through the render
// context (the reasoning the Python gave for the ContextVar — "a silently
// wrong unit at the one site someone forgets" — is answered in Go by the
// compiler: forgetting the argument fails the build, not the user).
package format
import (
"encoding/json"
"fmt"
"html/template"
"math"
"strconv"
"strings"
)
// Unit is the active display unit: "C", "F", or "" (unset — the Python's
// ContextVar default None). Anything other than "C" formats as Fahrenheit,
// exactly like the Python's `_UNIT.get() == "C"` checks; "" additionally
// means "no data-unit-default attribute on <body>".
type Unit string
// FCountries is the set of countries that actually use Fahrenheit. Mirrors
// backend/api/content_payloads.py's F_COUNTRIES / frontend static/units.js's
// F_REGIONS — a test asserts the backend copy stays identical (the backend
// has its own test asserting the same across its surfaces).
var FCountries = map[string]bool{
"US": true, "PR": true, "GU": true, "VI": true, "AS": true, "MP": true,
"UM": true, "BS": true, "BZ": true, "KY": true, "PW": true, "FM": true,
"MH": true, "LR": true,
}
// Unit-conversion factors (format.py's MM_PER_IN / KMH_PER_MPH).
const (
MMPerIn = 25.4
KmhPerMph = 1.609344
)
// tempTiers are the absolute-temperature colour tiers (°F upper bounds), the
// same 9 tiers the interactive grader uses (format.py's _TEMP_TIERS). Note
// these are ABSOLUTE °F cut points for painting cells — distinct from the
// percentile TEMP_BANDS in bands.go, which grade a day against its own
// history.
var tempTiers = []struct {
upper float64
class string
}{
{20, "rec-cold"}, {32, "very-cold"}, {45, "cold"}, {58, "cool"},
{70, "normal"}, {80, "warm"}, {90, "hot"}, {100, "very-hot"},
}
// Shared axis for the month range bars (format.py's _AXIS_LO/_AXIS_HI).
const (
axisLo = -10.0
axisHi = 115.0
)
// UnitForCountry maps an ISO country code to its display unit: "F" for the
// Fahrenheit-using set, "C" everywhere else (empty/unknown codes are "C").
func UnitForCountry(code string) Unit {
if FCountries[strings.ToUpper(code)] {
return "F"
}
return "C"
}
// RoundHalfUp rounds like JS's Math.round — floor(x + 0.5) — not Go's
// math.Round (which rounds halves away from zero) nor Python's round()
// (halves to even), so the server and climate.js's repaint never visibly
// disagree. Negative halves round UP (-0.5 -> 0), matching Math.round.
func RoundHalfUp(x float64) int {
return int(math.Floor(x + 0.5))
}
// cFromF converts °F to whole °C, rounding half-up (format.py's _c).
func cFromF(f float64) int {
return RoundHalfUp((f - 32) * 5 / 9)
}
// shown is the integer temperature to display in the active unit.
func shown(u Unit, f float64) int {
if u == "C" {
return cFromF(f)
}
return RoundHalfUp(f)
}
// letter is the displayed unit letter — "C" only when the active unit is
// Celsius, "F" otherwise (including the unset unit, like the Python).
func letter(u Unit) string {
if u == "C" {
return "C"
}
return "F"
}
// Temp renders a temperature span: the data-temp-f attribute always carries
// the raw °F value (one decimal) for the client-side converter, while the
// visible text is in the active unit. nil renders the em-dash placeholder.
func Temp(u Unit, f *float64) template.HTML {
if f == nil {
return "—"
}
return template.HTML(fmt.Sprintf(`<span class="temp" data-temp-f="%.1f">%d°%s</span>`,
*f, shown(u, *f), letter(u)))
}
// TempBare is Temp without the unit letter (and with data-bare for the
// converter): the range strip prints "58°71°".
func TempBare(u Unit, f *float64) template.HTML {
if f == nil {
return "—"
}
return template.HTML(fmt.Sprintf(`<span class="temp" data-temp-f="%.1f" data-bare>%d°</span>`,
*f, shown(u, *f)))
}
// TempText is the plain-text temperature ("71°F"), no span.
func TempText(u Unit, f *float64) string {
if f == nil {
return "—"
}
return fmt.Sprintf("%d°%s", shown(u, *f), letter(u))
}
// Precip renders a precipitation span; data-precip-in carries the raw inches
// (three decimals) for the converter.
func Precip(u Unit, v *float64) template.HTML {
if v == nil {
return "—"
}
return template.HTML(fmt.Sprintf(`<span class="precip" data-precip-in="%.3f">%s</span>`,
*v, PrecipText(u, v)))
}
// PrecipText is the plain-text precipitation: whole millimetres for Celsius
// locales, hundredths of an inch otherwise.
func PrecipText(u Unit, v *float64) string {
if v == nil {
return "—"
}
if u == "C" {
return fmt.Sprintf("%d mm", RoundHalfUp(*v*MMPerIn))
}
return fmt.Sprintf("%.2f in", *v)
}
// Wind renders a wind-speed span; data-wind-mph carries the raw mph.
func Wind(u Unit, v *float64) template.HTML {
if v == nil {
return "—"
}
return template.HTML(fmt.Sprintf(`<span class="wind" data-wind-mph="%.1f">%s</span>`,
*v, WindText(u, v)))
}
// WindText is the plain-text wind speed: km/h for Celsius locales, mph
// otherwise — whole numbers both ways.
func WindText(u Unit, v *float64) string {
if v == nil {
return "—"
}
if u == "C" {
return fmt.Sprintf("%d km/h", RoundHalfUp(*v*KmhPerMph))
}
return fmt.Sprintf("%d mph", RoundHalfUp(*v))
}
// tempMetrics are the metric keys formatted as temperatures (format.py's
// _TEMP_METRICS).
var tempMetrics = map[string]bool{"tmax": true, "tmin": true, "feels": true}
// Fmt dispatches a metric value to its formatter (format.py's fmt):
// temperatures and precip get their converter spans, humidity is a plain
// absolute figure, and everything else (wind, gust) formats as wind speed.
func Fmt(u Unit, metric string, v *float64) template.HTML {
if v == nil {
return "—"
}
if tempMetrics[metric] {
return Temp(u, v)
}
switch metric {
case "precip":
return Precip(u, v)
case "humid":
return template.HTML(fmt.Sprintf("%.1f g/m³", *v))
}
return Wind(u, v) // wind, gust
}
// TempClass is the diverging-palette tier name for an absolute Fahrenheit
// temperature (or "none" for a missing value). Each tier's bound is an
// EXCLUSIVE upper edge (f < upper), so exactly 20°F is "very-cold", not
// "rec-cold"; at or above the last bound is "rec-hot".
func TempClass(f *float64) string {
if f == nil {
return "none"
}
for _, t := range tempTiers {
if *f < t.upper {
return t.class
}
}
return "rec-hot"
}
// PctOrdinal renders a percentile as a display ordinal: 66.4 -> "66th",
// 99.6 -> "99th". Floored into 1..99 — an empirical percentile is a rank
// against the sample, so rounding can land on 100 (or 0), and "100th
// percentile" reads as a measurement error rather than "as extreme as it has
// ever been"; the band label ("Near Record") carries the how-extreme part.
//
// Mirrors backend/data/grading.py's pct_ordinal, which stays canonical for
// the in-process surfaces (and static/shared.js's pctOrd client-side); this
// is the SSR-side copy, same formula. floor(x + 0.5), NOT round-half-even:
// Python's round() gives 16 for 16.5 while JavaScript's Math.round gives 17 —
// the same reading would then read "16th" server-side and "17th" client-side.
//
// A non-numeric or missing value renders the em-dash placeholder, like the
// Python's caught TypeError/ValueError (NaN included: math.floor raised
// ValueError on it). Infinities clamp into the 1..99 range (the Python would
// have crashed on OverflowError; they cannot occur in a percentile).
func PctOrdinal(pct any) string {
f, ok := toFloat(pct)
if !ok || math.IsNaN(f) {
return "—"
}
var n int
switch {
case math.IsInf(f, 1):
n = 99
case math.IsInf(f, -1):
n = 1
default:
n = RoundHalfUp(f)
}
n = min(99, max(1, n))
suffix := "th"
if !(10 <= n%100 && n%100 <= 20) {
switch n % 10 {
case 1:
suffix = "st"
case 2:
suffix = "nd"
case 3:
suffix = "rd"
}
}
return strconv.Itoa(n) + suffix
}
// toFloat coerces the loosely-typed values templates hand the ordinal filter
// (payload float64s, nullable *float64s, json.Number, template int literals,
// strings — everything Python's float(pct) accepted).
func toFloat(v any) (float64, bool) {
switch x := v.(type) {
case nil:
return 0, false
case float64:
return x, true
case float32:
return float64(x), true
case *float64:
if x == nil {
return 0, false
}
return *x, true
case int:
return float64(x), true
case int64:
return float64(x), true
case json.Number:
f, err := x.Float64()
return f, err == nil
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(x), 64)
return f, err == nil
}
return 0, false
}
// AsFloatPtr coerces the loosely-typed values templates hand the formatter:
// nullable *float64 payload fields, plain float64s, and int/float literals
// (the template port writes {{.Fmt.Temp -10.0}} where Jinja had temp(-10)).
// nil (and a nil *float64) mean "missing" — rendered as the em-dash.
func AsFloatPtr(v any) *float64 {
switch x := v.(type) {
case nil:
return nil
case *float64:
return x
case float64:
return &x
case int:
f := float64(x)
return &f
}
return nil
}
// Formatter is the request-scoped, unit-bound formatter the render data
// carries as .Fmt — the Go stand-in for format.py's ContextVar unit scope.
// html/template FuncMaps are engine-global, so the per-request unit rides on
// the data instead, and templates call {{.Fmt.Temp .HighF}} where Jinja had
// {{ temp(m.high_f) }}.
type Formatter struct {
Unit Unit
}
// NewFormatter binds a formatter to the page's active unit ("" behaves as
// Fahrenheit, like the Python's unset ContextVar).
func NewFormatter(u Unit) *Formatter { return &Formatter{Unit: u} }
// Temp is the template-facing Temp (accepts *float64 fields and literals).
func (f *Formatter) Temp(v any) template.HTML { return Temp(f.Unit, AsFloatPtr(v)) }
// TempBare is the template-facing TempBare.
func (f *Formatter) TempBare(v any) template.HTML { return TempBare(f.Unit, AsFloatPtr(v)) }
// TempClass is the template-facing TempClass (unit-independent — absolute °F
// tiers — but lives on the formatter so templates have one formatting seam).
func (f *Formatter) TempClass(v any) string { return TempClass(AsFloatPtr(v)) }
// RangeBar is the geometry for one month's low->high bar on the shared
// -10..115°F axis: left offset + width as percentages, and the tier colour at
// each end. Left/Width are pre-formatted strings (Python rendered
// round(x, 1) floats, which always print with one decimal — "42.0", not
// "42" — so formatting here keeps the rendered bytes identical).
type RangeBar struct {
Left string // e.g. "48.8"
Width string // e.g. "12.4"; never below "2.0" (a sliver stays visible)
C1 string // tier colour at the low end (from the UNCLAMPED low)
C2 string // tier colour at the high end
}
// MonthRangeBar computes the bar, or nil when either end is missing
// (format.py's range_bar). The endpoints are clamped into the axis for
// geometry, but the colours come from the raw values.
func MonthRangeBar(lowF, highF *float64) *RangeBar {
if lowF == nil || highF == nil {
return nil
}
lo := math.Max(axisLo, math.Min(axisHi, *lowF))
hi := math.Max(axisLo, math.Min(axisHi, *highF))
const span = axisHi - axisLo
return &RangeBar{
Left: strconv.FormatFloat((lo-axisLo)/span*100, 'f', 1, 64),
Width: strconv.FormatFloat(math.Max(2.0, (hi-lo)/span*100), 'f', 1, 64),
C1: TempClass(lowF),
C2: TempClass(highF),
}
}