All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 7s
PR build (required check) / build-backend (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 7s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 1m0s
PR build (required check) / gate (pull_request) Successful in 2s
Ports frontend/ (Jinja2/FastAPI, ~1180 LOC) to Go with html/template. No climate math, no DB, no auth here -- every route fetches from the backend's /content/* API, so this is I/O-bound glue with no hard-porting wall; the risk was always in reproducing the rendering exactly, not the language. Verified with a golden-HTML diff, not just unit tests: both the Python original and the Go rewrite were run against the same committed fixtures (frontend/tests/fixtures/) and every one of the 11 routes compared byte-for-byte. The only surviving differences after that process are insignificant inter-tag whitespace and one attribute where Go's stricter escaper HTML-encodes an apostrophe Jinja left literal (functionally identical in every browser) -- confirmed programmatically by normalizing whitespace and unescaping before diffing, not by eyeballing. That process caught defects unit tests alone would have missed, because map[string]any has no compile-time field check: - Render-context keys were snake_case throughout (content.py's Jinja convention, ported verbatim) while the templates -- written independently -- read PascalCase fields. A missing map key doesn't error in html/template, it silently renders empty, so this was invisible in every status code and every "it built" signal: title, meta description, canonical URL, OpenGraph tags, the homepage's entire ranked list, and the brand-tag/nav-active state were all blank across every page. Fixed by renaming every key to match each template's own header comment (the authoritative per-page field contract) and, where an API struct's exported fields already matched what a template needed (contentapi.CityInfo, Crumb, HomeRanked, HubCountry, ...), passing the struct straight through instead of hand-rewrapping it in a map -- removes a whole layer of future drift risk, not just this instance of it. - Three pages 500'd outright: `.ToolHref` needed a fully-composed href string, not the bare "lat,lon" fragment the handlers were building; the all-time-records table needed the raw contentapi.AllTimeRecords struct, not a re-wrapped map. - JSON-LD was being double-encoded: `<script type="application/ld+json">` is JAVASCRIPT context to html/template's contextual escaper regardless of the script's `type` attribute, so a template.HTML-typed value placed there gets re-escaped as a quoted JS string instead of emitted raw -- the entire structured-data payload shipped as a JSON string containing JSON, which no crawler would parse as the intended object. Needed template.JS instead, the type that actually means "trusted JS source." The glossary term page's JSON-LD was simply never built at all (the Jinja original assembled it inline in the template rather than through content.py's context dict, and that got lost in translation) -- added. - html/template silently strips literal HTML comments AND JavaScript comments from the parsed output (verified in isolation, zero template actions involved) -- confirmed as real engine behavior, not a bug in either port, so both need a FuncMap function returning template.HTML / template.JS respectively to survive parsing rather than a literal `<!-- -->` or `//` in the template source. Packaging: multi-stage Go build, final image alpine (not distroless -- the Swarm stack's env-entrypoint.sh shim needs bash), 187MB -> 22.6MB. Two defects caught before they reached a host: - The Swarm stack overrides `entrypoint:` with no `command:`, which drops the image's own CMD entirely (Docker/Swarm semantics, not merged) -- env-entrypoint.sh then fell through to its hardcoded `exec uvicorn app:app` fallback, which doesn't exist in this image. Every deploy would have exited 127. Fixed with an explicit `command:` on the stack's frontend service, and corrected the shim's stale comment claiming CMD passes through automatically. - `COPY --chown=thermograph` resolves the group by NAME at copy time; Alpine's `adduser -S` with no `-G` doesn't create a same-named group, so the classic (non-BuildKit) Docker builder -- which this CI runner falls back to, since it installs plain `docker.io` with no buildx plugin -- failed outright. Fixed with an explicit group and numeric --chown. Verification: go build/vet/test -race clean across all packages; the Docker image builds and passes its embedded go test step under both BuildKit and the classic builder; shellcheck 0 findings on the one script touched; rebased onto current main (the ERA5 lake stack landed on both main and dev during this work -- confirmed additive, no overlap with frontend/daemon).
182 lines
No EOL
10 KiB
Cheetah
182 lines
No EOL
10 KiB
Cheetah
{{/* 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 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.</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}}&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}} · {{.PctLabel}} pct
|
|
{{- if .Normal}} · normal {{$.Fmt.Temp .Normal}}{{end}}
|
|
{{- if .DateLabel}} · {{.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>
|
|
· Data: ERA5 via Open-Meteo · NASA POWER · 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>{{/* 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. */}}
|
|
<script type="module" src="{{.AssetBase}}/climate.js"></script>
|
|
{{template "base_tail" .}} |