package content import ( "bytes" "encoding/json" "fmt" "html/template" "net/http" "strings" "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