From 9eecfc8eefc48b8dc4a88e8e84891fe2d9d41a53 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Thu, 23 Jul 2026 17:49:49 -0700 Subject: [PATCH] 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: `") + if err != nil { + t.Fatal(err) + } + var buf strings.Builder + if err := tmpl.Execute(&buf, nil); err != nil { + t.Fatal(err) + } + want := "" + if got := buf.String(); got != want { + t.Errorf("jscomment through a real template:\n got %q\nwant %q", got, want) + } +} + +func TestHeadVerifyHTML(t *testing.T) { + if headVerifyHTML("", "") != "" { + t.Error("no tokens -> empty") + } + got := headVerifyHTML("gtok", "btok") + want := "\n " + + "" + if string(got) != want { + t.Errorf("head_verify: %q", got) + } + // Attribute escaping matches markupsafe. + if !strings.Contains(string(headVerifyHTML(`a"b`, "")), "a"b") { + t.Error("token not attribute-escaped") + } +} + +func TestToJSON(t *testing.T) { + // u builds a backslash-u escape the way json.dumps/htmlsafe_json_dumps + // emit them (lowercase hex, four digits). + u := func(r rune) string { return fmt.Sprintf(`\u%04x`, r) } + cases := []struct { + in, want string + }{ + {"Feels-like temperature", `"Feels-like temperature"`}, + // Jinja's htmlsafe |tojson escapes the HTML-unsafe <, >, &, '. + {`a&'c`, `"a` + u('<') + `b` + u('>') + u('&') + u('\'') + `c"`}, + // ...and ensure_ascii turns non-ASCII into escapes. + {"50°F", `"50` + u('°') + `F"`}, + {"±7", `"` + u('±') + `7"`}, + } + for _, c := range cases { + got, err := toJSON(c.in) + if err != nil { + t.Fatal(err) + } + if string(got) != c.want { + t.Errorf("toJSON(%q) = %s, want %s", c.in, got, c.want) + } + } +} + +func TestFuncMapHelpers(t *testing.T) { + fm := FuncMap(testConfig()) + temp := fm["temp"].(func(any, any) template.HTML) + // Jinja's {{ temp(-10) }} literal (int) and a nullable payload pointer. + if got := temp("C", -10); got != `-23°C` { + t.Errorf("temp int literal: %q", got) + } + if got := temp("", (*float64)(nil)); got != "—" { + t.Errorf("temp nil pointer: %q", got) + } + tc := fm["temp_class"].(func(any) string) + if tc(nil) != "none" { + t.Error("temp_class nil") + } + ord := fm["ordinal"].(func(any) string) + if ord(89.1) != "89th" { + t.Error("ordinal") + } +} diff --git a/frontend/server/internal/content/funcmap.go b/frontend/server/internal/content/funcmap.go new file mode 100644 index 0000000..c1ec52c --- /dev/null +++ b/frontend/server/internal/content/funcmap.go @@ -0,0 +1,177 @@ +package content + +import ( + "fmt" + "html/template" + "strings" + "unicode/utf16" + + "thermograph/frontend/internal/config" + "thermograph/frontend/internal/format" +) + +// FuncMap builds the template helper set (the Jinja globals/filters +// content.py registered on its Environment). Pass it to render.New at boot — +// html/template resolves names at parse time. +// +// Unit-dependent helpers (temp, temp_bare) take the active unit as their +// FIRST argument: the Python scoped it in a ContextVar, but html/template +// FuncMaps are engine-global, so per-request state must arrive through the +// call. Every page context carries the active unit under the "unit" key +// (same value as "unit_default"), so template call sites are +// {{temp $.unit .high_f}} where Jinja had {{ temp(m.high_f) }}. +func FuncMap(cfg config.Config) template.FuncMap { + return template.FuncMap{ + // temp / temp_bare / temp_class accept the loosely-typed values the + // templates hand them: nullable *float64 payload fields, plain + // float64s, and int literals (Jinja's {{ temp(-10) }}). + "temp": func(unit, f any) template.HTML { + return format.Temp(unitArg(unit), floatArg(f)) + }, + "temp_bare": func(unit, f any) template.HTML { + return format.TempBare(unitArg(unit), floatArg(f)) + }, + "temp_class": func(f any) string { + return format.TempClass(floatArg(f)) + }, + // The `ordinal` filter: {{ c.percentile|ordinal }} -> {{ordinal .percentile}}. + "ordinal": format.PctOrdinal, + // Search-engine ownership-verification tags, from env (empty + // when unset). No backend dependency — the same two variables the + // Python read, resolved once into config. + "head_verify": func() template.HTML { + return headVerifyHTML(cfg.GoogleVerify, cfg.BingVerify) + }, + // Jinja's |tojson (glossary_term.html.j2's inline JSON-LD): JSON with + // the HTML-unsafe characters escaped so it can sit inside . html/template's + // contextual escaper treats . html/template's + // contextual escaper treats . html/template's + // contextual escaper treats + + +{{end}} +{{define "base_tail" -}} + + + +{{end}} +{{/* Shared breadcrumb nav (each Jinja page repeated this line verbatim; the + output is identical, so it is factored here). The separator is emitted + BEFORE every non-first crumb — same byte stream as Jinja's + `{% if not loop.last %}` after-each-but-last. Crumb.Href is *string + (terminal crumb: null). */}} +{{define "breadcrumb" -}} + +{{end}} \ No newline at end of file diff --git a/frontend/server/internal/render/templates/city.html.tmpl b/frontend/server/internal/render/templates/city.html.tmpl new file mode 100644 index 0000000..1166acd --- /dev/null +++ b/frontend/server/internal/render/templates/city.html.tmpl @@ -0,0 +1,101 @@ +{{/* Ported from templates/city.html.j2. Extra data (built by the handler + inside unit_scope(default_unit), as content.py's city_page did): + .Breadcrumb; .City (needs .Slug); .Display/.Name string; + .YearRange [2]int; .NYears int; + .Months []{Name, Slug string; HighF, LowF, RangeLoF, RangeHiF *float64; + High, Low, Precip template.HTML; + Bar *{Left, Width string; C1, C2 string}} + — Bar.Left/Width are STRINGS pre-formatted to Python's repr of + round(x, 1) ("36.0", not "36"): Go's %v drops the ".0" and the golden + diff would catch it; + .Warmest/.Coldest/.Wettest *month (entries of .Months, or nil); + .Records contentapi.AllTimeRecords (raw floats; .Fmt.Temp in-template); + .Today *{Date string; Cards []{Label, Grade, Cls string; + Value template.HTML; Percentile *float64}}; + .Flavor *{Extract string; URL string}; .Event *{Text string; URL string}; + .ToolHref string (full "{base}/#{lat:.5f},{lon:.5f}" href — composed + inline, html/template's fragment urlEscaper would %-escape the comma); + .CompareURL string ("{base}/compare#loc={lat:.4f},{lon:.4f}"); + .JSONLDStr template.JS (compacted raw payload bytes — the |safe site; + trusted backend-owned JSON-LD, never re-marshaled through a Go map). */}}{{template "base_head_start" .}}{{template "base_head_end" .}}{{template "base_body_start" .}}{{template "breadcrumb" .}} +
+

{{.Display}} climate

+

Average temperatures, precipitation and all-time records for {{.Display}}, + with every day graded against ~{{.NYears}} years of local climate history + ({{index .YearRange 0}}–{{index .YearRange 1}}).{{if and .Warmest .Coldest}} Its warmest month is + {{.Warmest.Name}}, averaging a high of {{.Warmest.High}}, and its coldest is + {{.Coldest.Name}}, with an average low of {{.Coldest.Low}}.{{end}}

+ +{{if .Flavor}}

{{.Flavor.Extract}}{{if .Flavor.URL}} + via Wikipedia{{end}}

+{{end}} +{{if .Today}}
+

How today compares

+

Latest recorded day: {{.Today.Date}}. Each reading placed on {{.Display}}'s + own ±7-day seasonal distribution.

+
+{{range .Today.Cards}}
+ {{.Label}} + {{.Value}} +{{if .Percentile}}{{.Grade}} · {{ordinal .Percentile}} pct +{{else}}{{.Grade}}{{end}}
+{{end}}
+

Open {{.Name}} in the live weather grader →

+
+{{end}} +{{if .Event}}
+

Notable weather in {{.Name}}

+

{{.Event.Text}}{{if .Event.URL}} Read more →{{end}}

+
+{{end}} +
+

{{.Name}} average temperatures by month

+

Typical daily high and low, and average precipitation, for each month in {{.Display}}, + based on {{index .YearRange 0}}–{{index .YearRange 1}} records. +{{if .Wettest}}The wettest month is {{.Wettest.Name}} ({{.Wettest.Precip}} on an average day).{{end}}

+
+ + + +{{range .Months}} + + + + + +{{end}} +
MonthAvg highAvg lowAvg precip
{{.Name}}{{.High}}{{.Low}}{{.Precip}}
+
+ + +

Each bar spans the 10th-percentile daily low to the 90th-percentile daily + high (the range most days fall within), coloured cold → hot on a shared + {{.Fmt.Temp -10.0}} to {{.Fmt.Temp 115.0}} scale, so the whole year's rhythm reads at a glance.

+
+ +
+

Thinking of visiting {{.Name}}?

+

Trips live and die by the weather. See how {{.Name}}'s comfort stacks up against where + you live. {{.Name}} is pre-loaded, just add your city.

+

Compare {{.Name}}'s comfort with your city →

+
+ +{{if or .Records.Tmax .Records.Tmin}}
+

Record extremes

+
    +{{if .Records.Tmax}}
  • Hottest day on record: {{.Fmt.Temp .Records.Tmax.Max}} on {{.Records.Tmax.MaxDate}}
  • {{end}}{{if .Records.Tmin}}
  • Coldest day on record: {{.Fmt.Temp .Records.Tmin.Min}} on {{.Records.Tmin.MinDate}}
  • {{end}}
+

All {{.Name}} weather records →

+
+{{end}} +

Percentiles compare each day to {{.Display}}'s own history, so grades are + relative to this location: a “Near Record” day here isn't the same temperature as elsewhere. + How the grades work →

+
+{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}} \ No newline at end of file diff --git a/frontend/server/internal/render/templates/glossary.html.tmpl b/frontend/server/internal/render/templates/glossary.html.tmpl new file mode 100644 index 0000000..4c730b2 --- /dev/null +++ b/frontend/server/internal/render/templates/glossary.html.tmpl @@ -0,0 +1,10 @@ +{{/* Ported from templates/glossary.html.j2. Extra data: .Breadcrumb, + .Terms []{Slug, Term, Short string} (glossary.yaml file order). */}}{{template "base_head_start" .}}{{template "base_head_end" .}}{{template "base_body_start" .}}{{template "breadcrumb" .}}
+

Weather & climate glossary

+

Plain-language definitions of the terms Thermograph uses to grade the weather.

+{{range .Terms}}
+

{{.Term}}

+

{{.Short}}

+
+{{end}}
+{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}} \ No newline at end of file diff --git a/frontend/server/internal/render/templates/glossary_term.html.tmpl b/frontend/server/internal/render/templates/glossary_term.html.tmpl new file mode 100644 index 0000000..a1fd2d4 --- /dev/null +++ b/frontend/server/internal/render/templates/glossary_term.html.tmpl @@ -0,0 +1,21 @@ +{{/* Ported from templates/glossary_term.html.j2. Extra data: + .Breadcrumb; .Term string; .Others []{Slug, Term string}; + .JSONLDStr template.JS — the handler builds the WHOLE DefinedTerm object + (the Jinja original interpolated `term|tojson` / `page_description|tojson` + / base_url inline; Go's JS-context escaping differs byte-wise from + markupsafe's tojson, so the handler reproduces Python htmlsafe-tojson — + ensure_ascii \uXXXX escapes plus <,>,&,' → <,>,&,' — + and template.JS passes it through untouched); + .Body template.HTML — glossary yaml `body` with {base} substituted. This + is the Jinja `| safe` site: the content is trusted, committed copy from + frontend/content/glossary.yaml, not user or API input. */}}{{template "base_head_start" .}}{{template "base_head_end" .}}{{template "base_body_start" .}}{{template "breadcrumb" .}} +{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}} \ No newline at end of file diff --git a/frontend/server/internal/render/templates/home.html.tmpl b/frontend/server/internal/render/templates/home.html.tmpl new file mode 100644 index 0000000..2ce3918 --- /dev/null +++ b/frontend/server/internal/render/templates/home.html.tmpl @@ -0,0 +1,182 @@ +{{/* Ported from templates/home.html.j2. + + The Weekly view — the product itself — with the distribution surfaces built + around it. Everything structural is server-rendered so crawlers and no-JS + readers get a complete page; app.js hydrates the live numbers in place. + + The interactive controls below (#find-btn, #date-input, #panel, #results, …) + are app.js's DOM contract, carried over verbatim from the old static + frontend/index.html. Do not rename these ids. + + Section / BrandTag ("p") / MainClass ("") come from the route handler's + render data, not the template — the SEO pages already pass Section that way. + + Extra data: .Unusual *contentapi.HomeUnusual; .Stale bool; + .Ranked []contentapi.HomeRanked (Lat/Lon are json.Number, printed raw); + .Cities []contentapi.HomeCity; + .JSONLDStr template.JS — _HOME_JSONLD + "url" appended, serialized like + Python's plain json.dumps: ", "/": " separators, ensure_ascii=True. */}}{{template "base_head_start" .}} +{{template "base_head_end" .}} +{{template "base_body_start" .}}{{/* --- Hero ------------------------------------------------------------ + The grade card is the LCP element: its frame is server-rendered and its + slots are sized here, so hydrating the live numbers causes no layout + shift. Band colour is always paired with the band word — colour is never + the only signal. */}}
+
+

How unusual is your weather?

+

Any day, anywhere on Earth, graded against 45 years of that place's own history.

+
+ +
{{/* --cat carries the band colour, the same convention .normal-card uses, + so the band token stays the single source of truth. */}} +
+

+ {{- if .Unusual -}} + Today in {{.Unusual.Display}} + {{- else -}} + Today + {{- end -}} +

+

{{if .Unusual}}{{.Unusual.Grade}}{{else}}—{{end}}

+

+ {{- if .Unusual -}} + {{.Unusual.MetricLabel}} in the {{ordinal .Unusual.Percentile}} percentile for {{.Unusual.WindowLabel}} + {{- else -}} + Pick a place to see how unusual its weather is today. + {{- end -}} +

+{{if and .Unusual .Unusual.IsDefault}}

the most unusual weather we're tracking{{if .Stale}} · as of {{.Unusual.Date}}{{end}}

+{{end}}
+
{{/* app.js hides the locate button outside a secure context, where the + browser refuses geolocation and it could only ever fail. */}} + + +
+ +

See your week ↓

+
+
+ +
+
+ + +
+
+ + + What do the grades mean? → +
+
+ +
+
+

Find a location to begin.

+

Thermograph fetches decades of daily highs, lows, and precipitation + for your ~4 sq mi cell, builds a ±7-day seasonal distribution for each + day of the year, and grades recent weather by where it falls on that distribution.

+
+ +
+ +{{/* Sits outside #panel on purpose: app.js rewrites results.innerHTML + wholesale on every grade, so anything inside it would be destroyed. */}}

+ Free. No ads. No tracking. No account needed. + How we work → +

+ +{{/* --- Unusual right now ---------------------------------------------- + Scroll-snap row, no JS carousel. Both tails are represented whenever a + cold-tail city qualifies — two-sided honesty is product identity. */}}{{if .Ranked}}
+

Unusual right now

+ +

See all records →

+
+{{end}} +{{/* --- How it works ---------------------------------------------------- */}}
+

How it works

+
    +
  1. We keep 45 years of climate history (ERA5 reanalysis) for your exact ~2-mile grid square.
  2. +
  3. Today gets compared to the same two weeks of the year, every year back to 1980.
  4. +
  5. The result is a percentile and a plain-language grade, from Near Record cold to Near Record hot.
  6. +
+

+ Full methodology → + · Data: ERA5 via Open-Meteo · NASA POWER · MET Norway +

+
+ +{{/* --- Explore --------------------------------------------------------- */}}
+

Explore

{{/* Each card takes a colour from the grade ramp rather than a decorative + palette, so the section reads as part of the product's own scale. + Calendar carries the ramp itself — it IS the heatmap. */}} + +
+ +{{/* --- City hub links -------------------------------------------------- + Real HTML links funnelling homepage authority into the ~1000-page + /climate surface. */}}
+

Climate context for 1,000 cities

+ +

Browse all cities →

+
+{{template "base_body_end" .}} + {{/* The records strip renders real temperatures server-side as .temp spans, so + it needs the same converter the SEO pages use or they'd stay in °F while + the toggle says °C. Both import units.js, which the module loader dedupes. */}} + +{{template "base_tail" .}} \ No newline at end of file diff --git a/frontend/server/internal/render/templates/hub.html.tmpl b/frontend/server/internal/render/templates/hub.html.tmpl new file mode 100644 index 0000000..082fd0c --- /dev/null +++ b/frontend/server/internal/render/templates/hub.html.tmpl @@ -0,0 +1,80 @@ +{{/* Ported from templates/hub.html.j2. Extra data: .Breadcrumb; + .NCities/.NCountries int; .Groups — MUST be the ordered + []contentapi.HubCountry slice from the hub payload, never a Go map + (template range over a map sorts keys; Python's dict kept the backend's + order, and the golden diff would catch the reorder). */}}{{template "base_head_start" .}}{{template "base_head_end" .}}{{template "base_body_start" .}}{{template "breadcrumb" .}}
+

City climates

+

Average temperatures by month, all-time records, and how today compares, for + {{.NCities}} cities across {{.NCountries}} countries. Every day is graded against that + location's own ~45 years of climate history.

+ + + + +
+{{range .Groups}}
+ {{.Country}} {{len .Cities}} + +
+{{end}}
+
+ + +{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}} \ No newline at end of file diff --git a/frontend/server/internal/render/templates/month.html.tmpl b/frontend/server/internal/render/templates/month.html.tmpl new file mode 100644 index 0000000..515d9a0 --- /dev/null +++ b/frontend/server/internal/render/templates/month.html.tmpl @@ -0,0 +1,60 @@ +{{/* Ported from templates/month.html.j2. Extra data (all display values are + pre-formatted by the handler inside the city's unit scope, as in Python): + .Breadcrumb; .City (needs .Slug); .Display/.Name/.MonthName string; + .YearRange [2]int; .AvgHigh/.AvgLow template.HTML (temp spans); + .AvgHighCls/.AvgLowCls string (temp_class names); + .Stats []{Label string; Value template.HTML}; + .Records []{Label, HighTag, LowTag string; High, Low template.HTML; + HighDate, LowDate string}; + .ToolHref string — the full "{base}/#{lat:.5f},{lon:.5f}" href, built by + the handler: composed inline (Jinja's {{ base }}/#{{ tool_hash }}) the + fragment would hit html/template's urlEscaper and %-escape the comma; + .CompareURL string; .Prev/.Next {Slug, Name string}. */}}{{template "base_head_start" .}}{{template "base_head_end" .}}{{template "base_body_start" .}}{{template "breadcrumb" .}} +
+

Weather in {{.Display}} in {{.MonthName}}

+

In an average {{.MonthName}}, {{.Name}} sees a daily high around + {{.AvgHigh}} and a low around + {{.AvgLow}}, based on + {{index .YearRange 0}}–{{index .YearRange 1}} of local climate records.

+ + + +{{range .Stats}} +{{end}} +
{{.Label}}{{.Value}}
+ +{{if .Records}}

{{.MonthName}} records

+

The most extreme {{.MonthName}} on record for each metric, across + {{index .YearRange 0}}–{{index .YearRange 1}}. For rainfall, the wettest and driest are by + total {{.MonthName}} accumulation.

+
+{{range .Records}}
+

{{.Label}}

+
+ {{.HighTag}} + {{.High}} + {{.HighDate}} +
+
+ {{.LowTag}} + {{.Low}} + {{.LowDate}} +
+
+{{end}}
+{{end}} +

See {{.Name}}'s live weather grade →

+ +
+

Visiting {{.Name}} in {{.MonthName}}? See how its comfort stacks up against + where you live.

+ Compare {{.Name}} with your city → +
+ +

+ ‹ {{.Prev.Name}} · + {{.Name}} climate overview · + {{.Next.Name}} › +

+
+{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}} \ No newline at end of file diff --git a/frontend/server/internal/render/templates/privacy.html.tmpl b/frontend/server/internal/render/templates/privacy.html.tmpl new file mode 100644 index 0000000..5db4099 --- /dev/null +++ b/frontend/server/internal/render/templates/privacy.html.tmpl @@ -0,0 +1,31 @@ +{{/* Ported from templates/privacy.html.j2. Extra data: .Breadcrumb. */}}{{template "base_head_start" .}}{{template "base_head_end" .}}{{template "base_body_start" .}}{{template "breadcrumb" .}}
+

Privacy

+

Thermograph is free, has no ads, and needs no account. It is built so that + there is very little about you to collect in the first place.

+ +

Your location

+

Thermograph never looks up your location from your IP address. The only way the site + learns where you are is if you press Use my location, which asks your browser for + permission. You can decline, and picking a place on the map works just as well.

+

Whichever way you choose a place, the coordinates are sent to the server only as part of + the ordinary request for that grid square's climate data, and are not logged beyond the + normal web-server request handling every site does. The place you picked is remembered in + your own browser's local storage, not on the server, so clearing your browser data + forgets it.

+ +

Analytics

+

There is no analytics library, no cookies for tracking, and no per-visitor identifier. + The server keeps simple aggregate counts (how many people opened a page or tapped a + button, and which site referred them) with nothing tying any of it to an individual.

+ +

Email

+

If you sign up for the monthly digest, your address is used for that digest and nothing + else. It is never sold or shared, and every message can unsubscribe you.

+ +

Accounts

+

An account is optional and only exists to hold weather alerts you have asked for. It + stores your email address and the places you are watching.

+ +

Questions: see About & methodology.

+
+{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}} \ No newline at end of file diff --git a/frontend/server/internal/render/templates/records.html.tmpl b/frontend/server/internal/render/templates/records.html.tmpl new file mode 100644 index 0000000..b8a4930 --- /dev/null +++ b/frontend/server/internal/render/templates/records.html.tmpl @@ -0,0 +1,95 @@ +{{/* Ported from templates/records.html.j2. Extra data (formatted by the + handler inside unit_for_country scope, as in Python): + .Breadcrumb; .City (needs .Slug); .Display/.Name string; .YearRange [2]int; + .Rows []{Label string; High, Low template.HTML; HighDate, LowDate string} + — Low is either the formatted metric value or the "N-day dry spell" + text, LowDate already "—"-defaulted (content.py records_page); + .Monthly []{Name, Slug string; High, Low side} and + .Seasonal []{Name, Span string; High, Low side} where a side is + {Warm, Cold *{Cls string; Txt template.HTML; Date string}}; + .Hemisphere string; .AllTime contentapi.AllTimeRecords (raw floats — + .Fmt.Temp runs in the template, like Jinja's temp()); + .ToolHref string (full "{base}/#{lat:.5f},{lon:.5f}" — composed inline the + fragment comma would be %-escaped by html/template's urlEscaper); + .JSONLDStr template.JS (compacted raw payload bytes — the |safe site; + trusted backend-owned JSON-LD, never re-marshaled through a Go map). */}} +{{/* A metric's two extremes — ▲ warmest and ▼ coldest it has ever been — as tinted + chips with the date each occurred. Used for the daytime-high and overnight-low + columns of the month and season tables. (Jinja macro `extremes`.) */}} +{{define "extremes" -}} +{{if and .Warm .Cold -}} +
{{.Warm.Txt}}{{.Warm.Date}}
+
{{.Cold.Txt}}{{.Cold.Date}}
+{{else -}} +—{{end}}{{end}}{{template "base_head_start" .}}{{template "base_head_end" .}}{{template "base_body_start" .}}{{template "breadcrumb" .}} +
+

{{.Display}} weather records

+

Record high and low temperatures for {{.Display}}, by month, by season, and + all-time, with the dates they occurred, across {{index .YearRange 0}}–{{index .YearRange 1}} of daily + climate history.{{if and .AllTime.Tmax .AllTime.Tmin}} Its hottest day on record reached + {{.Fmt.Temp .AllTime.Tmax.Max}} on {{.AllTime.Tmax.MaxDate}}; its coldest fell to + {{.Fmt.Temp .AllTime.Tmin.Min}} on {{.AllTime.Tmin.MinDate}}.{{end}}

+ +
+

Records by month

+

The warmest (▲) and coldest (▼) both the daytime high and the overnight low have ever been in + each calendar month, so each column shows its record high and its record low. Tap a + month for its full averages and typical range.

+
+ + + +{{range .Monthly}} + + + + +{{end}} +
MonthDaytime highOvernight low
{{.Name}}{{template "extremes" .High}}{{template "extremes" .Low}}
+
+
+ +
+

Records by season

+

The warmest (▲) and coldest (▼) both the daytime high and the overnight low have reached in each + meteorological season ({{.Hemisphere}} Hemisphere, each a three-month span).

+
+ + + +{{range .Seasonal}} + + + + +{{end}} +
SeasonDaytime highOvernight low
{{.Name}} ({{.Span}}){{template "extremes" .High}}{{template "extremes" .Low}}
+
+
+ +
+

All-time records

+

The most extreme value {{.Name}} has recorded for each metric across its entire history.

+
+{{range .Rows}}
+

{{.Label}}

+
+ {{if eq .Label "Precip"}}Wettest{{else}}Highest{{end}} + {{.High}} + {{.HighDate}} +
+
+ {{if eq .Label "Precip"}}Driest{{else}}Lowest{{end}} + {{.Low}} + {{.LowDate}} +
+
+{{end}}
+

For rainfall, the “driest” figure is the longest run of consecutive + days without measurable rain, dated to when that dry spell began.

+
+ +

Grade {{.Name}}'s weather now →

+

‹ Back to {{.Name}} climate

+
+{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}} \ No newline at end of file diff --git a/frontend/server/main.go b/frontend/server/main.go new file mode 100644 index 0000000..914bcf0 --- /dev/null +++ b/frontend/server/main.go @@ -0,0 +1,174 @@ +// thermograph-frontend: the SSR frontend service — server-rendered content +// pages (climate hub / per-city / month / records / glossary / about), the +// interactive tool's SPA shells, and every static asset. All climate data +// comes from the backend's content API over HTTP (internal/contentapi); this +// process holds no data of its own. +// +// Go port of app.py: configuration, the route wiring (internal/content for +// the content.py routes, internal/handlers for the SPA shells + static +// assets), and the process lifecycle uvicorn used to own (listen, access +// logs, graceful shutdown). +package main + +import ( + "context" + "errors" + "log/slog" + "mime" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "thermograph/frontend/internal/config" + "thermograph/frontend/internal/content" + "thermograph/frontend/internal/contentapi" + "thermograph/frontend/internal/contentdata" + "thermograph/frontend/internal/handlers" + "thermograph/frontend/internal/render" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + slog.SetDefault(logger) + + // Fail loud at boot on bad configuration or content (missing backend URL, + // garbage TTL, malformed glossary/pages YAML) — the Python raised at + // import for the same cases: a missing backend URL or a bad content edit + // should break the boot, not silently 500 on the first request. + cfg, err := config.Load() + if err != nil { + fatal(logger, "config", err) + } + + client := contentapi.New(contentapi.Options{ + BaseURL: cfg.APIBaseInternal, + BasePrefix: cfg.Base, // backend runs under the same THERMOGRAPH_BASE + APIVersion: cfg.APIVersion, + TTL: cfg.SSRCacheTTL, + }) + + glossary, err := contentdata.LoadGlossary(cfg.ContentDir) + if err != nil { + fatal(logger, "glossary", err) + } + pages, err := contentdata.LoadPages(cfg.ContentDir) + if err != nil { + fatal(logger, "pages", err) + } + + // Templates parse once, at boot, with the full FuncMap (html/template + // resolves function names at parse time) — the Go analogue of the Jinja + // environment's auto_reload=False. + engine, err := render.New(content.FuncMap(cfg)) + if err != nil { + fatal(logger, "templates", err) + } + + // The PWA manifest is served as a static file; register its media type so + // the file server labels it correctly (not in Go's default mime table). + if err := mime.AddExtensionType(".webmanifest", "application/manifest+json"); err != nil { + fatal(logger, "mime", err) + } + + // app.py-layer routes: SPA shells + static assets + the bare-BASE redirect. + app := handlers.New(handlers.Options{ + Base: cfg.Base, + StaticDir: cfg.StaticDir, + GoogleVerify: cfg.GoogleVerify, + BingVerify: cfg.BingVerify, + Log: logger, + }) + + // content.py-layer routes. content.New takes a *log.Logger; bridge it into + // slog so its lines (IndexNow boot warning, render failures) land on + // stdout structured like everything else. + contentHandlers, err := content.New(cfg, client, engine, glossary, pages, + slog.NewLogLogger(logger.Handler(), slog.LevelWarn)) + if err != nil { + fatal(logger, "content", err) + } + + mux := http.NewServeMux() + + // Liveness probe: the process is up and serving. Deliberately does no I/O + // — no call to the backend — so it stays cheap and reliable for a tight + // healthcheck interval. Readiness (backend reachable) is a separate concern. + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok"}`)) + }) + + // Register order does not matter for precedence (most-specific pattern + // wins), but content registration performs the eager IndexNow key fetch — + // same point in boot as the Python's content.register(app). Its lazy + // fallback route needs the static handler to delegate non-key requests to. + contentHandlers.Register(mux, app.Static()) + app.Register(mux) + + srv := &http.Server{ + Addr: net.JoinHostPort("", cfg.Port), // 0.0.0.0:PORT, like uvicorn --host 0.0.0.0 + Handler: accessLog(logger, mux), + ReadHeaderTimeout: 10 * time.Second, + } + + // Graceful shutdown on SIGINT/SIGTERM: stop accepting, drain in-flight + // requests, then exit — what uvicorn did for the Python process. + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + errCh := make(chan error, 1) + go func() { + logger.Info("thermograph-frontend listening", "port", cfg.Port, "base", cfg.Base) + errCh <- srv.ListenAndServe() + }() + + select { + case err := <-errCh: + if !errors.Is(err, http.ErrServerClosed) { + fatal(logger, "serve", err) + } + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + logger.Error("shutdown", "err", err) + os.Exit(1) + } + logger.Info("shut down cleanly") + } +} + +func fatal(logger *slog.Logger, stage string, err error) { + logger.Error(stage, "err", err) + os.Exit(1) +} + +// accessLog is the uvicorn access log's replacement: one structured line per +// request on stdout. +func accessLog(logger *slog.Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rec, r) + logger.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", rec.status, + "dur_ms", time.Since(start).Milliseconds(), + ) + }) +} + +// statusRecorder captures the response status for the access log. +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (rec *statusRecorder) WriteHeader(status int) { + rec.status = status + rec.ResponseWriter.WriteHeader(status) +} diff --git a/infra/deploy/stack/env-entrypoint.sh b/infra/deploy/stack/env-entrypoint.sh index 7af8173..da353d7 100755 --- a/infra/deploy/stack/env-entrypoint.sh +++ b/infra/deploy/stack/env-entrypoint.sh @@ -34,9 +34,14 @@ if [ -f "$ENV_FILE" ]; then done < "$ENV_FILE" fi -# Hand off: the backend image has a real entrypoint script; the frontend -# image is plain-CMD (docker passes that CMD to us as $@ when the stack -# overrides the entrypoint) -- exec whichever this image actually is. +# Hand off: the backend image has a real entrypoint script and takes that +# branch. Anything else needs an explicit `command:` in the stack yml's +# service block -- overriding `entrypoint:` here drops the image's own CMD +# entirely (Docker/Swarm does not merge them), so `$@` is empty unless the +# stack file supplies one. The frontend service does exactly that. The +# uvicorn fallback below is what ran, silently, whenever that expectation +# didn't hold; it is Python-specific and kept only as a loud, familiar-looking +# failure for a misconfigured non-Python image, not a real handoff path. if [ -x /app/deploy/entrypoint.sh ]; then exec /app/deploy/entrypoint.sh "$@" elif [ "$#" -gt 0 ]; then diff --git a/infra/deploy/stack/thermograph-stack.yml b/infra/deploy/stack/thermograph-stack.yml index 8bbc3c2..561b687 100644 --- a/infra/deploy/stack/thermograph-stack.yml +++ b/infra/deploy/stack/thermograph-stack.yml @@ -238,6 +238,12 @@ services: frontend: image: ${REGISTRY_HOST:-git.thermograph.org}/${FRONTEND_IMAGE_PATH:-emi/thermograph/frontend}:${FRONTEND_IMAGE_TAG:?required} entrypoint: ["/host/env-entrypoint.sh"] + # REQUIRED, not cosmetic: overriding `entrypoint:` with no `command:` drops + # the image's own CMD entirely (Docker/Swarm semantics, not merged) -- + # env-entrypoint.sh then sees zero args and falls through to its hardcoded + # `exec uvicorn app:app` fallback, which no longer exists in this (Go) + # image. Without this line the frontend task exits 127 on every deploy. + command: ["/usr/local/bin/thermograph-frontend"] environment: THERMOGRAPH_BASE: / PORT: 8080 diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index f4d7330..2b13bd5 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -8,8 +8,8 @@ # FRONTEND_IMAGE_TAG. This replaces the earlier Stage-4 model where both # containers shared the single emi/thermograph/app image and # THERMOGRAPH_SERVICE_ROLE picked the process; the split Dockerfiles now start -# the right process directly (frontend `uvicorn app:app`, backend -# entrypoint.sh), so a backend deploy and a frontend deploy are fully +# the right process directly (frontend the thermograph-frontend Go binary, +# backend entrypoint.sh), so a backend deploy and a frontend deploy are fully # independent -- deploy.sh rolls one service without touching the other's tag. # Both get their own loopback-published port since prod/beta's host Caddy # path-splits directly to each; backend also gets a reverse-proxy fallback to @@ -256,9 +256,9 @@ services: restart: unless-stopped frontend: - # Frontend's OWN image, published by thermograph-frontend's build-push.yml - # from its own Dockerfile (which starts `uvicorn app:app` directly -- no - # THERMOGRAPH_SERVICE_ROLE process-picking anymore). Independent + # Frontend's OWN image, published by frontend-build-push.yml from + # frontend/Dockerfile (which starts the thermograph-frontend Go binary + # directly -- no THERMOGRAPH_SERVICE_ROLE process-picking). Independent # FRONTEND_IMAGE_TAG so a frontend deploy never disturbs the backend's tag. image: ${REGISTRY_HOST:-git.thermograph.org}/${FRONTEND_IMAGE_PATH:-emi/thermograph/frontend}:${FRONTEND_IMAGE_TAG:-local} # Its own register() fetches the IndexNow key from backend at boot -- must