From 92e74c585ab677a8d5bac79055f53485a0256f40 Mon Sep 17 00:00:00 2001 From: emi Date: Fri, 24 Jul 2026 00:53:48 +0000 Subject: [PATCH 1/7] frontend: rewrite the SSR content service in Go (#28) Ports frontend/ (Jinja2/FastAPI, ~1180 LOC) to Go with html/template. No climate math, no DB, no auth -- every route fetches from the backend's /content/* API. 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 and every route compared byte-for-byte, confirmed programmatically. That process caught defects unit tests alone missed, since map[string]any has no compile-time field check: - Render-context keys were snake_case throughout while the templates read PascalCase fields. A missing map key doesn't error, it silently renders empty -- title, meta description, canonical URL, OpenGraph tags, and the homepage's entire ranked list were blank on every page despite every route returning 200. Fixed by renaming every key to match each template's own documented field contract, and passing API structs straight through wherever their fields already matched (removes a whole layer of future drift risk). - Three pages 500'd: ToolHref needed a composed href, not a bare "lat,lon" fragment; the records table needed the raw API struct. - JSON-LD was 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 -- 2.45.2 From 979653f407a3836665a51b7777fe8c248d501c56 Mon Sep 17 00:00:00 2001 From: emi Date: Fri, 24 Jul 2026 04:03:26 +0000 Subject: [PATCH 2/7] deploy.sh: fix the daemon-binary probe, which always dropped daemon (#33) docker compose config --images daemon does not filter to the named service on this host's Compose v5.3.1 -- it prints every service's image, one per line, in file order, so `| head -1` was silently grabbing db's image (timescaledb) instead of daemon's. The probe then always found no /usr/local/bin/thermograph-daemon in a Postgres image and dropped daemon from every backend deploy, regardless of what the real backend image contained. Fixed by building the image reference directly from the same vars docker-compose.yml's daemon.image: already interpolates, instead of going through docker compose config at all. Reproduced against beta directly: the old sequence selected the wrong image; the new construction resolves correctly and the binary probe passes. --- infra/deploy/deploy.sh | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/infra/deploy/deploy.sh b/infra/deploy/deploy.sh index 487779e..51748eb 100755 --- a/infra/deploy/deploy.sh +++ b/infra/deploy/deploy.sh @@ -247,9 +247,17 @@ fi # next deploy of a tag that has it picks it up with no further action. case " ${TARGETS[*]} " in *" daemon "*) - daemon_img="$(docker compose config --images daemon 2>/dev/null | head -1 || true)" - if [ -n "$daemon_img" ] \ - && ! docker run --rm --entrypoint sh "$daemon_img" -c 'test -x /usr/local/bin/thermograph-daemon' 2>/dev/null; then + # Built directly from the same vars docker-compose.yml's `daemon.image:` + # interpolates (REGISTRY_HOST/BACKEND_IMAGE_PATH/BACKEND_IMAGE_TAG), + # NOT via `docker compose config --images daemon`: that command does not + # actually filter to the named service (confirmed live on Compose + # v5.3.1 -- it prints every service's image, one per line, in file + # order) so `| head -1` silently grabbed db's image instead. The probe + # then always found no daemon binary in a Postgres image and dropped + # daemon from EVERY backend deploy, regardless of what the real backend + # image contained -- reproduced and confirmed against beta directly. + daemon_img="${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph/backend}:${BACKEND_IMAGE_TAG}" + if ! docker run --rm --entrypoint sh "$daemon_img" -c 'test -x /usr/local/bin/thermograph-daemon' 2>/dev/null; then echo "==> $daemon_img predates the daemon binary; rolling without the daemon service this run" kept=() for t in "${TARGETS[@]}"; do -- 2.45.2 From da82abda27d04e60a020947930bcfc0f252c064a Mon Sep 17 00:00:00 2001 From: emi Date: Fri, 24 Jul 2026 06:25:06 +0000 Subject: [PATCH 3/7] frontend: fetch City() concurrently with the page's primary API call (#37) MonthPage and RecordsPage each made two backend calls sequentially (CityMonth/CityRecords, then City) where the second never depended on the first's result. fetchWithCity launches both concurrently via goroutines and a WaitGroup. Verified live against a stub with an injected 400ms delay on both endpoints: city page (1 call) and month/records pages (2 calls each) all cost ~0.404s now, not double for the two-call pages. Error priority preserved exactly: primary's error wins even when City also fails, matching the old sequential code. One trade-off: City() is now always launched even on a request about to 404 from primary, costing one extra cheap lookup on that rare path. Tests include a deterministic concurrency proof via rendezvous channels (the old sequential code would deadlock this test, not just run it slower). Full suite green under -race -count=2. --- .../server/internal/content/content_test.go | 111 ++++++++++++++++++ frontend/server/internal/content/pages.go | 67 ++++++++--- 2 files changed, 164 insertions(+), 14 deletions(-) diff --git a/frontend/server/internal/content/content_test.go b/frontend/server/internal/content/content_test.go index ef90a28..1d199ea 100644 --- a/frontend/server/internal/content/content_test.go +++ b/frontend/server/internal/content/content_test.go @@ -19,6 +19,7 @@ import ( "path/filepath" "strings" "testing" + "time" "thermograph/frontend/internal/config" "thermograph/frontend/internal/contentapi" @@ -474,6 +475,116 @@ func TestMonthCtx(t *testing.T) { } } +// --- fetchWithCity ----------------------------------------------------------- + +func TestFetchWithCityHappyPath(t *testing.T) { + api := &fakeAPI{ + city: func(slug, _ string) (*contentapi.CityPayload, error) { + return &contentapi.CityPayload{City: contentapi.CityInfo{Slug: slug, Name: "Testville"}}, nil + }, + } + primary, city, err := fetchWithCity(api, "testville", func() (string, error) { return "primary-value", nil }) + if err != nil { + t.Fatal(err) + } + if primary != "primary-value" { + t.Errorf("primary = %q", primary) + } + if city.Slug != "testville" || city.Name != "Testville" { + t.Errorf("city = %+v", city) + } +} + +// When only primary fails, its error must win even though City() also ran +// (and, here, succeeded) -- matching the old sequential code's behavior, +// where City() was never even called once primary had already failed. +func TestFetchWithCityPrimaryErrorWins(t *testing.T) { + primaryErr := notFound("no such month") + api := &fakeAPI{ + city: func(slug, _ string) (*contentapi.CityPayload, error) { + return &contentapi.CityPayload{City: contentapi.CityInfo{Slug: slug}}, nil + }, + } + _, _, err := fetchWithCity(api, "testville", func() (string, error) { return "", primaryErr }) + if err != primaryErr { + t.Errorf("err = %v, want the primary error", err) + } +} + +// When primary succeeds but City() fails, City's error must surface -- same +// as the old sequential code (it called City() second and returned whatever +// that call did). +func TestFetchWithCityCityErrorSurfaces(t *testing.T) { + cityErr := notFound("no such city") + api := &fakeAPI{ + city: func(_, _ string) (*contentapi.CityPayload, error) { return nil, cityErr }, + } + _, _, err := fetchWithCity(api, "testville", func() (string, error) { return "primary-value", nil }) + if err != cityErr { + t.Errorf("err = %v, want the city error", err) + } +} + +// When BOTH fail, primary's error still wins -- the same priority as the +// single-failure case above, just with both goroutines erroring. +func TestFetchWithCityBothErrorPrimaryWins(t *testing.T) { + primaryErr, cityErr := notFound("primary"), notFound("city") + api := &fakeAPI{ + city: func(_, _ string) (*contentapi.CityPayload, error) { return nil, cityErr }, + } + _, _, err := fetchWithCity(api, "testville", func() (string, error) { return "", primaryErr }) + if err != primaryErr { + t.Errorf("err = %v, want the primary error (city error must not win)", err) + } +} + +// Proves the two calls actually run CONCURRENTLY rather than sequentially -- +// deterministically, via a rendezvous, not by timing (which would be flaky +// under CI load). Each fake blocks until BOTH have started; if fetchWithCity +// ran them sequentially, the second would never start until the first +// returned, and this would deadlock and fail on the test's own timeout. +func TestFetchWithCityRunsConcurrently(t *testing.T) { + started := make(chan struct{}, 2) + release := make(chan struct{}) + rendezvous := func() { + started <- struct{}{} + <-release + } + + api := &fakeAPI{ + city: func(_, _ string) (*contentapi.CityPayload, error) { + rendezvous() + return &contentapi.CityPayload{}, nil + }, + } + + done := make(chan struct{}) + go func() { + fetchWithCity(api, "testville", func() (string, error) { + rendezvous() + return "", nil + }) + close(done) + }() + + // Both goroutines must reach the rendezvous before either can proceed -- + // only possible if they were launched concurrently. + for range 2 { + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for both calls to start -- they are not running concurrently") + } + } + close(release) + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("fetchWithCity did not return after release") + } +} + func TestMonthCtxUnknownLabelIsError(t *testing.T) { h := newHandlers(t, fixtureAPI(t)) j := loadFixture[contentapi.MonthPayload](t, "month") diff --git a/frontend/server/internal/content/pages.go b/frontend/server/internal/content/pages.go index 43eaedf..a508df0 100644 --- a/frontend/server/internal/content/pages.go +++ b/frontend/server/internal/content/pages.go @@ -7,6 +7,7 @@ import ( "html/template" "net/http" "strings" + "sync" "thermograph/frontend/internal/contentapi" "thermograph/frontend/internal/contentdata" @@ -165,6 +166,49 @@ func (h *Handlers) cityCtx(j *contentapi.CityPayload) (map[string]any, format.Un return ctx, unit, nil } +// fetchWithCity runs a page's primary API call and the shared City lookup +// concurrently instead of sequentially: both are independent single-slug +// fetches (City never depends on primary's result), so waiting for one to +// fully round-trip before even starting the other was pure latency with +// nothing to show for it. Concurrent instead cuts that leg to roughly +// whichever call is slower. +// +// If primary fails, its error wins even when City also fails or hasn't +// finished — matching the old sequential code's behavior exactly (it never +// even called City() once primary had already failed). The one real +// trade-off: City() is now ALWAYS launched, even on a request that's about +// to 404 from primary, so an invalid slug costs one extra (wasted, cheap) +// backend lookup it previously skipped. Worth it: a bad slug is the rare +// path; a good one is the common path this speeds up. +func fetchWithCity[T any](api API, slug string, primary func() (T, error)) (T, contentapi.CityInfo, error) { + var ( + pVal T + pErr error + cPay *contentapi.CityPayload + cErr error + group sync.WaitGroup + ) + group.Add(2) + go func() { + defer group.Done() + pVal, pErr = primary() + }() + go func() { + defer group.Done() + cPay, cErr = api.City(slug, "") // no origin: the Python called city(slug) bare here too + }() + group.Wait() + + if pErr != nil { + return pVal, contentapi.CityInfo{}, pErr + } + if cErr != nil { + var zero T + return zero, contentapi.CityInfo{}, cErr + } + return pVal, cPay.City, nil +} + // --- month page -------------------------------------------------------------- // MonthPage renders /climate/{slug}/{month} (content.py's month_page). @@ -176,17 +220,14 @@ func (h *Handlers) cityCtx(j *contentapi.CityPayload) (map[string]any, format.Un // Jinja iterated (label, value) tuples). func (h *Handlers) MonthPage(w http.ResponseWriter, r *http.Request) { slug, month := r.PathValue("slug"), r.PathValue("month") - j, err := h.api.CityMonth(slug, month) + j, city, err := fetchWithCity(h.api, slug, func() (*contentapi.MonthPayload, error) { + return h.api.CityMonth(slug, month) + }) if err != nil { h.apiError(w, err) return } - cp, err := h.api.City(slug, "") // no origin: the Python called city(slug) bare here - if err != nil { - h.apiError(w, err) - return - } - ctx, unit, err := h.monthCtx(j, cp.City) + ctx, unit, err := h.monthCtx(j, city) if err != nil { h.serverError(w, err) return @@ -308,17 +349,15 @@ func fmtPeriodSide(unit format.Unit, s contentapi.PeriodSide) map[string]any { // temperature metrics, nil otherwise, as the Python set them. func (h *Handlers) RecordsPage(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("slug") - j, err := h.api.CityRecords(slug, origin(r)) + o := origin(r) + j, city, err := fetchWithCity(h.api, slug, func() (*contentapi.RecordsPayload, error) { + return h.api.CityRecords(slug, o) + }) if err != nil { h.apiError(w, err) return } - cp, err := h.api.City(slug, "") // no origin, same as month_page - if err != nil { - h.apiError(w, err) - return - } - ctx, unit, err := h.recordsCtx(j, cp.City) + ctx, unit, err := h.recordsCtx(j, city) if err != nil { h.serverError(w, err) return -- 2.45.2 From dcf15ea5724c4deae8cff90c02adc0d4df30a0e9 Mon Sep 17 00:00:00 2001 From: emi Date: Fri, 24 Jul 2026 18:56:46 +0000 Subject: [PATCH 4/7] infra/forgejo: add resource limits and a leaner CI job image (#38) --- infra/deploy/forgejo/README.md | 46 +++++++++++++++++++++ infra/deploy/forgejo/ci-runner/Dockerfile | 46 +++++++++++++++++++++ infra/deploy/forgejo/docker-stack.yml | 8 ++++ infra/deploy/forgejo/register-lan-runner.sh | 2 +- 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 infra/deploy/forgejo/ci-runner/Dockerfile diff --git a/infra/deploy/forgejo/README.md b/infra/deploy/forgejo/README.md index 0325386..8ad737d 100644 --- a/infra/deploy/forgejo/README.md +++ b/infra/deploy/forgejo/README.md @@ -41,6 +41,15 @@ Do this **before** the DNS + Caddy step below — Caddy's reverse_proxy target (`127.0.0.1:3080`) needs the `forgejo` service actually listening first, or its first health check just fails harmlessly until it is. +This stack has **no auto-deploy trigger** — nothing in `.forgejo/workflows/` +redeploys it on push. A change to `docker-stack.yml` only takes effect once +someone re-runs `docker stack deploy` by hand on the manager (prod). + +`db`/`forgejo` both carry `resources.limits` (defaults: db 1 CPU/1g, forgejo 2 +CPU/2g — several times observed steady-state usage), overridable with +`FORGEJO_DB_CPUS`/`FORGEJO_DB_MEMORY`/`FORGEJO_CPUS`/`FORGEJO_MEMORY` env vars +before `docker stack deploy`, same convention as the app stack. + ## DNS + TLS: reusing beta's existing Caddy, not a second reverse proxy Forgejo is pinned to beta (`role=forge`) — but beta is also **today's live @@ -98,6 +107,43 @@ See that script's header for exactly what it replaces (the pre-Forgejo GitHub self-hosted runner on this same machine) and why it registers with two labels where there used to be two separate runners. +## Custom CI job image (`ci-runner/`) + +`ci-runner/Dockerfile` still bases on `node:20-bookworm` — Node is a hard +requirement, not leftover: Forgejo's runner executes `actions/checkout@v4` +(used by every workflow) as `node dist/index.js` *inside the job container*, +regardless of whether the workflow itself uses npm/node. (v1 of this image +tried a Node-free Debian-slim base and broke every job's checkout step — +`node: executable file not found` — within a minute of going live; reverted +immediately.) What it actually fixes: every `docker`-labeled build-push job +currently re-installs the Docker CLI on each run (`apt-get install +docker.io`), which pulls in the classic builder rather than BuildKit (the +classic builder mishandles `COPY --chown=` group resolution — a real +bug hit during the frontend Go rewrite). `ci-runner` adds `docker-ce-cli` + +`docker-buildx-plugin` (BuildKit) on top of the same Node base, plus +`git`/`python3`/`python3-yaml` for the other jobs that need them +(`shell-lint`, `observability-validate`). + +Current tag: `git.thermograph.org/emi/thermograph/ci-runner:v2` (`v1` is +broken — do not register any runner against it). Rebuild/push (requires a PAT +with `write:package` scope — the embedded git-remote token lacks it, same +requirement documented in `backend-build-push.yml`): + +```bash +docker build -t git.thermograph.org/emi/thermograph/ci-runner:vN deploy/forgejo/ci-runner +docker push git.thermograph.org/emi/thermograph/ci-runner:vN +``` + +`register-lan-runner.sh`'s `LABELS` default points at the current tag, so +fresh registrations pick it up automatically. The live runner is cut over by +editing the `labels` array in `~/forgejo-runner/.runner` on the desktop (same +runner id/token, no re-registration needed) and restarting the service — +**verify a real job runs green under the new image before relying on it**, +same way v1's break was caught. Only after that verification should the +now-redundant `apt-get install docker.io` / `python3-yaml` steps be removed +from the workflows that had them — removing them first would break every job +still running on the stock `node:20-bookworm` image. + ## Why Postgres here and not the Thermograph app's TimescaleDB Separate instance, separate network (`forgejo_net`, not the app's compose diff --git a/infra/deploy/forgejo/ci-runner/Dockerfile b/infra/deploy/forgejo/ci-runner/Dockerfile new file mode 100644 index 0000000..e4259e0 --- /dev/null +++ b/infra/deploy/forgejo/ci-runner/Dockerfile @@ -0,0 +1,46 @@ +# Job-container image for the LAN Forgejo Actions runner's `docker`-labeled +# jobs (see ../register-lan-runner.sh) — used by every backend/frontend +# build-push, deploy, and validation workflow that needs `docker build`. +# +# Base stays node:20-bookworm, NOT a Node-free slim image (v1 of this file +# tried that and broke every job: `actions/checkout@v4` is a JS action that +# Forgejo's runner execs as `node dist/index.js` *inside the job container*, +# so a Node runtime is a hard requirement regardless of whether the workflow +# itself uses npm/node — this repo's own workflows don't, but the checkout +# step every one of them starts with does). +# +# What this image actually fixes: every workflow using the `docker` label +# currently re-provisions the Docker CLI on each run via `apt-get install +# docker.io` — that step costs ~15-20s per job and, more importantly, +# installs the CLASSIC Docker builder rather than BuildKit, which mishandles +# `COPY --chown=` group resolution on images without a matching system +# group (a real bug hit during the frontend Go rewrite). Installing +# docker-buildx-plugin here makes BuildKit the default, closing that bug +# class, and skips the per-job install entirely. +# +# Build/push (manual — see ../README.md for when to rebuild): +# docker build -t git.thermograph.org/emi/thermograph/ci-runner:vN \ +# infra/deploy/forgejo/ci-runner +# docker push git.thermograph.org/emi/thermograph/ci-runner:vN +# +# Cutting over the live runner to a new tag means editing the `labels` array +# in ~/forgejo-runner/.runner on the runner host directly (same runner +# id/token, no re-registration needed) and updating register-lan-runner.sh's +# LABELS default so future re-provisioning picks it up too. +FROM node:20-bookworm + +RUN apt-get update -qq \ + && apt-get install -y -qq --no-install-recommends \ + ca-certificates curl gnupg git python3 python3-yaml \ + && install -m 0755 -d /etc/apt/keyrings \ + && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ + && chmod a+r /etc/apt/keyrings/docker.asc \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" \ + > /etc/apt/sources.list.d/docker.list \ + && apt-get update -qq \ + && apt-get install -y -qq --no-install-recommends docker-ce-cli docker-buildx-plugin \ + && rm -rf /var/lib/apt/lists/* /etc/apt/sources.list.d/docker.list + +RUN docker --version && docker buildx version && git --version \ + && node --version \ + && python3 -c "import yaml; print('PyYAML', yaml.__version__)" diff --git a/infra/deploy/forgejo/docker-stack.yml b/infra/deploy/forgejo/docker-stack.yml index 9d94cbd..9891c3b 100644 --- a/infra/deploy/forgejo/docker-stack.yml +++ b/infra/deploy/forgejo/docker-stack.yml @@ -48,6 +48,10 @@ services: deploy: placement: constraints: [node.labels.role == forge] + resources: + limits: + cpus: "${FORGEJO_DB_CPUS:-1}" + memory: ${FORGEJO_DB_MEMORY:-1g} restart_policy: condition: on-failure @@ -131,6 +135,10 @@ services: deploy: placement: constraints: [node.labels.role == forge] + resources: + limits: + cpus: "${FORGEJO_CPUS:-2}" + memory: ${FORGEJO_MEMORY:-2g} restart_policy: condition: on-failure diff --git a/infra/deploy/forgejo/register-lan-runner.sh b/infra/deploy/forgejo/register-lan-runner.sh index 71f1c90..c35660d 100755 --- a/infra/deploy/forgejo/register-lan-runner.sh +++ b/infra/deploy/forgejo/register-lan-runner.sh @@ -26,7 +26,7 @@ set -euo pipefail FORGEJO_URL="${1:?usage: $0 }" TOKEN="${2:?}" RUNNER_DIR="${RUNNER_DIR:-$HOME/forgejo-runner}" -LABELS="${LABELS:-docker:docker://node:20-bookworm,thermograph-lan}" +LABELS="${LABELS:-docker:docker://git.thermograph.org/emi/thermograph/ci-runner:v2,thermograph-lan}" echo "==> Stopping and disabling the old GitHub Actions runner service, if present" systemctl --user stop github-actions-runner 2>/dev/null || true -- 2.45.2 From 81c579ddcb5f48c249e91e047eb9c073de1b408e Mon Sep 17 00:00:00 2001 From: emi Date: Fri, 24 Jul 2026 19:05:21 +0000 Subject: [PATCH 5/7] infra/forgejo: raise LAN runner concurrency to 8 (#43) --- infra/deploy/forgejo/README.md | 19 +++++++++++++++++++ infra/deploy/forgejo/register-lan-runner.sh | 12 ++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/infra/deploy/forgejo/README.md b/infra/deploy/forgejo/README.md index 8ad737d..6f91009 100644 --- a/infra/deploy/forgejo/README.md +++ b/infra/deploy/forgejo/README.md @@ -107,6 +107,25 @@ See that script's header for exactly what it replaces (the pre-Forgejo GitHub self-hosted runner on this same machine) and why it registers with two labels where there used to be two separate runners. +`config.yaml`'s `runner.capacity` is raised from the tool's default of 1 to 8 +(override with `CAPACITY=`) — a single PR push fires `pr-build`, +`secrets-guard`, and `shell-lint` simultaneously (3 independent workflows, +no `needs:` between them), so capacity 1 serializes work that could run in +parallel, and even capacity 3 (an earlier, undocumented hand-tune) leaves +`build-backend`/`build-frontend`/`validate-observability` queued behind +those three before they get a slot. The desktop has 16 cores / 34GB free +today; 8 concurrent jobs is comfortable headroom without starving LAN dev's +own compose stack. + +**Adding more runner capacity should mean raising this number, or adding a +second runner on the desktop itself — not putting a runner on prod or +beta.** `container.docker_host: automount` gives job containers the *host's* +Docker socket; on prod or beta that would mean any CI job has root-equivalent +access to whatever else is running there (the live app stack, or Forgejo +itself). An earlier revision of this stack actually did run the runner as a +Swarm-hosted container on beta and was deliberately reverted to the desktop +for this reason — see the note at the top of `docker-stack.yml`. + ## Custom CI job image (`ci-runner/`) `ci-runner/Dockerfile` still bases on `node:20-bookworm` — Node is a hard diff --git a/infra/deploy/forgejo/register-lan-runner.sh b/infra/deploy/forgejo/register-lan-runner.sh index c35660d..0f96f97 100755 --- a/infra/deploy/forgejo/register-lan-runner.sh +++ b/infra/deploy/forgejo/register-lan-runner.sh @@ -62,11 +62,15 @@ echo "==> Registering with $FORGEJO_URL (label: $LABELS)" --name "thermograph-lan-$(hostname -s)" \ --labels "$LABELS" -echo "==> Runner config (docker.sock automount, so docker:-labeled jobs like" -echo " build-push.yml can actually run 'docker build/push' -- generated" -echo " config only overridden on the one setting that matters here)" +echo "==> Runner config (docker.sock automount so docker:-labeled jobs like" +echo " build-push.yml can actually run 'docker build/push'; capacity raised" +echo " from the default of 1 -- a single PR push fires 3+ independent" +echo " workflows (pr-build, secrets-guard, shell-lint) simultaneously, so" +echo " anything less than that serializes jobs that could run in parallel)" +CAPACITY="${CAPACITY:-8}" ./forgejo-runner generate-config \ - | sed 's/docker_host: "-"/docker_host: "automount"/' \ + | sed -e 's/docker_host: "-"/docker_host: "automount"/' \ + -e "s/capacity: 1/capacity: ${CAPACITY}/" \ > "${RUNNER_DIR}/config.yaml" echo "==> systemd --user unit" -- 2.45.2 From ca84e0ce95f090bf9865012e0db3b897772611d9 Mon Sep 17 00:00:00 2001 From: emi Date: Fri, 24 Jul 2026 23:20:16 +0000 Subject: [PATCH 6/7] =?UTF-8?q?Promote=20dev=20=E2=86=92=20main=20(fronten?= =?UTF-8?q?d=20QA=20batch=20=E2=86=92=20beta)=20(#71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++ backend/api/content_routes.py | 18 +++++++- backend/data/climate.py | 29 ++++++++++++- backend/data/grading.py | 29 +++++++------ backend/notifications/push.py | 13 +++--- backend/tests/api/test_content_routes.py | 22 ++++++++++ backend/tests/data/test_climate.py | 43 +++++++++++++++++++ backend/tests/data/test_grading.py | 6 ++- backend/tests/notifications/test_push.py | 52 +++++++++++++++++++++-- backend/tests/web/test_api.py | 12 ++++++ backend/web/app.py | 18 +++++++- frontend/content.py | 18 +++++++- frontend/static/app.js | 11 +++-- frontend/static/calendar.js | 6 +-- frontend/static/chart.js | 10 +++-- frontend/static/day.js | 18 +++++--- frontend/static/push-client.js | 34 +++++++++++++-- frontend/static/shared.js | 24 +++++++++-- frontend/tests/unit/test_rendering.py | 45 ++++++++++++++++++++ infra/.claude/skills/key-gaps/SKILL.md | 14 +++++- infra/.claude/skills/key-gaps/key_gaps.py | 2 +- 21 files changed, 374 insertions(+), 54 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5aee0f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ + +# Claude Code worktrees. These are other sessions' checkouts living inside this +# repo; `git add -A` will otherwise commit them as embedded git repositories. +.claude/worktrees/ diff --git a/backend/api/content_routes.py b/backend/api/content_routes.py index cd682b1..ef01df2 100644 --- a/backend/api/content_routes.py +++ b/backend/api/content_routes.py @@ -6,6 +6,7 @@ pattern web/app.py's own endpoints use. """ import hashlib import os +from urllib.parse import urlsplit from fastapi import APIRouter, HTTPException, Request, Response @@ -84,9 +85,24 @@ def _load_history(cell): return history +# The configured public origin is the source of truth for the scheme. Caddy +# terminates TLS and reverse-proxies plain HTTP, so x-forwarded-proto (and +# request.url.scheme) read "http" even though the public site is HTTPS-only -- +# and this origin is folded into the jsonld "url" the payloads carry. Trusting +# the forwarded scheme makes that url (and the canonical/og the frontend builds +# from the same origin it forwards here) emit http://, which 308-redirects to +# https://. So a request that arrives on the configured public host is answered +# with the configured public scheme; localhost/LAN dev never matches that host +# and keeps the observed scheme, leaving plain-HTTP development unaffected. +_PUBLIC = urlsplit(os.environ.get("THERMOGRAPH_BASE_URL", "https://thermograph.org")) + + def _origin(request: Request) -> str: - proto = request.headers.get("x-forwarded-proto") or request.url.scheme host = request.headers.get("host") or request.url.netloc + if _PUBLIC.scheme and _PUBLIC.netloc and host == _PUBLIC.netloc: + proto = _PUBLIC.scheme + else: + proto = request.headers.get("x-forwarded-proto") or request.url.scheme return f"{proto}://{host}" diff --git a/backend/data/climate.py b/backend/data/climate.py index b1c5e00..2bb1b2f 100644 --- a/backend/data/climate.py +++ b/backend/data/climate.py @@ -937,9 +937,29 @@ def _fetch_recent_forecast(cell: dict) -> pl.DataFrame: .sort("date")) +def _om_local_today(payload: dict) -> "datetime.date | None": + """The calendar date it is *now* at the cell, from the UTC offset Open-Meteo + reports for a ``timezone=auto`` request. None when the offset is absent, which + tells the caller to skip the in-progress-day guard rather than guess a date.""" + offset = payload.get("utc_offset_seconds") + if offset is None: + return None + return (datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(seconds=int(offset))).date() + + def _fetch_recent_forecast_om(cell: dict) -> pl.DataFrame: """Fallback recent+forecast bundle from the Open-Meteo forecast API (the former - primary): recent past + forward days in one call.""" + primary): recent past + forward days in one call. + + The cell's in-progress *local* day is dropped from the bundle. Open-Meteo's + daily high/low for today aggregates only the hours elapsed so far, so grading it + reads a still-unfolding day as complete and produces spurious extremes (a cool + morning served as a record-low high, seen live at the 1st percentile). This is + the same failure the MET Norway path guards against with its diurnal-coverage + gate (see _metno_to_frame); the fallback needs the equivalent. Past days are + complete and future days are whole-day forecasts, so only today is excluded — + the day lands in the record once it is over.""" params = { "latitude": cell["center_lat"], "longitude": cell["center_lon"], @@ -952,7 +972,12 @@ def _fetch_recent_forecast_om(cell: dict) -> pl.DataFrame: "forecast_days": FORECAST_DAYS, } r = _request(FORECAST_URL, params, 60, phase="recent_forecast_fetch") - return _to_frame(r.json()["daily"]) + payload = r.json() + df = _to_frame(payload["daily"]) + local_today = _om_local_today(payload) + if local_today is not None: + df = df.filter(pl.col("date") != local_today) + return df def _load_recent_forecast(cell: dict) -> pl.DataFrame: diff --git a/backend/data/grading.py b/backend/data/grading.py index dfd34df..a160eb1 100644 --- a/backend/data/grading.py +++ b/backend/data/grading.py @@ -279,8 +279,10 @@ def all_time_records(df: pl.DataFrame) -> dict: def longest_dry_streak(df: pl.DataFrame) -> tuple[int, str | None]: - """Longest run of consecutive days without measurable rain, and the ISO date the - streak began. A dry day is precip < RAIN_THRESHOLD; null precip counts as dry.""" + """Longest run of consecutive days without any rain, and the ISO date the streak + began. A dry day has no rain at all (precip <= 0); null precip counts as dry. The + threshold matches _grade_precip's dry/rain split — any measurable rain, however + slight, is a rain day that breaks the streak (not the 0.01" rain-frequency line).""" if "precip" not in df.columns: return (0, None) d = df.select(["date", "precip"]).sort("date") @@ -288,7 +290,7 @@ def longest_dry_streak(df: pl.DataFrame) -> tuple[int, str | None]: best_len, best_start = 0, None cur_len, cur_start = 0, None for dt, p in zip(dates, precips): - wet = p is not None and not (isinstance(p, float) and np.isnan(p)) and p >= RAIN_THRESHOLD + wet = p is not None and not (isinstance(p, float) and np.isnan(p)) and p > 0 if wet: cur_len, cur_start = 0, None else: @@ -305,11 +307,12 @@ def longest_dry_streak(df: pl.DataFrame) -> tuple[int, str | None]: def _band_stats(samples: np.ndarray) -> dict | None: if samples.size == 0: return None - # Percentiles for the chart's nested "normal" fan, matching the 9 tier bounds: - # p40-p60 is the Normal band; p25/p75, p10/p90 and p1/p99 mark the successive - # Below/Above Normal, Low/High, Very Low/High and Near-Record edges. - p1, p10, p25, p40, p50, p60, p75, p90, p99 = np.percentile( - samples, [1, 10, 25, 40, 50, 60, 75, 90, 99] + # Percentiles for the chart's nested "normal" fan. p40-p60 is the Normal band; + # p25/p75, p10/p90 and p1/p99 mark the successive Below/Above Normal, Low/High, + # Very Low/High and Near-Record edges. p95 additionally splits the rain fan's top + # region into Very Heavy (90-95) and Severe (95-99); unused by the temperature fan. + p1, p10, p25, p40, p50, p60, p75, p90, p95, p99 = np.percentile( + samples, [1, 10, 25, 40, 50, 60, 75, 90, 95, 99] ) return { "p1": round(float(p1), 1), @@ -320,6 +323,7 @@ def _band_stats(samples: np.ndarray) -> dict | None: "p60": round(float(p60), 1), "p75": round(float(p75), 1), "p90": round(float(p90), 1), + "p95": round(float(p95), 1), "p99": round(float(p99), 1), } @@ -352,13 +356,14 @@ def _grade_precip(samples: np.ndarray, value) -> dict | None: def dry_streaks(dates, precips) -> dict[str, int]: - """Map each date (ISO string) to days since the last measurable rain, walking a - chronological precip series. Missing precip counts as a dry day. `dates` is an - iterable of ``datetime.date`` (a polars Date column's ``.to_list()``).""" + """Map each date (ISO string) to days since the last day with any rain, walking a + chronological precip series. Any measurable rain (precip > 0) resets the count, so + this matches _grade_precip's dry/rain split; missing precip counts as a dry day. + `dates` is an iterable of ``datetime.date`` (a polars Date column's ``.to_list()``).""" out: dict[str, int] = {} streak = 0 for d, p in zip(dates, precips): - wet = p is not None and not (isinstance(p, float) and np.isnan(p)) and p >= RAIN_THRESHOLD + wet = p is not None and not (isinstance(p, float) and np.isnan(p)) and p > 0 streak = 0 if wet else streak + 1 out[_as_date(d).isoformat()] = streak return out diff --git a/backend/notifications/push.py b/backend/notifications/push.py index 594607f..d2807e7 100644 --- a/backend/notifications/push.py +++ b/backend/notifications/push.py @@ -37,7 +37,7 @@ log = logging.getLogger("thermograph.push") _DATA_DIR = paths.DATA_DIR _VAPID_PATH = os.environ.get("THERMOGRAPH_VAPID_FILE") or os.path.join(_DATA_DIR, "vapid.json") # The VAPID "sub" claim — a contact the push service can reach about our traffic. -_CONTACT = os.environ.get("THERMOGRAPH_VAPID_CONTACT", "mailto:admin@thermograph.app") +_CONTACT = os.environ.get("THERMOGRAPH_VAPID_CONTACT", "mailto:admin@thermograph.org") # pywebpush 2.0.0 forwards `timeout` straight to requests.post with no default of # its own — omit the kwarg here and the send blocks with NO timeout at all (the @@ -174,10 +174,13 @@ def send(subscription_info: dict, payload: dict) -> str: return "ok" except WebPushException as e: status = getattr(getattr(e, "response", None), "status_code", None) - if status in (404, 410): - return "gone" # endpoint retired — caller should delete it - # 401/403 = VAPID key mismatch, etc. Record it where it's visible (the errors - # JSONL / dashboard), not just the system journal. + if status in (401, 403, 404, 410): + # 404/410 = endpoint retired; 401/403 = VAPID mismatch (e.g. after a key + # rotation) — the row was minted under a key we can no longer sign for and + # will never authenticate again. All are permanently dead: caller deletes. + return "gone" + # Anything else (rate limits, 5xx, malformed payload) may be transient — keep + # the row and record it where it's visible (the errors JSONL / dashboard). log.warning("web push failed (status=%s): %s", status, e) audit.log_event("error", {"phase": "push", "status": status, "endpoint": (subscription_info.get("endpoint") or "")[:120]}) diff --git a/backend/tests/api/test_content_routes.py b/backend/tests/api/test_content_routes.py index 236601a..e172b9b 100644 --- a/backend/tests/api/test_content_routes.py +++ b/backend/tests/api/test_content_routes.py @@ -118,6 +118,28 @@ def test_records_payload_shape(client): assert body["canonical_path"] == "/climate/testville/records" +# --- https origin (jsonld url) ----------------------------------------------- + +def test_public_host_jsonld_url_is_https(client): + """The public site is HTTPS-only behind Caddy, which proxies plain HTTP, so + x-forwarded-proto reads "http". A request on the configured public host + (THERMOGRAPH_BASE_URL defaults to https://thermograph.org) must still fold an + https:// origin into the payload's jsonld url; otherwise the frontend renders + an http:// canonical/JSON-LD that 308-redirects.""" + import json as _json + hdr = {"host": "thermograph.org", "x-forwarded-proto": "http"} + for path in ("city/testville", "city/testville/records"): + s = _json.dumps(client.get(f"{BASE}/{path}", headers=hdr).json()) + assert "https://thermograph.org/thermograph/climate/testville" in s + assert "http://thermograph.org" not in s + + +def test_non_public_host_jsonld_keeps_forwarded_scheme(client): + hdr = {"host": "192.168.1.10:8000", "x-forwarded-proto": "http"} + body = client.get(f"{BASE}/city/testville", headers=hdr).json() + assert body["jsonld"]["@graph"][0]["url"].startswith("http://192.168.1.10:8000/") + + # --- 404s -------------------------------------------------------------------- def test_unknown_slug_is_404(client, counts): diff --git a/backend/tests/data/test_climate.py b/backend/tests/data/test_climate.py index fab8bad..de90ff3 100644 --- a/backend/tests/data/test_climate.py +++ b/backend/tests/data/test_climate.py @@ -208,6 +208,49 @@ def test_recent_forecast_falls_back_to_open_meteo(monkeypatch, tmp_path): assert df.height == om.height +def test_recent_forecast_om_drops_the_in_progress_local_day(monkeypatch): + """The Open-Meteo fallback must not grade the cell's in-progress local day: its + daily high/low is only a partial aggregate of the hours elapsed so far (a cool + morning would read as a record-low high). Past days and future forecast days + survive; today — per the UTC offset Open-Meteo reports for a timezone=auto + request — is dropped, matching the MET path's diurnal-coverage gate.""" + offset = 3 * 3600 # UTC+3, e.g. Europe/Vilnius (the reported Ringaudai incident) + local_today = (datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(seconds=offset)).date() + days = [local_today + datetime.timedelta(days=n) for n in (-2, -1, 0, 1)] + daily = { + "time": [d.isoformat() for d in days], + "temperature_2m_max": [80.0, 82.0, 61.0, 84.0], # today's 61 is the partial value + "temperature_2m_min": [60.0, 61.0, 57.0, 62.0], + "precipitation_sum": [0.0, 0.0, 0.0, 0.0], + } + payload = {"utc_offset_seconds": offset, "daily": daily} + + class Resp: + def json(self): return payload + monkeypatch.setattr(climate, "_request", lambda *a, **k: Resp()) + + got = climate._fetch_recent_forecast_om( + {"center_lat": 54.9, "center_lon": 23.8})["date"].to_list() + assert local_today not in got # partial today dropped + assert local_today - datetime.timedelta(days=1) in got # yesterday kept + assert local_today + datetime.timedelta(days=1) in got # tomorrow's forecast kept + assert len(got) == 3 + + +def test_recent_forecast_om_keeps_all_days_without_a_utc_offset(monkeypatch): + """No UTC offset reported -> skip the in-progress-day guard rather than guess a + date, so the bundle passes through as before (only the usual null-day filter).""" + payload = {"daily": _om_daily(3)} # no utc_offset_seconds + + class Resp: + def json(self): return payload + monkeypatch.setattr(climate, "_request", lambda *a, **k: Resp()) + + df = climate._fetch_recent_forecast_om({"center_lat": 1.0, "center_lon": 2.0}) + assert df.height == climate._to_frame(_om_daily(3)).height + + def test_recent_forecast_serves_stale_cache_when_all_sources_fail(monkeypatch, tmp_path): """With every source down (the NASA + MET Norway primary and the Open-Meteo fallback), an existing (stale) cache is served rather than failing — and it is NOT diff --git a/backend/tests/data/test_grading.py b/backend/tests/data/test_grading.py index cfe7d28..a2f512a 100644 --- a/backend/tests/data/test_grading.py +++ b/backend/tests/data/test_grading.py @@ -113,7 +113,9 @@ def test_dry_streaks_walk(): dates = [datetime.date(2024, 1, 1) + datetime.timedelta(days=i) for i in range(5)] precips = [0.5, 0.0, float("nan"), 0.02, 0.005] out = grading.dry_streaks(dates, precips) - assert list(out.values()) == [0, 1, 2, 0, 1] # NaN counts as dry + # Any rain > 0 breaks the streak (matching the dry/rain grading split), so the + # 0.005" trace day resets to 0; only exact 0.0 and NaN count as dry. + assert list(out.values()) == [0, 1, 2, 0, 0] # ---- range + day grading over a synthetic record -------------------------------- @@ -141,7 +143,7 @@ def test_grade_day_normals_and_departure(history): result = grading.grade_day(history, target, {"tmax": row["tmax"], "tmin": row["tmin"], "precip": row["precip"]}) assert set(result["normals"]["tmax"]) == {"p1", "p10", "p25", "p40", "p50", "p60", - "p75", "p90", "p99"} + "p75", "p90", "p95", "p99"} expected = max(abs(result["tmax"]["percentile"] - 50), abs(result["tmin"]["percentile"] - 50)) assert result["departure"] == round(expected, 1) diff --git a/backend/tests/notifications/test_push.py b/backend/tests/notifications/test_push.py index 422583f..5450778 100644 --- a/backend/tests/notifications/test_push.py +++ b/backend/tests/notifications/test_push.py @@ -1,11 +1,16 @@ """VAPID key resolution: the atomic first-writer-wins claim of the keypair file (push.py's `_claim_file`), and that `_load()` caches whatever that settles on -rather than its own local generation when it loses the race. +rather than its own local generation when it loses the race. Also `send()`'s +mapping of push-service responses onto 'ok'/'gone'/'error'. -No network — `send()`'s pywebpush call isn't exercised here (that's notify.py's -`test_push_dispatched_on_new_notification` etc., which stub `push.send` itself).""" +No network — where `send()` is exercised the pywebpush call is stubbed; the +dispatch path (notify.py's `test_push_dispatched_on_new_notification` etc.) stubs +`push.send` itself.""" import json +import pytest +from pywebpush import WebPushException + from notifications import push @@ -94,3 +99,44 @@ def test_load_prefers_env_over_file(monkeypatch, tmp_path): result = push._load() assert result == {"private_key": "priv-env", "public_key": "pub-env"} + + +# --- send() response mapping ------------------------------------------------ +_SUB = {"endpoint": "https://push.example.com/ep-1", "keys": {"p256dh": "BKEY", "auth": "YXV0aA"}} + + +class _Resp: + def __init__(self, status_code): + self.status_code = status_code + + +def _raise_status(status): + def _webpush(**kwargs): + raise WebPushException("boom", response=_Resp(status)) + return _webpush + + +@pytest.mark.parametrize("status", [401, 403, 404, 410]) +def test_send_prunes_permanently_dead_endpoints(monkeypatch, status): + # 404/410 = endpoint retired; 401/403 = VAPID key mismatch (e.g. after a key + # rotation). All are permanently dead, so send() reports 'gone' and the caller + # deletes the row — otherwise a rotated key leaves dead subscriptions forever. + monkeypatch.setattr(push, "webpush", _raise_status(status)) + assert push.send(_SUB, {"hello": "world"}) == "gone" + + +@pytest.mark.parametrize("status", [429, 500, 502]) +def test_send_keeps_row_on_transient_failure(monkeypatch, status): + # Rate limits / 5xx may recover; keep the row and report it as an error. + monkeypatch.setattr(push, "webpush", _raise_status(status)) + assert push.send(_SUB, {"hello": "world"}) == "error" + + +def test_send_ok(monkeypatch): + monkeypatch.setattr(push, "webpush", lambda **kwargs: None) + assert push.send(_SUB, {"hello": "world"}) == "ok" + + +def test_default_contact_is_the_org_domain(): + # The VAPID "sub" contact must be a domain we actually own; .app was a typo. + assert push._CONTACT == "mailto:admin@thermograph.org" diff --git a/backend/tests/web/test_api.py b/backend/tests/web/test_api.py index f6893fa..1048001 100644 --- a/backend/tests/web/test_api.py +++ b/backend/tests/web/test_api.py @@ -71,6 +71,18 @@ def test_api_version_reports_backend_contract(client): assert body["backend_version"] == "2" +def test_malformed_date_is_rejected(client): + # A malformed or non-calendar date must be a 422, not a 500: fromisoformat + # raises ValueError on these and the handler used to leak it as a crash. + for bad in ("notadate", "2026-13-40", "2026-02-30"): + for route in ("grade", "day"): + r = client.get(f"/thermograph/api/v2/{route}", params={**Q, "date": bad}) + assert r.status_code == 422, (route, bad, r.status_code) + # A valid date still works. + assert client.get("/thermograph/api/v2/grade", + params={**Q, "date": "2026-07-11"}).status_code == 200 + + def test_day_detail_and_ladders(client, history): r = client.get("/thermograph/api/v2/day", params=Q) assert r.status_code == 200 diff --git a/backend/web/app.py b/backend/web/app.py index b8a5d35..5949033 100644 --- a/backend/web/app.py +++ b/backend/web/app.py @@ -79,6 +79,20 @@ def _weather_fetch_error(e) -> HTTPException: return HTTPException(status_code=502, detail=f"weather data fetch failed: {e}") +def _parse_target_date(date: str | None, default: datetime.date) -> datetime.date: + """Parse the ``date`` query param (YYYY-MM-DD) into a date, falling back to + ``default`` when it's absent. A malformed or non-calendar date (e.g. 2026-13-40) + is a client error, not a crash: surface it as a 422 rather than let + fromisoformat's ValueError become a 500.""" + if not date: + return default + try: + return datetime.date.fromisoformat(date[:10]) + except ValueError: + raise HTTPException(status_code=422, + detail=f"invalid date: {date!r} (expected YYYY-MM-DD)") + + # --- background neighbor warming --------------------------------------------- # /cell?neighbors=1 asks the server to warm the 8 grid cells around the request # (the frontend used to fire 8 staggered prefetch requests, mirroring grid.py's @@ -492,7 +506,7 @@ def api_grade( description="days to grade after the target (observed, or forecast when future; " "the forecast reaches ~7 days out so future days cap there)"), ): - target = datetime.date.fromisoformat(date[:10]) if date else datetime.date.today() + target = _parse_target_date(date, datetime.date.today()) cell = grid.snap(lat, lon) with audit.RunAudit( @@ -580,7 +594,7 @@ def api_day( ) as run: history, cache_meta, _ = _fetch_history(run, cell) last = history["date"].max() - target = datetime.date.fromisoformat(date[:10]) if date else last + target = _parse_target_date(date, last) run.set(target_date=target.isoformat()) def build(place): diff --git a/frontend/content.py b/frontend/content.py index 6981b25..7295362 100644 --- a/frontend/content.py +++ b/frontend/content.py @@ -13,6 +13,7 @@ import hashlib import json import logging import os +from urllib.parse import urlsplit import httpx from fastapi import HTTPException, Request, Response @@ -56,13 +57,28 @@ _log = logging.getLogger(__name__) # --- helpers ------------------------------------------------------------- +# The configured public origin is the source of truth for the scheme. Caddy +# terminates TLS and reverse-proxies plain HTTP, so x-forwarded-proto (and +# request.url.scheme) read "http" even though the public site is HTTPS-only. +# Trusting that scheme makes every canonical / og:url / og:image / sitemap +# emit http://, which 308-redirects to https:// -- self-conflicting canonicals +# and a sitemap full of redirecting URLs. So a request that arrives on the +# configured public host is answered with the configured public scheme (https on +# prod/beta, both of which set THERMOGRAPH_BASE_URL per host); localhost/LAN dev +# never matches it and keeps the observed scheme, so plain-HTTP dev is unchanged. +_PUBLIC = urlsplit(os.environ.get("THERMOGRAPH_BASE_URL", "https://thermograph.org")) + + def _origin(request: Request) -> str: # x-forwarded-host takes precedence over host: when reached via backend's # internal proxy fallback (no Caddy in front -- see _proxy_to_frontend in # backend/web/app.py), Host is the internal hop's own address, not the # browser-facing one. - proto = request.headers.get("x-forwarded-proto") or request.url.scheme host = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc + if _PUBLIC.scheme and _PUBLIC.netloc and host == _PUBLIC.netloc: + proto = _PUBLIC.scheme + else: + proto = request.headers.get("x-forwarded-proto") or request.url.scheme return f"{proto}://{host}" diff --git a/frontend/static/app.js b/frontend/static/app.js index 47fa6be..4b21646 100644 --- a/frontend/static/app.js +++ b/frontend/static/app.js @@ -8,7 +8,7 @@ import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette, tempChart, precipChart, dryChart, attachChartHover } from "./chart.js"; import { track } from "./digest.js"; import { TIER_COLORS, SCALE_TEMP, drynessColor, pctOrd, esc, todayISO, placeLabel, - tierKeySegs, GUIDE_LINK, fmtPrecip, fmtWind, fmtHumid } from "./shared.js"; + tierKeySegs, GUIDE_LINK, fmtPrecip, fmtPrecipTier, fmtWind, fmtHumid } from "./shared.js"; let selected = null; // {lat, lon} @@ -323,11 +323,14 @@ function precipCell(d, isFc, isToday) { const dd = ` data-date="${d.date}"`; const cls = "rd-c" + (isFc ? " rd-fc" : isToday ? " rd-today" : ""); if (!g) return ``; - if (g.value === 0 && d.dsr != null && d.dsr > 0) { - return `${d.dsr}d`; + // Only a genuinely dry day (no rain at all) labels the streak; any rain day — + // down to sub-0.01" trace that rounds to 0 — shows its depth, tinted by tier. + if (g.class === "dry" && d.dsr != null && d.dsr > 0) { + return `${d.dsr}d`; } const col = TIER_COLORS[g.class] || ""; - return `${cRain(g.value)}`; + const amt = g.class && g.class !== "dry" ? fmtPrecipTier(g.value, g.class, false) : cRain(g.value); + return `${amt}`; } // Compact "graded days" table — a ROW per metric, a COLUMN per day (newest first). diff --git a/frontend/static/calendar.js b/frontend/static/calendar.js index ed3f848..c2b19bd 100644 --- a/frontend/static/calendar.js +++ b/frontend/static/calendar.js @@ -7,7 +7,7 @@ import { uv } from "./account.js"; // header sign-in entry + notification bell import { TTL, chunkedFetch, prefetchViews } from "./cache.js"; import { initFindButton, setFindLabel } from "./mappicker.js"; import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, pctOrd, - placeLabel, fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd, + placeLabel, fmtPrecip, fmtPrecipTier, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd, CHUNK_MONTHS, buildChunks, clickOpensPicker, metricBuckets, distStrip, seasonFilterDropdown, seasonSummaryText, syncSeasonChecks, applySeasonMonthChange, initSeasonExpand, allMonths, monthsToMask, maskToMonths, @@ -42,7 +42,7 @@ const SKY_WORDS = [ // The shared weather summary, adapted to the calendar's compact day records // (dsr included, so a long-dry no-rain day reads "dry" rather than "clear"). const weatherType = (rec) => - wxType(rec.tmax && rec.tmax.v, rec.precip && rec.precip.v, rec.dsr); + wxType(rec.tmax && rec.tmax.v, rec.precip && rec.precip.v, rec.dsr, rec.precip && rec.precip.c); let selected = null; // {lat, lon} let data = null; // last /api/v2/calendar response @@ -704,7 +704,7 @@ function attachHover(byDate) { ["humid", line("humid", "Humid", rec.humid, fmtHumid, tempColor(rec.humid))], ["wind", line("wind", "Wind", rec.wind, fmtWind, tempColor(rec.wind))], ["gust", line("gust", "Gust", rec.gust, fmtWind, tempColor(rec.gust))], - ["precip", line("precip", "Precip", rec.precip, fmtPrecip, precipColor)], + ["precip", line("precip", "Precip", rec.precip, (v) => fmtPrecipTier(v, rec.precip && rec.precip.c), precipColor)], ]; if (dsrStr) { const rc = metric === "dsr" ? " tt-r-active" : ""; diff --git a/frontend/static/chart.js b/frontend/static/chart.js index d48dfae..6facfcd 100644 --- a/frontend/static/chart.js +++ b/frontend/static/chart.js @@ -64,7 +64,7 @@ const PCT_FALLBACK = { p1: ["p1", "p10", "min", "p50"], p10: ["p10", "p50"], p25: ["p25", "p30", "p50"], p40: ["p40", "p30", "p50"], p60: ["p60", "p70", "p50"], p75: ["p75", "p70", "p50"], - p90: ["p90", "p50"], p99: ["p99", "p90", "max", "p50"], + p90: ["p90", "p50"], p95: ["p95", "p90", "p50"], p99: ["p99", "p90", "max", "p50"], }; const pget = (o, k) => { for (const kk of (PCT_FALLBACK[k] || [k])) if (o && o[kk] != null) return o[kk]; @@ -93,8 +93,12 @@ const TEMP_FAN = [ ["p90", "p99", "very-hot"], ["p75", "p90", "hot"], ["p60", "p75", "warm"], ["p40", "p60", "normal"], ["p25", "p40", "cool"], ["p10", "p25", "cold"], ["p1", "p10", "very-cold"], ]; +// Eight rain-intensity tiers (post-split): Trace / Light / Brisk / Typical / Heavy +// (60-90) / Very Heavy (90-95) / Severe (95-99) / Extreme. Each band maps a rain-day +// percentile range to its calendar tier colour; the p75 vertex inside the single +// Heavy tier keeps the fan following the p75 contour. const RAIN_FAN = [ - ["p90", "p99", "wet-8"], ["p75", "p90", "wet-7"], ["p60", "p75", "wet-6"], + ["p95", "p99", "wet-8"], ["p90", "p95", "wet-7"], ["p75", "p90", "wet-6"], ["p60", "p75", "wet-6"], ["p40", "p60", "wet-5"], ["p25", "p40", "wet-4"], ["p10", "p25", "wet-3"], ["p1", "p10", "wet-2"], ]; @@ -212,7 +216,7 @@ export function precipChart(days, n, xFor, C) { getPct: (d) => (d.precip ? d.precip.percentile : null), // Rain days take their intensity-tier color; no-rain days warm with the dry // streak (tan→red) so a dry spell reads as dry, matching the Dry chart. - dotColor: (d) => (d.precip && d.precip.value > 0 ? (TIER_COLORS[d.precip.class] || C.precip) : (drynessColor(d.dsr) || C.precip)), + dotColor: (d) => (d.precip && d.precip.class && d.precip.class !== "dry" ? (TIER_COLORS[d.precip.class] || C.precip) : (drynessColor(d.dsr) || C.precip)), fmtAxis: (v) => fmtPrecip(v, false), fmtLabel: (v) => fmtPrecip(v, false), labelOn: (v) => v > 0, // only label rain days; dry days rest on the baseline diff --git a/frontend/static/day.js b/frontend/static/day.js index c07aec3..3760323 100644 --- a/frontend/static/day.js +++ b/frontend/static/day.js @@ -6,8 +6,8 @@ import { fmtTemp, onUnitChange } from "./units.js"; import { uv } from "./account.js"; // header sign-in entry + notification bell, and the API-version resolver import { getJSON, TTL, prefetchViews } from "./cache.js"; import { initFindButton, setFindLabel } from "./mappicker.js"; -import { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, pctOrd, fmtPrecip, fmtWind, fmtHumid, - todayISO, weatherType, placeLabel } from "./shared.js"; +import { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, pctOrd, fmtPrecip, fmtPrecipTier, fmtWind, fmtHumid, + todayISO, isoOfDate, weatherType, placeLabel } from "./shared.js"; // Color a tier the same way the calendar cell would be colored for it. const tierColor = (c) => (c === "dry" ? DRY_COLOR : TIER_COLORS[c] || PRECIP_COLORS[c] || ""); @@ -44,7 +44,9 @@ function stepDay(delta) { if (!curDate) return; const d = new Date(curDate + "T00:00:00"); d.setDate(d.getDate() + delta); - const iso = d.toISOString().slice(0, 10); + // Format in LOCAL time — curDate was parsed as local midnight, so toISOString() + // (UTC) would shift the result a day for UTC± viewers and desync the today guard. + const iso = isoOfDate(d); if (iso > todayISO()) return; // don't step past today curDate = iso; fetchDay(); @@ -115,7 +117,8 @@ function render(data) { // Weather summary from the observed high + precip (omitted for days with no obs yet). const th = d.metrics.tmax.obs ? d.metrics.tmax.obs.value : null; const pr = d.metrics.precip.obs ? d.metrics.precip.obs.value : null; - const wt = th != null || pr != null ? weatherType(th, pr) : null; + const prCls = d.metrics.precip.obs ? d.metrics.precip.obs.class : null; + const wt = th != null || pr != null ? weatherType(th, pr, undefined, prCls) : null; dayHead.innerHTML = `

${nice}

${wt ? `

${wt.icon} ${wt.text}

` : ""} @@ -155,11 +158,14 @@ function ladderCard(title, m, fmt, kind) { if (!m || !m.ladder) return ""; const obs = m.obs; const activeClass = obs ? obs.class : null; + // The observed precip depth reads "trace" for a sub-0.01" rain day (rounds to 0 + // but is graded a rain tier); ladder tier ranges keep the plain numeric formatter. + const obsFmt = kind === "precip" ? ((v) => fmtPrecipTier(v, activeClass)) : fmt; let summary; if (obs) { const pct = obs.percentile == null ? "" : ` · ${pctOrd(obs.percentile)} pct`; - summary = `${fmt(obs.value)} + summary = `${obsFmt(obs.value)} ${obs.grade}${pct}`; } else { summary = `No observation for this day yet`; @@ -182,7 +188,7 @@ function ladderCard(title, m, fmt, kind) { else if (t.lo == null) val = `< ${fmt(t.hi)}`; // bottom tier: strictly below p1 else val = fmtRange(fmt, t.lo, t.hi); const marker = t.c === activeClass && obs - ? `◀ ${bareVal(fmt, obs.value)}` : ""; + ? `◀ ${bareVal(obsFmt, obs.value)}` : ""; return `
${t.label} diff --git a/frontend/static/push-client.js b/frontend/static/push-client.js index 8e61f8b..f9081b1 100644 --- a/frontend/static/push-client.js +++ b/frontend/static/push-client.js @@ -36,6 +36,21 @@ async function registration() { return navigator.serviceWorker.ready; } +// Does an existing subscription's baked-in applicationServerKey still match the +// server's current VAPID public key? After a key rotation it won't: the browser +// keeps signing pushes the server can no longer authenticate, so we must replace +// the subscription. `options.applicationServerKey` is an ArrayBuffer of the raw +// key bytes (or null on browsers that don't expose it — treat that as a mismatch +// and re-subscribe rather than leaving a possibly-stale subscription in place). +function applicationServerKeyMatches(sub, serverKey) { + const current = sub.options && sub.options.applicationServerKey; + if (!current) return false; + const a = new Uint8Array(current); + if (a.length !== serverKey.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== serverKey[i]) return false; + return true; +} + // Is this device currently subscribed? (a PushSubscription exists locally) export async function isEnabled() { if (!supported()) return false; @@ -60,13 +75,24 @@ export async function enable() { const reg = await registration(); let sub = await reg.pushManager.getSubscription(); + + // Always resolve the server's current VAPID key: it's needed to subscribe, and + // — when a subscription already exists — to detect a key rotation. A subscription + // minted under an old key silently stops receiving pushes, so on a mismatch we + // drop it and re-subscribe with the new key. Matching key = happy path, untouched. + const keyRes = await apiFetch(uv("push/vapid-key")); + if (!keyRes.ok) throw new Error("Couldn't fetch the server key."); + const { key } = await keyRes.json(); + const serverKey = urlB64ToUint8Array(key); + + if (sub && !applicationServerKeyMatches(sub, serverKey)) { + await sub.unsubscribe(); + sub = null; + } if (!sub) { - const res = await apiFetch(uv("push/vapid-key")); - if (!res.ok) throw new Error("Couldn't fetch the server key."); - const { key } = await res.json(); sub = await reg.pushManager.subscribe({ userVisibleOnly: true, - applicationServerKey: urlB64ToUint8Array(key), + applicationServerKey: serverKey, }); } const res = await apiFetch(uv("push/subscribe"), { method: "POST", json: sub.toJSON() }); diff --git a/frontend/static/shared.js b/frontend/static/shared.js index 423c5ea..a6e5224 100644 --- a/frontend/static/shared.js +++ b/frontend/static/shared.js @@ -257,7 +257,10 @@ export const pctOrd = (n) => { if (!Number.isFinite(v)) return "—"; return ord(Math.min(99, Math.max(1, Math.round(v)))); }; -export const todayISO = () => new Date().toISOString().slice(0, 10); +// Today in the viewer's LOCAL zone (see isoOfDate below). A UTC date rolls a day +// early/late for UTC± viewers past local midnight, putting "today" and the +// date-picker max out of reach of the day they're actually living in. +export const todayISO = () => isoOfDate(new Date()); export const esc = (s) => String(s).replace(/&/g, "&").replace(//g, ">"); // The heading label for a graded response: the resolved place name, or the // cell-center coordinates while (or if) no name resolves. @@ -464,13 +467,28 @@ export const WX_ICONS = { snow: WX(``), }; +// A graded precip depth for display, given its grade class. Reanalysis "trace" +// rain — any measurable precip below 0.01" — is graded as a rain tier but rounds to +// 0.00" / 0 mm, so its depth prints as "trace" rather than a bone-dry "0.00" that +// would contradict the rain grade (and the now-reset dry streak). Genuinely dry +// days (class "dry") and real, roundable depths format as usual. +export function fmtPrecipTier(v, cls, withUnit = true) { + if (cls && cls !== "dry" && v != null && parseFloat(fmtPrecip(v, false)) === 0) + return "trace"; + return fmtPrecip(v, withUnit); +} + // Plain-language "what was the day like" descriptor from the raw values: a // temperature word (from the daily high, °F) crossed with a sky/precip word // (precip in inches — falling as snow at/below freezing). `dsr` is optional: // when a long dry streak is known, a no-rain day reads "dry" instead of // "clear" (the calendar passes it; the day page doesn't track streaks). -export function weatherType(t, p, dsr) { - const wet = p != null && p >= 0.01; +// `precipCls` is the precip grade class when known: a rain tier means it rained +// even when the rounded depth reads 0 (trace), so it decides wet/dry ahead of the +// depth — matching the dry/rain grading split (precip > 0). Without it, any +// positive depth counts as wet. +export function weatherType(t, p, dsr, precipCls) { + const wet = precipCls != null ? precipCls !== "dry" : (p != null && p > 0); const freezing = t != null && t <= 34; const temp = t == null ? "" : t >= 95 ? "Scorching" : t >= 85 ? "Hot" : t >= 72 ? "Warm" : diff --git a/frontend/tests/unit/test_rendering.py b/frontend/tests/unit/test_rendering.py index 496580e..a10f9f8 100644 --- a/frontend/tests/unit/test_rendering.py +++ b/frontend/tests/unit/test_rendering.py @@ -29,6 +29,51 @@ def test_sitemap_lists_city_urls(client): assert f"/climate/{SLUG}/records" in r.text +# The public site is HTTPS-only behind Caddy, which terminates TLS and proxies +# plain HTTP -- so x-forwarded-proto / request.url.scheme read "http". A request +# arriving on the configured public host (THERMOGRAPH_BASE_URL defaults to +# https://thermograph.org in the test env) must still emit https:// in every +# frontend-built absolute URL, or canonicals self-conflict and the sitemap lists +# redirecting URLs. +_PUBLIC = {"host": "thermograph.org", "x-forwarded-proto": "http"} + + +def test_public_host_canonical_and_og_are_https(client): + b = client.get(f"{B}/climate/{SLUG}", headers=_PUBLIC).text + assert f'https://thermograph.org{B}/climate/{SLUG}" in body + assert "http://thermograph.org" not in body + + +def test_public_host_robots_sitemap_is_https(client): + body = client.get(f"{B}/robots.txt", headers=_PUBLIC).text + assert f"Sitemap: https://thermograph.org{B}/sitemap.xml" in body + + +def test_non_public_host_keeps_forwarded_scheme(client): + # A LAN/dev host never matches the configured public host, so the observed + # (plain-http) scheme is preserved -- http development is unaffected. + b = client.get(f"{B}/climate/{SLUG}", + headers={"host": "192.168.1.10:8000", "x-forwarded-proto": "http"}).text + assert f'