* Hide footer email digest signup for now
Comment out the compact digest signup form in the site footer; restore
by removing the surrounding Jinja comment wrapper.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Update digest test for hidden footer form
The footer signup form is now hidden; assert its absence site-wide.
The /digest endpoint stays live and its tests are unchanged.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
Search Console shows impressions skewing hard to °C countries — India, South
Africa, New Zealand, the UK, Australia — while every climate page rendered °F
into the HTML. Search snippets quote the server-rendered text, so a reader in
Delhi saw a temperature they had to convert in their head before the page was
worth a click.
Climate pages now render temperatures in the city's country convention: °F for
the US and the handful of other Fahrenheit countries, °C everywhere else. This
happens server-side, so it holds with no JS and shows up in the snippet.
data-temp-f keeps the raw Fahrenheit in every case — it stays the conversion
source of truth, and climate.js repaints from it on toggle exactly as before.
The visitor's stored choice still wins over everything; the page default only
sits between that and the locale guess, so a first-time visitor stops seeing a
°F→°C repaint on load for the majority of our traffic.
The unit rides on a ContextVar rather than a parameter because _temp() is
reached from both directions — templates call it as a Jinja global, and the
context builders call it directly to pre-render the records tables into strings.
Threading an argument would mean touching ~20 call sites across three levels of
nesting, where missing one yields a silently wrong unit instead of an error. The
scope wraps the context builder as well as the render, since the builder is
evaluated as an argument and runs first, and it resets in a finally: the page
handlers are sync `def`, so Starlette runs them on a recycled threadpool thread
and a value left set would leak into the next request that thread serves.
Conversion rounds half-up to match JS's Math.round rather than Python's
round-half-to-even, so the server prints the number climate.js would and there
is no flicker on load for a visitor whose unit already matched.
Pages with no single city — the homepage strip, the hub, the interactive views —
set no scope, emit no data-unit-default, and keep today's locale behaviour.
Precipitation, wind and humidity stay imperial; that inconsistency is worth a
follow-up but is outside this change.
Two existing assertions were passing for the wrong reason: base.html.j2 mentions
"°F/°C" in an HTML comment, so a bare `"°F" in html` check succeeded even on a
page whose every temperature was Celsius. They now read the rendered spans.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
The homepage was a bare tool: a find-bar and an empty panel reading "Find a
location to begin." A visitor arriving from a search result or a shared link
learned nothing about what the product does before deciding to leave.
Rebuild it around the Weekly view, which is untouched:
- Hero with the question as the h1, and a grade card showing a real graded
example — the most unusual city we're currently tracking, either tail. The
card's frame and text slots are server-rendered with reserved heights, so
app.js re-pointing it at the visitor's own place shifts nothing.
- "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city
is force-included whenever one qualifies.
- Stance line, how-it-works, explore cards, and 12 city chips linking into the
~1000-page /climate surface.
- Monthly digest form, in the footer of every page.
Serve / from Jinja instead of a static file with placeholder substitution, so
crawlers and no-JS readers get the whole page as real HTML. home.html.j2
extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand
degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted
rather than left behind the static mount, where it would keep serving indexable
duplicate content.
"Where is it most unusual right now" has no cheap answer at request time —
percentiles live inside zlib-compressed payload blobs with no column to sort on.
So homepage.py sweeps the warm cache and writes data/homepage.json, read by the
template. The sweep is strictly cache-only (climate.load_cached_recent_forecast
is new, the sibling of load_cached_history), so grading ~1000 cities costs zero
upstream requests. It rides the notifier's timer behind an hourly guard rather
than starting a second daemon, and also runs at the tail of warm_cities so a
fresh deploy has a populated feed.
Instrumentation: metrics gains a product-event counter keyed by (event,
referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by
POST /api/v2/event. The referrer is taken from the request's own header, never
from the client. The beacon gets its own inbound category that record_inbound
ignores, so reporting an interaction doesn't also count as traffic. The
dashboard grows an events block with per-referrer attribution.
Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a
local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the
choice between direct-to-MX and relaying through a provider stays a Postfix
config change with no code change. The backend defaults to "console", which
logs and sends nothing, so dev and tests exercise the whole signup path safely.
Signups land in pending_digest unconfirmed; collecting the list shouldn't wait
on delivery.
Also adds /privacy, linked from the footer and kept out of the sitemap.
The strip's classes are named unusual-* rather than record-*: .record-card is
already the SEO records page's, and reusing it leaked layout rules onto
/climate/<slug>/records.
The glyph previously sat outside the h1, centered by the flex row
against the whole title block - so its position relative to the
wordmark shifted between pages with and without a tagline. Move it
inside the h1 (and inside the home link on server-rendered pages) and
lay the lockup out as its own flex row, so mark and wordmark are always
aligned to each other; the tagline now sits under the full lockup.
Claude-Session: https://claude.ai/code/session_01KdTZCjpLeD26ZbXe6ApjpR
The mark is a quantized climb through the temperature-band tokens
(cold -> cool -> normal -> warm -> hot -> very-hot) to a rec-hot
terminal at a dashed record line, on the dark rounded tile.
- favicon.svg (canonical) + favicon-16/32/48.png fallbacks; the 16px
raster drops the whisper strata and record dash so the climb survives
- apple-touch-icon.png 180 rendered edge-to-edge (iOS supplies rounding)
- maskable 192/512 scale the artwork 0.78 about center so every element
clears the 40%-radius circular safe zone; any-purpose 192/512 and the
512 OG logo.png regenerated; reused filenames cache-busted with ?v=2
- header lockup: inline glyph referencing var(--cold) etc so a palette
change propagates, beside a lowercase 650-weight wordmark
- PNGs are palette-quantized with the band/chrome hexes pinned exact;
full asset set is ~20 KB
Claude-Session: https://claude.ai/code/session_01KdTZCjpLeD26ZbXe6ApjpR
Put Alerts in the main navigation (after Climate) across the interactive pages
and the SEO/climate pages, so it's a discoverable entry point rather than only the
header bell. The bell stays; this is an additional way in.
The signed-out /alerts gate now leads with a clear "What are weather alerts?"
explainer — what they do, how the percentile threshold works, and an example —
above the sign-in / create-account button, so visitors understand the feature
before signing in.
The extra nav item pushed the inline header past ~800px into a horizontal
scrollbar; trim the tab padding in the 641–1000px band so the row fits again
(phones already collapse into the hamburger).
Faster search-engine indexing for the ~14k climate/city/record URLs:
- IndexNow (backend/indexnow.py): instantly notify Bing/DuckDuckGo/Yandex of new
or changed URLs. Per-host key resolved env → gitignored file → generated (mirrors
push.py), served at /{key}.txt, and echoed in submissions. `submit_all()` + a
`make indexnow` CLI push every indexable URL, batched under the 10k cap. Google
doesn't use IndexNow, so it stays on the sitemap.
- Sitemap: replace the per-request today() <lastmod> (which churns every fetch and
trains crawlers to ignore lastmod) with a stable content-build date; factor the
URL list into public_paths() shared with IndexNow so the two never drift.
- Search-console verification: render google-site-verification + msvalidate.01
<meta> tags from env into every page's <head> — the SEO pages via base.html.j2
and the static pages (incl. the homepage Google verifies) via _page().
- Docs: env example documents the three new vars; .gitignore ignores the key.
Tests: key-file route, stable single-date lastmod, IndexNow payload/batching, and
verification meta on both SEO and static pages.
The server-rendered SEO pages shared base.html.j2's nav but carried no JS, so
they lacked the °F/°C toggle and account/bell, and their temperatures were baked
as dual-unit strings that couldn't respond to a toggle.
- Load units.js + account.js + climate.js on base.html.j2, so every SEO page gets
the same header as the interactive pages (nav + °F/°C toggle + account + bell).
- Emit each temperature as <span class="temp" data-temp-f> (default °F text, real
for crawlers/no-JS); climate.js rewrites them to the active unit on load and on
toggle. Tint classes stay pinned to absolute °F. Drops the inline "(NN°C)".
- Make account.js base-aware: derive the app base from import.meta.url so its
api/v2 fetches and the alerts links resolve from deep /climate/{slug} URLs
instead of 404ing. Equivalent on the depth-1 interactive pages.
- Default the unit from the browser locale for first-time visitors (no stored
pref): en-US → °F, everyone else → °C. A stored choice always wins.
- Compact the mobile menu: narrower panel (208px), tighter rows, and the nav
list now stretches to the panel width and left-aligns (it was centering because
the base .view-nav align-self:center overrode the panel's stretch).
- Stylish separation between entries: an inset hairline between each nav item,
suppressed around the active pill so nothing crosses it.
- Rename the "Day" view to "Day Detail" across the app nav, and add it to the
climate/city page nav (base.html.j2), which was missing it.
On phones the header spanned two rows: the title block plus a control bar of
the °F/°C toggle, bell, account and hamburger. Wrap the nav in a .nav-panel
inside the <details> menu and inject the °F/°C toggle and account/bell into that
panel instead of into the header row. On desktop the panel is display:contents,
so the inline top bar renders exactly as before (order restored via flex order);
on phones the panel becomes a right-anchored, viewport-clamped dropdown and the
header collapses to a single row (logo + name + hamburger, tagline hidden).
The viewport clamp also fixes the SEO/city pages, whose menu dropdown previously
ran off-screen.
- Collapse the view navigation into a hamburger menu on phones. The nav is
wrapped in a <details> (pure CSS, no JS — so it works on the crawlable SEO
pages too): transparent (display:contents) on desktop so the tabs render
inline, a dropdown behind a hamburger under 640px. The °F/°C toggle, bell and
account stay in the header bar; units.js/account.js now anchor their injected
controls on the wrapper.
- Replace the header logo: the ▚ glyph was a rotated Unicode char that fonts
rendered inconsistently (warped). Use a crisp inline SVG of the two accent
squares (currentColor) across all headers.
- Month pages: turn the plain "Visiting <city> in <month>?" line into a bordered
callout with a CTA button, matching the city page's travel box.
Verified in Firefox and Chromium at desktop and 390px, light and dark: desktop
nav inline, mobile hamburger opens the dropdown, logo crisp, travel note styled.
* SEO: generate curated city set for crawlable climate pages
gen_cities.py reuses the GeoNames index places.py already parses to select the top
~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when
it repeats the city name), and writes committed backend/cities.json. cities.py loads
it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping
for the upcoming hub + sitemap.
* SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene
- content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt
(disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates
the home/static pages plus every city, month, and records URL from cities.py).
Registered on the app before the StaticFiles mount so the routes win.
- templates/base.html.j2: shared layout with unique title/description, self-
referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate
link) and a footer link graph.
- Give each existing page a unique <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page.
- Pin jinja2.
* SEO: server-rendered per-city climate page (/climate/{slug})
The keystone crawlable page: for a city it snaps to the grid cell, loads the
archive (fetching once if missing, self-healing), and renders as real HTML — a
'how today compares' block (grade + percentile per metric from grade_day, tinted
by tier), a monthly normals table (climatology at each month's 15th, shown in °F
and °C), all-time records (new grading.all_time_records helper), a breadcrumb,
Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into
the interactive tool + month/records pages. Content-page CSS added to style.css
(renamed the table class to avoid colliding with the app's .normals flex row).
* SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages
Month pages render the exact-month long-tail ('average weather in {city} in
{month}') with that month's average high/low, typical p10-p90 range, month-specific
records, and prev/next month links. Records pages show all-time record highs/lows
per metric with dates (grading.all_time_records). Shared _resolve_city helper; the
literal /records route is registered before the {month} param and month names are
validated (unknown month -> 404).
* SEO: climate hub, weather glossary, and about/methodology pages
- /climate: crawlable directory of all ~500 cities grouped by country — the
internal-link graph that lets search engines discover every city page.
- /glossary + /glossary/{term}: plain-language definitions (climate normal,
percentile, temperature anomaly, feels-like, heat index, wind chill, humidity,
reanalysis) with DefinedTerm JSON-LD and cross-links into the tool.
- /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window,
percentile grading) for E-E-A-T. All linked from the shared footer.
* SEO: archive warmer, content-page tests, and deploy docs
- warm_cities.py: paced, idempotent offline warmer that pre-fetches each city
cell's archive so /climate pages serve from cache and a crawl can't burst the
archive quota (pages self-heal if hit before warming).
- tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap
enumerating city/month/records URLs, and that a rendered city page carries the
stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/
about routing and 404s.
- DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.