thermograph/frontend/server/internal/render/templates/home.html.tmpl

185 lines
10 KiB
Cheetah
Raw Normal View History

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: <script type="application/ld+json"> is JAVASCRIPT context to html/template's escaper regardless of the script's type attribute, so template.HTML gets re-escaped as a quoted JS string. Needed template.JS. The glossary term page's JSON-LD was never built at all -- added. - html/template silently strips literal HTML and JS comments from parsed output (verified in isolation) -- both need a FuncMap function returning template.HTML/template.JS to survive. Packaging: 187MB -> 22.6MB. Two defects caught before reaching a host: the Swarm stack's entrypoint override with no explicit command drops the image's CMD entirely (every deploy would have exited 127), and COPY --chown by name fails under the classic Docker builder on Alpine. Both fixed. go build/vet/test -race clean; docker build passes its embedded test step under both BuildKit and the classic builder; shellcheck 0 findings.
2026-07-24 00:53:48 +00:00
{{/* 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" .}} <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
{{template "base_head_end" .}} <script type="application/ld+json">
{{.JSONLDStr}}
</script>
{{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. */}} <section id="hero" class="hero">
<div class="hero-copy">
<h1>How unusual is your weather?</h1>
<p class="hero-sub">Any day, anywhere on Earth, graded against 45 years of that place's own history.</p>
</div>
<div class="hero-card">{{/* --cat carries the band colour, the same convention .normal-card uses,
so the band token stays the single source of truth. */}}
<div class="grade-card" id="hero-grade" aria-live="polite"
{{if .Unusual}}style="--cat: var(--{{.Unusual.Cls}})"{{end}}>
<p class="grade-where" id="hero-where">
{{- if .Unusual -}}
Today in {{.Unusual.Display}}
{{- else -}}
Today
{{- end -}}
</p>
<p class="grade-band" id="hero-band">{{if .Unusual}}{{.Unusual.Grade}}{{else}}—{{end}}</p>
<p class="grade-detail" id="hero-detail">
{{- 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 -}}
</p>
{{if and .Unusual .Unusual.IsDefault}} <p class="grade-why muted">the most unusual weather we're tracking{{if .Stale}} · as of {{.Unusual.Date}}{{end}}</p>
{{end}} </div>
<div class="hero-actions">{{/* app.js hides the locate button outside a secure context, where the
browser refuses geolocation and it could only ever fail. */}}
<button type="button" id="hero-locate" class="btn-primary">Use my location</button>
<button type="button" id="hero-pick" class="btn-ghost">Pick on the map</button>
</div>
<p class="hero-locate-msg" id="hero-locate-msg" role="status" aria-live="polite" hidden></p>
<p class="hero-jump"><a href="#panel">See your week ↓</a></p>
</div>
</section>
<section class="controls">
<div class="find-bar">
<button type="button" id="find-btn" class="find-btn"></button>
<span id="loc-label" class="loc-label" hidden></span>
</div>
<div class="date-row">
<label>Target date
<input id="date-input" type="date" />
</label>
<button type="button" id="today-btn" class="today-chip" title="Reset the target date to today">Today</button>
<span class="hint"><a href="{{.AssetBase}}/legend">What do the grades mean? →</a></span>
</div>
</section>
<section id="panel" class="panel panel-full">
<div class="placeholder" id="placeholder">
<p>Find a location to begin.</p>
<p class="muted">Thermograph fetches decades of daily highs, lows, and precipitation
for your ~4&nbsp;sq&nbsp;mi cell, builds a &plusmn;7-day seasonal distribution for each
day of the year, and grades recent weather by where it falls on that distribution.</p>
</div>
<div id="results" hidden></div>
</section>
{{/* Sits outside #panel on purpose: app.js rewrites results.innerHTML
wholesale on every grade, so anything inside it would be destroyed. */}} <p class="stance-line muted">
Free. No ads. No tracking. No account needed.
<a href="{{.Base}}/about">How we work →</a>
</p>
{{/* --- 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}} <section class="unusual-strip" aria-labelledby="records-title">
<h2 id="records-title">Unusual right now</h2>
<ul class="unusual-row">
{{range .Ranked}} <li class="unusual-card" style="--cat: var(--{{.Cls}})">
<a href="{{$.AssetBase}}/day#lat={{.Lat}}&amp;lon={{.Lon}}"
data-event="home.records_click">
<span class="unusual-city">{{.Display}}</span>{{/* The reading itself, then what it is and what it should be —
a percentile means little without the value it describes. */}}
<span class="unusual-value">{{$.Fmt.Temp .Value}}</span>
<span class="unusual-band">{{.GradeLabel}}</span>
<span class="unusual-meta">
{{.MetricLabel}} &middot; {{.PctLabel}} pct
{{- if .Normal}} &middot; normal {{$.Fmt.Temp .Normal}}{{end}}
{{- if .DateLabel}} &middot; {{.DateLabel}}{{end}} </span>
</a>
</li>
{{end}} </ul>
<p class="unusual-more"><a href="{{.Base}}/climate">See all records →</a></p>
</section>
{{end}}
{{/* --- How it works ---------------------------------------------------- */}} <section class="how-it-works" aria-labelledby="how-title">
<h2 id="how-title">How it works</h2>
<ol class="how-steps">
<li>We keep 45 years of climate history (ERA5 reanalysis) for your exact ~2-mile grid square.</li>
<li>Today gets compared to the same two weeks of the year, every year back to 1980.</li>
<li>The result is a percentile and a plain-language grade, from Near Record cold to Near Record hot.</li>
</ol>
<p class="muted">
<a href="{{.AssetBase}}/legend">Full methodology →</a>
&middot; Data: ERA5 via Open-Meteo &middot; NASA POWER &middot; MET Norway
</p>
</section>
{{/* --- Explore --------------------------------------------------------- */}} <section class="explore" aria-labelledby="explore-title">
<h2 id="explore-title">Explore</h2>{{/* 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. */}}
<div class="explore-cards">
<a class="explore-card" href="{{.AssetBase}}/calendar" data-event="home.nav_calendar"
style="--cat: var(--warm)">
<span class="explore-name">Calendar</span>
<span class="explore-ramp" aria-hidden="true">{{/* Jinja looped over the nine tier
names here; unrolled so the CSS custom-property references stay literal. */}}
<i style="background: var(--rec-cold)"></i>
<i style="background: var(--very-cold)"></i>
<i style="background: var(--cold)"></i>
<i style="background: var(--cool)"></i>
<i style="background: var(--normal)"></i>
<i style="background: var(--warm)"></i>
<i style="background: var(--hot)"></i>
<i style="background: var(--very-hot)"></i>
<i style="background: var(--rec-hot)"></i>
</span>
<span class="explore-desc">Two years of your days, each colored by how unusual it was.</span>
</a>
<a class="explore-card" href="{{.AssetBase}}/compare" data-event="home.nav_compare"
style="--cat: var(--normal)">
<span class="explore-name">Compare</span>
<span class="explore-desc">Rank any places by comfort for a trip or a move.</span>
</a>
<a class="explore-card" href="{{.AssetBase}}/day" data-event="home.nav_day"
style="--cat: var(--cool)">
<span class="explore-name">Day</span>
<span class="explore-desc">Any date since 1980, anywhere: what was normal, what happened.</span>
</a>
<a class="explore-card" href="{{.AssetBase}}/alerts" data-event="home.nav_alerts"
style="--cat: var(--rec-hot)">
<span class="explore-name">Alerts</span>
<span class="explore-desc">Free percentile alerts: get pinged when your weather goes statistically weird.</span>
</a>
</div>
</section>
{{/* --- City hub links --------------------------------------------------
Real HTML links funnelling homepage authority into the ~1000-page
/climate surface. */}} <section class="city-hub" aria-labelledby="cities-title">
<h2 id="cities-title">Climate context for 1,000 cities</h2>
<ul class="city-chips">
{{range .Cities}} <li><a href="{{$.Base}}/climate/{{.Slug}}" data-event="home.nav_city">{{.Name}}</a></li>
{{end}} </ul>
<p><a href="{{.Base}}/climate" data-event="home.nav_cities">Browse all cities →</a></p>
</section>
{{template "base_body_end" .}} <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script type="module" src="{{.AssetBase}}/app.js"></script>{{/* Bookmarked locations: a star toggle in the results share row + a "Saved"
dropdown by the Find button. Loaded as its own module alongside app.js
(not merged into it) — see bookmarks-ui.js's header comment for why. */}}
<script type="module" src="{{.AssetBase}}/bookmarks-ui.js"></script>{{/* The records strip renders real temperatures server-side as .temp spans, so
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: <script type="application/ld+json"> is JAVASCRIPT context to html/template's escaper regardless of the script's type attribute, so template.HTML gets re-escaped as a quoted JS string. Needed template.JS. The glossary term page's JSON-LD was never built at all -- added. - html/template silently strips literal HTML and JS comments from parsed output (verified in isolation) -- both need a FuncMap function returning template.HTML/template.JS to survive. Packaging: 187MB -> 22.6MB. Two defects caught before reaching a host: the Swarm stack's entrypoint override with no explicit command drops the image's CMD entirely (every deploy would have exited 127), and COPY --chown by name fails under the classic Docker builder on Alpine. Both fixed. go build/vet/test -race clean; docker build passes its embedded test step under both BuildKit and the classic builder; shellcheck 0 findings.
2026-07-24 00:53:48 +00:00
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. */}}
<script type="module" src="{{.AssetBase}}/climate.js"></script>
{{template "base_tail" .}}