package content
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"net/http"
"strings"
"sync"
"thermograph/frontend/internal/contentapi"
"thermograph/frontend/internal/contentdata"
"thermograph/frontend/internal/format"
)
// metricKeyByLabel maps a record row's display label back to its metric key
// for the format dispatch (content.py's _METRIC_KEY_BY_LABEL). An unknown
// label is a payload-contract break and 500s, like the Python's KeyError.
var metricKeyByLabel = map[string]string{
"High": "tmax", "Low": "tmin", "Feels-like": "feels",
"Humidity": "humid", "Wind": "wind", "Gust": "gust", "Precip": "precip",
}
// --- shared ctx builders -----------------------------------------------------
//
// Where an contentapi.* struct's exported field names already match what a
// template needs (checked against internal/render/templates' own header
// comments — the authoritative contract), it is passed straight through
// rather than re-wrapped in a map: one fewer hand-typed key string to drift,
// and Go's ordinary pointer/struct nil semantics ({{if .X}}, auto-deref on
// field access) already do the right thing. Only genuinely COMPUTED display
// values (temp spans, range-bar geometry, tier classes) still build a map.
// findMonth returns the fmtMonths entry with the given slug, or nil (the
// template guards every use with {{if .Warmest}} etc., matching Python's
// next((m for m in months if m["slug"] == slug), None)).
func findMonth(months []map[string]any, slug string) any {
for _, m := range months {
if m["Slug"] == slug {
return m
}
}
return nil
}
// fmtMonths is content.py's _fmt_months: each payload month plus its
// display-formatted high/low/precip and range-bar geometry. Name/Slug/HighF/
// LowF/RangeLoF/RangeHiF/PrecipF are read directly off the payload elsewhere
// in the template (e.g. {{$.Fmt.TempClass .HighF}}), so they ride along
// alongside the computed High/Low/Precip/Bar rather than being dropped.
func fmtMonths(unit format.Unit, months []contentapi.CityMonth) []map[string]any {
out := make([]map[string]any, 0, len(months))
for _, m := range months {
out = append(out, map[string]any{
"Name": m.Name, "Slug": m.Slug,
"HighF": m.HighF, "LowF": m.LowF,
"RangeLoF": m.RangeLoF, "RangeHiF": m.RangeHiF,
"PrecipF": m.PrecipF,
"High": format.Temp(unit, m.HighF),
"Low": format.Temp(unit, m.LowF),
"Precip": format.Precip(unit, m.PrecipF),
"Bar": format.MonthRangeBar(m.RangeLoF, m.RangeHiF),
})
}
return out
}
// --- per-city climate page ---------------------------------------------------
// CityPage renders /climate/{slug} (content.py's city_page).
//
// ctx keys: Section, City, Display, Name, YearRange, NYears, Months,
// Warmest, Coldest, Wettest, Records, Today, ToolHref, Flavor, Event,
// CompareURL, Breadcrumb, CanonicalPath, PageTitle, PageDescription,
// JSONLDStr (+ the shared keys respondHTML adds).
func (h *Handlers) CityPage(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
j, err := h.api.City(slug, origin(r))
if err != nil {
h.apiError(w, err)
return
}
ctx, unit, err := h.cityCtx(j)
if err != nil {
h.serverError(w, err)
return
}
h.respondHTML(w, r, "city.html.tmpl", unit, ctx)
}
// cityCtx builds the render context — split from the handler so the port's
// behaviour tests can assert on it without the (separately-owned) templates.
func (h *Handlers) cityCtx(j *contentapi.CityPayload) (map[string]any, format.Unit, error) {
// The backend decides the page's display unit (its own F_COUNTRIES
// logic); month/records pages derive it from the country code instead —
// preserved as-is from the Python.
unit := format.Unit(j.DefaultUnit)
months := fmtMonths(unit, j.Months)
var today any
if j.TodayVsNormal != nil {
cards := make([]map[string]any, 0, len(j.TodayVsNormal.Cards))
for _, c := range j.TodayVsNormal.Cards {
cards = append(cards, map[string]any{
"Label": c.Label, "Metric": c.Metric, "ValueF": c.ValueF,
"Percentile": c.Percentile, "Grade": c.Grade, "Cls": c.Cls,
"Value": format.Fmt(unit, c.Metric, c.ValueF),
})
}
today = map[string]any{"Date": j.TodayVsNormal.Date, "Cards": cards}
}
jsonld, err := compactJSONLD(j.JSONLD)
if err != nil {
return nil, unit, err
}
// Flavor/Event: contentapi.Flavor/Event's own fields (Extract/Title/URL,
// Text/URL) already match what the template reads, so dereference-or-nil
// is enough — no map wrapping. A typed-nil *Flavor stored in the ctx
// directly would make {{.Flavor.Extract}} hard-error on a nil pointer
// (unlike a plain missing map key, which degrades to empty); dereferencing
// first avoids that and still renders {{if .Flavor}} correctly (Go
// template truthiness on a nil `any` is false; on a struct value, true).
var flavor, event any
if j.Flavor != nil {
flavor = *j.Flavor
}
if j.Event != nil {
event = *j.Event
}
ctx := map[string]any{
"Section": "climate",
"City": j.City, "Display": j.Display, "Name": j.City.Name,
"YearRange": j.YearRange, "NYears": j.NYears,
"Months": months,
"Warmest": findMonth(months, j.WarmestMonthSlug),
"Coldest": findMonth(months, j.ColdestMonthSlug),
"Wettest": findMonth(months, j.WettestMonthSlug),
"Records": j.AllTimeRecords, "Today": today,
// The full href, not just the "lat,lon" fragment: composing it here
// (rather than {{.Base}}/#{{.ToolHash}} in the template) avoids
// html/template's URL-context escaper %-escaping the comma.
"ToolHref": fmt.Sprintf("%s/#%.5f,%.5f", h.cfg.Base, j.City.Lat, j.City.Lon),
"Flavor": flavor,
"Event": event,
"CompareURL": fmt.Sprintf("%s/compare#loc=%.4f,%.4f", h.cfg.Base, j.City.Lat, j.City.Lon),
"Breadcrumb": j.Breadcrumb,
"CanonicalPath": j.CanonicalPath,
"PageTitle": j.PageTitle,
"PageDescription": j.PageDescription,
// template.JS, NOT template.HTML: this value is interpolated inside
// . html/template's
// contextual escaper treats . html/template's
// contextual escaper treats . html/template's
// contextual escaper treats