thermograph/web/content.py

945 lines
40 KiB
Python
Raw Normal View History

SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
"""Server-rendered, crawlable content pages (climate hub / per-city / month /
records / glossary / about) plus robots.txt and sitemap.xml.
These are the SEO surface: real URLs with the climate stats in the HTML, rendered
with Jinja2 from the same builders the API uses, linking into the interactive tool.
Registered on the app (via register()) BEFORE the StaticFiles mount so they win.
"""
Default climate pages to the city's own temperature unit (#189) 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
2026-07-19 18:25:55 +00:00
import contextlib
import contextvars
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
import datetime
import functools
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
import hashlib
import json
Default climate pages to the city's own temperature unit (#189) 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
2026-07-19 18:25:55 +00:00
import math
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
import os
import polars as pl
from fastapi import HTTPException, Request, Response
from fastapi.responses import PlainTextResponse
from jinja2 import Environment, FileSystemLoader, select_autoescape
from markupsafe import Markup
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
from api import homepage
from api import sitemap
from api.payloads import OBS_COLS
Split the backend into domain packages (#217) * Centralize filesystem paths in a single module Add paths.py, which resolves the repo root once and derives the cache, accounts DB, logs, templates, frontend and bundled-city-data locations from it. Replace the 13 per-module `dirname(__file__)/..` anchors with references to it, so a module's location no longer determines where the app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are unchanged; every resolved path is byte-identical to before. Groundwork for moving modules into packages without re-pointing paths. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62 * Split the backend into domain packages Group the flat backend modules into packages that mirror their concerns: data/ climate, grading, scoring, grid, places, cities, city_events, store web/ app, views, homepage, content, schemas notifications/ notify, digest, push, mailer, discord, discord_interactions, discord_link accounts/ models, users, api_accounts, db core/ metrics, singleton, audit Intra-project imports are rewritten to the package-qualified form. The entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor) and paths.py stay at the backend/ root, and backend/app.py becomes a shim re-exporting web.app:app so the launch target stays `app:app` — run.sh, the systemd units, and CI need no change. Verified: full suite (318) passes, `uvicorn app:app` boots and serves the home/SEO/static/API surfaces, and every root script imports clean. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
from data import cities
from data import city_events
from data import climate
from data import grading
from data import grid
Extract the glossary and static-page SEO copy into a schema-validated content/ tree (#241) The weather-terms glossary (9 entries) and four static pages' SEO title/ description were hardcoded directly in web/content.py - a 1019-line module that also owns all SSR rendering logic - mixed in with code that changes on a completely different cadence and for different reasons. Add content/glossary.yaml and content/pages.yaml (a new repo-root content/ tree, a sibling of backend/ and frontend/ paths.py resolves the same way - the seed of a future thermograph-copy repo per the architecture decision doc's own §4) plus web/content_loader.py: a small loader that validates each file's shape at load time (required fields present and non-empty, no duplicate glossary slugs) and fails loudly on a malformed edit rather than rendering a blank glossary card or an empty <title>. content.py's GLOSSARY dict and the about/privacy/hub/glossary_index page_title/description literals now come from the loader. Scope: only content that is genuinely pure static data with no embedded template logic. cities_flavor.json (Wikipedia extracts, already its own generated file) and the homepage's title/description (embedded in home.html.j2 as Jinja block overrides, a heavily-tested product-critical template) are deliberately left as they are - a future pass, not required for this one. UI microcopy bound to frontend logic stays in frontend/, per the doc's own line between content and frontend. Verified: content/glossary.yaml generated programmatically from the live GLOSSARY dict (not hand-transcribed) and round-tripped byte-for-byte identical against it; content/pages.yaml's four entries checked field-by- field against the original hardcoded strings. Full backend suite green (362 passed, 4 skipped) with zero existing test changes needed beyond one assertion made escaping-aware (a pre-existing Jinja double-escape quirk on the one title containing "&amp;", intentionally preserved not fixed). Built and booted the real Docker image: content/ present at /app/content, and curled /glossary, /glossary/percentine, /about, /privacy from inside the running container - all four render with the exact expected title text.
2026-07-21 01:21:11 +00:00
from web import content_loader
Split the backend into domain packages (#217) * Centralize filesystem paths in a single module Add paths.py, which resolves the repo root once and derives the cache, accounts DB, logs, templates, frontend and bundled-city-data locations from it. Replace the 13 per-module `dirname(__file__)/..` anchors with references to it, so a module's location no longer determines where the app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are unchanged; every resolved path is byte-identical to before. Groundwork for moving modules into packages without re-pointing paths. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62 * Split the backend into domain packages Group the flat backend modules into packages that mirror their concerns: data/ climate, grading, scoring, grid, places, cities, city_events, store web/ app, views, homepage, content, schemas notifications/ notify, digest, push, mailer, discord, discord_interactions, discord_link accounts/ models, users, api_accounts, db core/ metrics, singleton, audit Intra-project imports are rewritten to the package-qualified form. The entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor) and paths.py stay at the backend/ root, and backend/app.py becomes a shim re-exporting web.app:app so the launch target stays `app:app` — run.sh, the systemd units, and CI need no change. Verified: full suite (318) passes, `uvicorn app:app` boots and serves the home/SEO/static/API surfaces, and every root script imports clean. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
import paths
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
BASE = f"/{_BASE}" if _BASE else ""
Split the backend into domain packages (#217) * Centralize filesystem paths in a single module Add paths.py, which resolves the repo root once and derives the cache, accounts DB, logs, templates, frontend and bundled-city-data locations from it. Replace the 13 per-module `dirname(__file__)/..` anchors with references to it, so a module's location no longer determines where the app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are unchanged; every resolved path is byte-identical to before. Groundwork for moving modules into packages without re-pointing paths. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62 * Split the backend into domain packages Group the flat backend modules into packages that mirror their concerns: data/ climate, grading, scoring, grid, places, cities, city_events, store web/ app, views, homepage, content, schemas notifications/ notify, digest, push, mailer, discord, discord_interactions, discord_link accounts/ models, users, api_accounts, db core/ metrics, singleton, audit Intra-project imports are rewritten to the package-qualified form. The entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor) and paths.py stay at the backend/ root, and backend/app.py becomes a shim re-exporting web.app:app so the launch target stays `app:app` — run.sh, the systemd units, and CI need no change. Verified: full suite (318) passes, `uvicorn app:app` boots and serves the home/SEO/static/API surfaces, and every root script imports clean. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
TEMPLATES_DIR = paths.TEMPLATES_DIR
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
_env = Environment(
loader=FileSystemLoader(TEMPLATES_DIR),
autoescape=select_autoescape(["html", "xml", "j2"]),
trim_blocks=True,
lstrip_blocks=True,
)
MONTHS = ["january", "february", "march", "april", "may", "june",
"july", "august", "september", "october", "november", "december"]
MONTHS_TITLE = [m.capitalize() for m in MONTHS]
MONTH_INDEX = {m: i + 1 for i, m in enumerate(MONTHS)}
SEO: monthly & seasonal records + colour-coded climate pages (#107) * SEO: monthly & seasonal records on the city records page The /climate/<city>/records page showed only all-time records per metric. Expand it into a full records page: record high/low for each of the 12 months (each linking its month page) and for each meteorological season, alongside the existing all-time table. Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities and summer for southern ones (picked from the city's latitude). Records reuse grading.all_time_records over a month-filtered archive, so no new data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD block and richer title/description for the expanded coverage. * SEO: colour-code climate & records pages (heat-map + range strip) The city, records and month pages were plain muted tables — generic climate-site styling. Bring the interactive grader's diverging cold→hot palette onto them so they read as heat maps in the site's own visual language: - Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the temperature cells across the monthly-normals, monthly/seasonal/all-time records tables. The value stays in the ink token; the tint is a wash behind it. Non-temp rows (humidity/wind/precip) and date columns stay untinted. - Add a monthly temperature-range strip on the city page: one gradient bar per month spanning the average low→high on a shared −10..115°F axis, so a city's whole-year rhythm (and hot-vs-cold character) reads at a glance. - Tint the month page's hero high/low and its record values inline. Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix) and cold (Anchorage) cities.
2026-07-16 03:50:59 +00:00
# Meteorological seasons as (northern-hemisphere label, southern-hemisphere label,
# month numbers, span text). The same three months are one season everywhere — only
# the name flips across the equator (DecFeb is winter in the north, summer in the
# south), so a city's latitude picks the label.
SEASONS = [
("Winter", "Summer", [12, 1, 2], "DecFeb"),
("Spring", "Autumn", [3, 4, 5], "MarMay"),
("Summer", "Winter", [6, 7, 8], "JunAug"),
("Autumn", "Spring", [9, 10, 11], "SepNov"),
]
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
# Display labels for the graded metrics (order = how they appear on the page).
METRIC_LABELS = [
("tmax", "High"), ("tmin", "Low"), ("feels", "Feels-like"),
("humid", "Humidity"), ("wind", "Wind"), ("gust", "Gust"), ("precip", "Precip"),
]
_TEMP_METRICS = {"tmax", "tmin", "feels"}
Default climate pages to the city's own temperature unit (#189) 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
2026-07-19 18:25:55 +00:00
# Countries that actually use Fahrenheit. Mirrors F_REGIONS in frontend/units.js —
# the client applies it to the visitor's locale, we apply it to the city's country.
# A test asserts the two lists stay identical.
F_COUNTRIES = frozenset({"US", "PR", "GU", "VI", "AS", "MP", "UM",
"BS", "BZ", "KY", "PW", "FM", "MH", "LR"})
Report rain in whole millimetres; lift tier spans out of the bars (#192) Two fixes to how measurements read. Rain goes back to millimetres, rendered as whole numbers — that is how rainfall is reported, and a tenth of a millimetre is below what the source resolves. Inches keep two decimals, since a whole inch of rain is a lot to round to. The distribution strip's low-high span moves from inside each bar to just under the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min 17°" pill clipped — it had already been shrunk to 8px to cope, and on a high-rainfall city with longer values it lost both ends of the label, because the text is centred and overflow-hidden. Above the bar it has the full column width and needs no scrim to stay legible over nine tier colours. The span is now bare numbers ("17-62"), since the average directly above it carries the unit; repeating " g/m³" on both ends is what made it too wide in the first place. It uses the same precision as that average, or the two lines disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero so precip's ten columns still fit a phone. With nothing inside the bars, the height floor drops from 30px to 14px: it only has to keep a non-empty bucket visible now, so the heights encode frequency more honestly. Category names get 2px of side padding, which stops long neighbours ("Light-Mod" beside "Moderate") reading as one word. Verified at 390/412/1920 in both themes across all four calendar metrics and the compare strips, in imperial and metric: nothing clips. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
# One flag drives every measure: a °C page also gets mm and km/h. Mirrors the same
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
# decision in frontend/units.js.
Report rain in whole millimetres; lift tier spans out of the bars (#192) Two fixes to how measurements read. Rain goes back to millimetres, rendered as whole numbers — that is how rainfall is reported, and a tenth of a millimetre is below what the source resolves. Inches keep two decimals, since a whole inch of rain is a lot to round to. The distribution strip's low-high span moves from inside each bar to just under the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min 17°" pill clipped — it had already been shrunk to 8px to cope, and on a high-rainfall city with longer values it lost both ends of the label, because the text is centred and overflow-hidden. Above the bar it has the full column width and needs no scrim to stay legible over nine tier colours. The span is now bare numbers ("17-62"), since the average directly above it carries the unit; repeating " g/m³" on both ends is what made it too wide in the first place. It uses the same precision as that average, or the two lines disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero so precip's ten columns still fit a phone. With nothing inside the bars, the height floor drops from 30px to 14px: it only has to keep a non-empty bucket visible now, so the heights encode frequency more honestly. Category names get 2px of side padding, which stops long neighbours ("Light-Mod" beside "Moderate") reading as one word. Verified at 390/412/1920 in both themes across all four calendar metrics and the compare strips, in imperial and metric: nothing clips. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
MM_PER_IN = 25.4
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
KMH_PER_MPH = 1.609344
Default climate pages to the city's own temperature unit (#189) 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
2026-07-19 18:25:55 +00:00
# The unit the current page renders temperatures in. None = °F, and also means
# "this page has no city", which is what keeps the interactive pages on the
# client-side locale default instead of being pinned server-side.
#
# A ContextVar rather than a parameter because _temp() is reached from both sides:
# templates call it as a Jinja global, and the context builders call it directly
# (the records tables are pre-rendered into strings in Python). Threading a `unit`
# argument would mean touching ~20 call sites across three levels of nesting, where
# forgetting one yields a silently wrong unit rather than an error.
_UNIT: contextvars.ContextVar[str | None] = contextvars.ContextVar("display_unit", default=None)
@contextlib.contextmanager
def _unit_scope(unit: str | None):
"""Render temperatures in `unit` for the duration of the block.
The reset is not optional: the page handlers are sync `def`, so Starlette runs
them on a recycled threadpool thread, and a value left set would leak into
whatever request that thread serves next including pages with no city.
"""
token = _UNIT.set(unit)
try:
yield
finally:
_UNIT.reset(token)
def unit_for_country(code: str | None) -> str:
"""The temperature unit a reader in this country expects."""
return "F" if (code or "").upper() in F_COUNTRIES else "C"
def _round_half_up(x: float) -> int:
"""Round like JS's Math.round, not like Python's round().
Python rounds halves to even, JS rounds them up. Where they disagree the
server would render one number and climate.js would repaint a different one
a visible flicker for exactly the visitor whose unit already matched.
"""
return math.floor(x + 0.5)
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
def _c(f: float) -> int:
Default climate pages to the city's own temperature unit (#189) 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
2026-07-19 18:25:55 +00:00
return _round_half_up((f - 32) * 5 / 9)
def _shown(f: float) -> int:
"""The number to print, in the active unit."""
return _c(f) if _UNIT.get() == "C" else _round_half_up(f)
def _letter() -> str:
return "C" if _UNIT.get() == "C" else "F"
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
def _temp(f):
"""A Fahrenheit temperature as a client-convertible span, e.g.
Default climate pages to the city's own temperature unit (#189) 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
2026-07-19 18:25:55 +00:00
'<span class="temp" data-temp-f="72.3">72°F</span>'. The text is rendered in
the page's unit (the city's country convention see _unit_scope), so crawlers
and no-JS visitors get the local convention rather than a conversion they have
to do themselves; climate.js repaints on toggle. data-temp-f stays Fahrenheit
either way it is the conversion source of truth. '' for a missing value."""
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
if f is None:
return ""
Default climate pages to the city's own temperature unit (#189) 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
2026-07-19 18:25:55 +00:00
return Markup('<span class="temp" data-temp-f="{:.1f}">{}°{}</span>').format(
f, _shown(f), _letter())
def _temp_bare(f):
"""Like _temp() but with no unit letter ('72°'), for the range strip where the
axis already implies the unit. Still carries data-temp-f so it converts."""
if f is None:
return ""
Default climate pages to the city's own temperature unit (#189) 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
2026-07-19 18:25:55 +00:00
return Markup('<span class="temp" data-temp-f="{:.1f}" data-bare>{}°</span>').format(
f, _shown(f))
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
Lead climate page titles with the city and the payload (#188) Search Console's first 48 hours showed city-month pages drawing impressions at positions 44-77 and converting almost none of them: the title template spent its width on region and country ("Average weather in London, England, United Kingdom in July"), so Google cut it before the month a searcher was looking for. Titles now lead with the bare city name and put the subject next, which keeps both inside the first ~40 characters: London in July: normal weather & records London climate: daily normals, records & how unusual it is now London weather records: hottest & coldest days since 1980 Add cities.title_name() for the short form. 968 of the 1000 cities have a unique name and render bare; the 32 that don't are qualified with their country code ("London, GB"). Five names — Columbus, Arlington, Aurora, Glendale, Gorakhpur — recur *within* one country, where the country code collides too and would hand two different pages the same title, so those fall back to admin1 ("Columbus, Ohio"). A test asserts every rendered title is unique across the set. Meta descriptions now lead with the page's actual numbers rather than restating the template ("London averages highs of 73°F in July and lows of 38°F in January."), capped at the ~155 characters a SERP shows. The records title derives its year from the loaded history rather than a fixed date, so it can't contradict the page. H1s, URLs, breadcrumbs and JSON-LD are unchanged; og:title and og:description follow <title>/<meta> automatically through base.html.j2. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 18:17:13 +00:00
def _temp_text(f) -> str:
"""'72°F' as plain text. The span _temp() returns can't go in a
<meta content="">, and a meta description is the one place a temperature is
Default climate pages to the city's own temperature unit (#189) 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
2026-07-19 18:25:55 +00:00
never converted client-side it is what the search snippet quotes, which is
why it has to be in the city's own unit at render time."""
Lead climate page titles with the city and the payload (#188) Search Console's first 48 hours showed city-month pages drawing impressions at positions 44-77 and converting almost none of them: the title template spent its width on region and country ("Average weather in London, England, United Kingdom in July"), so Google cut it before the month a searcher was looking for. Titles now lead with the bare city name and put the subject next, which keeps both inside the first ~40 characters: London in July: normal weather & records London climate: daily normals, records & how unusual it is now London weather records: hottest & coldest days since 1980 Add cities.title_name() for the short form. 968 of the 1000 cities have a unique name and render bare; the 32 that don't are qualified with their country code ("London, GB"). Five names — Columbus, Arlington, Aurora, Glendale, Gorakhpur — recur *within* one country, where the country code collides too and would hand two different pages the same title, so those fall back to admin1 ("Columbus, Ohio"). A test asserts every rendered title is unique across the set. Meta descriptions now lead with the page's actual numbers rather than restating the template ("London averages highs of 73°F in July and lows of 38°F in January."), capped at the ~155 characters a SERP shows. The records title derives its year from the loaded history rather than a fixed date, so it can't contradict the page. H1s, URLs, breadcrumbs and JSON-LD are unchanged; og:title and og:description follow <title>/<meta> automatically through base.html.j2. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 18:17:13 +00:00
if f is None:
return ""
Default climate pages to the city's own temperature unit (#189) 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
2026-07-19 18:25:55 +00:00
return f"{_shown(f)}°{_letter()}"
Lead climate page titles with the city and the payload (#188) Search Console's first 48 hours showed city-month pages drawing impressions at positions 44-77 and converting almost none of them: the title template spent its width on region and country ("Average weather in London, England, United Kingdom in July"), so Google cut it before the month a searcher was looking for. Titles now lead with the bare city name and put the subject next, which keeps both inside the first ~40 characters: London in July: normal weather & records London climate: daily normals, records & how unusual it is now London weather records: hottest & coldest days since 1980 Add cities.title_name() for the short form. 968 of the 1000 cities have a unique name and render bare; the 32 that don't are qualified with their country code ("London, GB"). Five names — Columbus, Arlington, Aurora, Glendale, Gorakhpur — recur *within* one country, where the country code collides too and would hand two different pages the same title, so those fall back to admin1 ("Columbus, Ohio"). A test asserts every rendered title is unique across the set. Meta descriptions now lead with the page's actual numbers rather than restating the template ("London averages highs of 73°F in July and lows of 38°F in January."), capped at the ~155 characters a SERP shows. The records title derives its year from the loaded history rather than a fixed date, so it can't contradict the page. H1s, URLs, breadcrumbs and JSON-LD are unchanged; og:title and og:description follow <title>/<meta> automatically through base.html.j2. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 18:17:13 +00:00
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
def _precip(v):
"""Precipitation as a client-convertible span, the same contract as _temp():
text in the page's unit, data-precip-in always inches."""
if v is None:
return ""
return Markup('<span class="precip" data-precip-in="{:.3f}">{}</span>').format(
v, _precip_text(v))
def _precip_text(v) -> str:
Report rain in whole millimetres; lift tier spans out of the bars (#192) Two fixes to how measurements read. Rain goes back to millimetres, rendered as whole numbers — that is how rainfall is reported, and a tenth of a millimetre is below what the source resolves. Inches keep two decimals, since a whole inch of rain is a lot to round to. The distribution strip's low-high span moves from inside each bar to just under the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min 17°" pill clipped — it had already been shrunk to 8px to cope, and on a high-rainfall city with longer values it lost both ends of the label, because the text is centred and overflow-hidden. Above the bar it has the full column width and needs no scrim to stay legible over nine tier colours. The span is now bare numbers ("17-62"), since the average directly above it carries the unit; repeating " g/m³" on both ends is what made it too wide in the first place. It uses the same precision as that average, or the two lines disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero so precip's ten columns still fit a phone. With nothing inside the bars, the height floor drops from 30px to 14px: it only has to keep a non-empty bucket visible now, so the heights encode frequency more honestly. Category names get 2px of side padding, which stops long neighbours ("Light-Mod" beside "Moderate") reading as one word. Verified at 390/412/1920 in both themes across all four calendar metrics and the compare strips, in imperial and metric: nothing clips. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
"""Whole millimetres — that is how rainfall is reported, and a tenth of a
millimetre is below what the source resolves. Inches keep two decimals, since
a whole inch of rain is a lot to round to."""
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
if v is None:
return ""
Report rain in whole millimetres; lift tier spans out of the bars (#192) Two fixes to how measurements read. Rain goes back to millimetres, rendered as whole numbers — that is how rainfall is reported, and a tenth of a millimetre is below what the source resolves. Inches keep two decimals, since a whole inch of rain is a lot to round to. The distribution strip's low-high span moves from inside each bar to just under the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min 17°" pill clipped — it had already been shrunk to 8px to cope, and on a high-rainfall city with longer values it lost both ends of the label, because the text is centred and overflow-hidden. Above the bar it has the full column width and needs no scrim to stay legible over nine tier colours. The span is now bare numbers ("17-62"), since the average directly above it carries the unit; repeating " g/m³" on both ends is what made it too wide in the first place. It uses the same precision as that average, or the two lines disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero so precip's ten columns still fit a phone. With nothing inside the bars, the height floor drops from 30px to 14px: it only has to keep a non-empty bucket visible now, so the heights encode frequency more honestly. Category names get 2px of side padding, which stops long neighbours ("Light-Mod" beside "Moderate") reading as one word. Verified at 390/412/1920 in both themes across all four calendar metrics and the compare strips, in imperial and metric: nothing clips. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
return (f"{_round_half_up(v * MM_PER_IN)} mm" if _UNIT.get() == "C"
else f"{v:.2f} in")
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
def _wind(v):
"""Wind/gust as a client-convertible span; data-wind-mph is always mph."""
if v is None:
return ""
return Markup('<span class="wind" data-wind-mph="{:.1f}">{}</span>').format(
v, _wind_text(v))
def _wind_text(v) -> str:
if v is None:
return ""
return (f"{_round_half_up(v * KMH_PER_MPH)} km/h" if _UNIT.get() == "C"
else f"{_round_half_up(v)} mph")
Lead climate page titles with the city and the payload (#188) Search Console's first 48 hours showed city-month pages drawing impressions at positions 44-77 and converting almost none of them: the title template spent its width on region and country ("Average weather in London, England, United Kingdom in July"), so Google cut it before the month a searcher was looking for. Titles now lead with the bare city name and put the subject next, which keeps both inside the first ~40 characters: London in July: normal weather & records London climate: daily normals, records & how unusual it is now London weather records: hottest & coldest days since 1980 Add cities.title_name() for the short form. 968 of the 1000 cities have a unique name and render bare; the 32 that don't are qualified with their country code ("London, GB"). Five names — Columbus, Arlington, Aurora, Glendale, Gorakhpur — recur *within* one country, where the country code collides too and would hand two different pages the same title, so those fall back to admin1 ("Columbus, Ohio"). A test asserts every rendered title is unique across the set. Meta descriptions now lead with the page's actual numbers rather than restating the template ("London averages highs of 73°F in July and lows of 38°F in January."), capped at the ~155 characters a SERP shows. The records title derives its year from the loaded history rather than a fixed date, so it can't contradict the page. H1s, URLs, breadcrumbs and JSON-LD are unchanged; og:title and og:description follow <title>/<meta> automatically through base.html.j2. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 18:17:13 +00:00
def _month_year(date_s: str) -> str:
"""'1995-07-13' -> 'Jul 1995'. Meta descriptions have ~155 characters to
spend, so a record's date gives up its day."""
try:
return datetime.date.fromisoformat(str(date_s)).strftime("%b %Y")
except (ValueError, TypeError):
return str(date_s)
def _clamp_desc(text: str, limit: int = 155) -> str:
"""Meta descriptions are cut at ~155 characters in the SERP. Trim on a word
boundary so we choose where the sentence ends rather than Google doing it."""
text = " ".join(text.split())
if len(text) <= limit:
return text
return text[:limit].rsplit(" ", 1)[0].rstrip(",;—-") + ""
def _titled(text: str, suffix: str = " · Thermograph", limit: int = 60) -> str:
"""Brand the title only when it costs nothing — the payload ("… in July",
"hottest & coldest days") has to survive truncation first."""
return text + suffix if len(text) + len(suffix) <= limit else text
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
def _fmt(metric: str, v) -> str:
if v is None:
return ""
if metric in _TEMP_METRICS:
return _temp(v)
if metric == "precip":
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
return _precip(v)
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
if metric == "humid":
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
# Absolute humidity is g/m³ in both systems — there is no imperial unit for
# it anyone would recognise — so it is never converted.
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
return f"{v:.1f} g/m³"
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
return _wind(v) # wind, gust
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
SEO: monthly & seasonal records + colour-coded climate pages (#107) * SEO: monthly & seasonal records on the city records page The /climate/<city>/records page showed only all-time records per metric. Expand it into a full records page: record high/low for each of the 12 months (each linking its month page) and for each meteorological season, alongside the existing all-time table. Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities and summer for southern ones (picked from the city's latitude). Records reuse grading.all_time_records over a month-filtered archive, so no new data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD block and richer title/description for the expanded coverage. * SEO: colour-code climate & records pages (heat-map + range strip) The city, records and month pages were plain muted tables — generic climate-site styling. Bring the interactive grader's diverging cold→hot palette onto them so they read as heat maps in the site's own visual language: - Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the temperature cells across the monthly-normals, monthly/seasonal/all-time records tables. The value stays in the ink token; the tint is a wash behind it. Non-temp rows (humidity/wind/precip) and date columns stay untinted. - Add a monthly temperature-range strip on the city page: one gradient bar per month spanning the average low→high on a shared −10..115°F axis, so a city's whole-year rhythm (and hot-vs-cold character) reads at a glance. - Tint the month page's hero high/low and its record values inline. Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix) and cold (Anchorage) cities.
2026-07-16 03:50:59 +00:00
# Absolute-temperature colour tiers (°F upper bounds) mapped to the site's diverging
# cold→hot palette — the same 9 tiers the interactive grader uses. Colouring records
# and normals by these turns the tables into a heat map in the site's own visual
# language, rather than a plain grid.
_TEMP_TIERS = [
(20, "rec-cold"), (32, "very-cold"), (45, "cold"), (58, "cool"),
(70, "normal"), (80, "warm"), (90, "hot"), (100, "very-hot"),
]
# °F axis for the monthly temperature-range strip on the city page.
_AXIS_LO, _AXIS_HI = -10.0, 115.0
def temp_class(f) -> str:
"""Diverging-palette tier name for an absolute Fahrenheit temperature (or 'none')."""
if f is None:
return "none"
for upper, cls in _TEMP_TIERS:
if f < upper:
return cls
return "rec-hot"
def _range_bar(low_f, high_f) -> dict | None:
"""Geometry for one month's low→high bar on the shared _AXIS_LO.._AXIS_HI axis:
left offset + width as percentages, and the tier colour at each end (for a
gradient fill). None when a value is missing."""
if low_f is None or high_f is None:
return None
lo = max(_AXIS_LO, min(_AXIS_HI, low_f))
hi = max(_AXIS_LO, min(_AXIS_HI, high_f))
span = _AXIS_HI - _AXIS_LO
return {
"left": round((lo - _AXIS_LO) / span * 100, 1),
"width": round(max(2.0, (hi - lo) / span * 100), 1),
"c1": temp_class(low_f), "c2": temp_class(high_f),
}
Rebuild the homepage as a distribution landing; add an SMTP seam (#178) 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.
2026-07-18 07:39:47 +00:00
def _ordinal(n) -> str:
"""Percentile -> display ordinal. Delegates to grading so every surface
(Day page, calendar, chart, city pages, homepage strip) agrees."""
return grading.pct_ordinal(n)
Rebuild the homepage as a distribution landing; add an SMTP seam (#178) 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.
2026-07-18 07:39:47 +00:00
SEO: monthly & seasonal records + colour-coded climate pages (#107) * SEO: monthly & seasonal records on the city records page The /climate/<city>/records page showed only all-time records per metric. Expand it into a full records page: record high/low for each of the 12 months (each linking its month page) and for each meteorological season, alongside the existing all-time table. Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities and summer for southern ones (picked from the city's latitude). Records reuse grading.all_time_records over a month-filtered archive, so no new data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD block and richer title/description for the expanded coverage. * SEO: colour-code climate & records pages (heat-map + range strip) The city, records and month pages were plain muted tables — generic climate-site styling. Bring the interactive grader's diverging cold→hot palette onto them so they read as heat maps in the site's own visual language: - Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the temperature cells across the monthly-normals, monthly/seasonal/all-time records tables. The value stays in the ink token; the tint is a wash behind it. Non-temp rows (humidity/wind/precip) and date columns stay untinted. - Add a monthly temperature-range strip on the city page: one gradient bar per month spanning the average low→high on a shared −10..115°F axis, so a city's whole-year rhythm (and hot-vs-cold character) reads at a glance. - Tint the month page's hero high/low and its record values inline. Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix) and cold (Anchorage) cities.
2026-07-16 03:50:59 +00:00
_env.globals["temp_class"] = temp_class
_env.globals["temp"] = _temp
_env.globals["temp_bare"] = _temp_bare
Rebuild the homepage as a distribution landing; add an SMTP seam (#178) 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.
2026-07-18 07:39:47 +00:00
_env.filters["ordinal"] = _ordinal
SEO: monthly & seasonal records + colour-coded climate pages (#107) * SEO: monthly & seasonal records on the city records page The /climate/<city>/records page showed only all-time records per metric. Expand it into a full records page: record high/low for each of the 12 months (each linking its month page) and for each meteorological season, alongside the existing all-time table. Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities and summer for southern ones (picked from the city's latitude). Records reuse grading.all_time_records over a month-filtered archive, so no new data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD block and richer title/description for the expanded coverage. * SEO: colour-code climate & records pages (heat-map + range strip) The city, records and month pages were plain muted tables — generic climate-site styling. Bring the interactive grader's diverging cold→hot palette onto them so they read as heat maps in the site's own visual language: - Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the temperature cells across the monthly-normals, monthly/seasonal/all-time records tables. The value stays in the ink token; the tint is a wash behind it. Non-temp rows (humidity/wind/precip) and date columns stay untinted. - Add a monthly temperature-range strip on the city page: one gradient bar per month spanning the average low→high on a shared −10..115°F axis, so a city's whole-year rhythm (and hot-vs-cold character) reads at a glance. - Tint the month page's hero high/low and its record values inline. Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix) and cold (Anchorage) cities.
2026-07-16 03:50:59 +00:00
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
def _month_doy(month_idx: int) -> int:
return datetime.date(2001, month_idx, 15).timetuple().tm_yday
# --- helpers -----------------------------------------------------------------
def origin(request: Request) -> str:
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
host = request.headers.get("host") or request.url.netloc
return f"{proto}://{host}"
def _breadcrumb_jsonld(o: str, breadcrumb: list[tuple[str, str | None]]) -> dict:
"""BreadcrumbList structured data. Google requires ``item`` on every
ListItem except the last, so unlinked intermediate crumbs (e.g. the
country, which has no page of its own) are omitted here even though the
visible breadcrumb shows them."""
crumbs = [c for c in breadcrumb[:-1] if c[1]] + breadcrumb[-1:]
return {"@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": i + 1, "name": nm,
**({"item": f"{o}{href}"} if href else {})}
for i, (nm, href) in enumerate(crumbs)]}
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
def _respond_html(request: Request, template: str, **ctx) -> Response:
o = origin(request)
Default climate pages to the city's own temperature unit (#189) 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
2026-07-19 18:25:55 +00:00
# Taken from the same ContextVar the numbers were rendered through, so the
# attribute units.js reads can't drift from what the page actually says.
# None on any page without a city -> base.html.j2 omits it entirely.
ctx.setdefault("unit_default", _UNIT.get())
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
html = _env.get_template(template).render(base=BASE, origin=o, base_url=f"{o}{BASE}", **ctx)
etag = f'W/"{hashlib.sha1(html.encode()).hexdigest()[:20]}"'
inm = request.headers.get("if-none-match")
if inm and etag in {t.strip() for t in inm.split(",")}:
return Response(status_code=304, headers={"ETag": etag})
return Response(html, media_type="text/html", headers={"ETag": etag})
# --- robots.txt & sitemap.xml -------------------------------------------------
def robots_txt(request: Request) -> Response:
base_url = f"{origin(request)}{BASE}"
body = (
"User-agent: *\n"
"Allow: /\n"
f"Disallow: {BASE}/api/\n" # JSON endpoints, nothing to index
f"Disallow: {BASE}/alerts\n" # per-user, requires login
f"Sitemap: {base_url}/sitemap.xml\n"
)
return PlainTextResponse(body)
@functools.lru_cache(maxsize=1)
def _content_lastmod() -> str:
"""A stable 'content last built' date for <lastmod> — the newest mtime of the
city list and this module (both change on a content/code rebuild, and the
process restarts on deploy). Honest and stable, unlike a per-request today()
that churns every fetch and trains crawlers to ignore lastmod entirely."""
Split the backend into domain packages (#217) * Centralize filesystem paths in a single module Add paths.py, which resolves the repo root once and derives the cache, accounts DB, logs, templates, frontend and bundled-city-data locations from it. Replace the 13 per-module `dirname(__file__)/..` anchors with references to it, so a module's location no longer determines where the app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are unchanged; every resolved path is byte-identical to before. Groundwork for moving modules into packages without re-pointing paths. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62 * Split the backend into domain packages Group the flat backend modules into packages that mirror their concerns: data/ climate, grading, scoring, grid, places, cities, city_events, store web/ app, views, homepage, content, schemas notifications/ notify, digest, push, mailer, discord, discord_interactions, discord_link accounts/ models, users, api_accounts, db core/ metrics, singleton, audit Intra-project imports are rewritten to the package-qualified form. The entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor) and paths.py stay at the backend/ root, and backend/app.py becomes a shim re-exporting web.app:app so the launch target stays `app:app` — run.sh, the systemd units, and CI need no change. Verified: full suite (318) passes, `uvicorn app:app` boots and serves the home/SEO/static/API surfaces, and every root script imports clean. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
srcs = [paths.CITIES_JSON, __file__]
mtimes = [os.path.getmtime(p) for p in srcs if os.path.exists(p)]
day = datetime.date.fromtimestamp(max(mtimes)) if mtimes else datetime.date.today()
return day.isoformat()
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
def sitemap_xml(request: Request) -> Response:
base_url = f"{origin(request)}{BASE}"
lastmod = _content_lastmod()
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
parts = ['<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
for path, cf, pr in sitemap.sitemap_entries():
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
parts.append(
f"<url><loc>{base_url}{path}</loc><lastmod>{lastmod}</lastmod>"
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
f"<changefreq>{cf}</changefreq><priority>{pr}</priority></url>"
)
parts.append("</urlset>")
return Response("\n".join(parts), media_type="application/xml")
def head_verify_html() -> Markup:
"""Search-engine ownership-verification <meta> tags, from env (empty when
unset). Injected into every page's <head> — Google verifies the homepage."""
metas = []
google = os.environ.get("THERMOGRAPH_GOOGLE_VERIFY", "").strip()
bing = os.environ.get("THERMOGRAPH_BING_VERIFY", "").strip()
if google:
metas.append(Markup('<meta name="google-site-verification" content="{}">').format(google))
if bing:
metas.append(Markup('<meta name="msvalidate.01" content="{}">').format(bing))
return Markup("\n ").join(metas)
_env.globals["head_verify"] = head_verify_html
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
# --- per-city climate pages ---------------------------------------------------
def _history_for(cell: dict):
"""Cached archive for a cell, fetching once if missing (self-heals like the
notifier). Returns a polars frame or None."""
hist = climate.load_cached_history(cell)
if hist is not None and not hist.is_empty():
return hist
try:
hist, _ = climate.get_history(cell)
except Exception: # noqa: BLE001 - upstream unavailable: caller renders a 503
return None
return hist if (hist is not None and not hist.is_empty()) else None
def _monthly_normals(history) -> list[dict]:
"""One row per month: average high/low and average precip, from the ±7-day
climatology around each month's 15th."""
rows = []
for i, name in enumerate(MONTHS_TITLE, start=1):
clim = grading.climatology(history, _month_doy(i))
tmax, tmin, precip = clim.get("tmax"), clim.get("tmin"), clim.get("precip")
SEO: monthly & seasonal records + colour-coded climate pages (#107) * SEO: monthly & seasonal records on the city records page The /climate/<city>/records page showed only all-time records per metric. Expand it into a full records page: record high/low for each of the 12 months (each linking its month page) and for each meteorological season, alongside the existing all-time table. Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities and summer for southern ones (picked from the city's latitude). Records reuse grading.all_time_records over a month-filtered archive, so no new data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD block and richer title/description for the expanded coverage. * SEO: colour-code climate & records pages (heat-map + range strip) The city, records and month pages were plain muted tables — generic climate-site styling. Bring the interactive grader's diverging cold→hot palette onto them so they read as heat maps in the site's own visual language: - Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the temperature cells across the monthly-normals, monthly/seasonal/all-time records tables. The value stays in the ink token; the tint is a wash behind it. Non-temp rows (humidity/wind/precip) and date columns stay untinted. - Add a monthly temperature-range strip on the city page: one gradient bar per month spanning the average low→high on a shared −10..115°F axis, so a city's whole-year rhythm (and hot-vs-cold character) reads at a glance. - Tint the month page's hero high/low and its record values inline. Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix) and cold (Anchorage) cities.
2026-07-16 03:50:59 +00:00
high_f = tmax["mean"] if tmax else None
low_f = tmin["mean"] if tmin else None
# The range strip spans the typical spread: 10th-percentile daily low to
# 90th-percentile daily high (the band most days fall within).
rng_lo = tmin["p10"] if tmin else None
rng_hi = tmax["p90"] if tmax else None
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
rows.append({
"name": name,
"slug": MONTHS[i - 1],
"high": _temp(tmax["mean"]) if tmax else "",
SEO: monthly & seasonal records + colour-coded climate pages (#107) * SEO: monthly & seasonal records on the city records page The /climate/<city>/records page showed only all-time records per metric. Expand it into a full records page: record high/low for each of the 12 months (each linking its month page) and for each meteorological season, alongside the existing all-time table. Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities and summer for southern ones (picked from the city's latitude). Records reuse grading.all_time_records over a month-filtered archive, so no new data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD block and richer title/description for the expanded coverage. * SEO: colour-code climate & records pages (heat-map + range strip) The city, records and month pages were plain muted tables — generic climate-site styling. Bring the interactive grader's diverging cold→hot palette onto them so they read as heat maps in the site's own visual language: - Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the temperature cells across the monthly-normals, monthly/seasonal/all-time records tables. The value stays in the ink token; the tint is a wash behind it. Non-temp rows (humidity/wind/precip) and date columns stay untinted. - Add a monthly temperature-range strip on the city page: one gradient bar per month spanning the average low→high on a shared −10..115°F axis, so a city's whole-year rhythm (and hot-vs-cold character) reads at a glance. - Tint the month page's hero high/low and its record values inline. Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix) and cold (Anchorage) cities.
2026-07-16 03:50:59 +00:00
"high_f": high_f,
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
"low": _temp(tmin["mean"]) if tmin else "",
SEO: monthly & seasonal records + colour-coded climate pages (#107) * SEO: monthly & seasonal records on the city records page The /climate/<city>/records page showed only all-time records per metric. Expand it into a full records page: record high/low for each of the 12 months (each linking its month page) and for each meteorological season, alongside the existing all-time table. Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities and summer for southern ones (picked from the city's latitude). Records reuse grading.all_time_records over a month-filtered archive, so no new data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD block and richer title/description for the expanded coverage. * SEO: colour-code climate & records pages (heat-map + range strip) The city, records and month pages were plain muted tables — generic climate-site styling. Bring the interactive grader's diverging cold→hot palette onto them so they read as heat maps in the site's own visual language: - Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the temperature cells across the monthly-normals, monthly/seasonal/all-time records tables. The value stays in the ink token; the tint is a wash behind it. Non-temp rows (humidity/wind/precip) and date columns stay untinted. - Add a monthly temperature-range strip on the city page: one gradient bar per month spanning the average low→high on a shared −10..115°F axis, so a city's whole-year rhythm (and hot-vs-cold character) reads at a glance. - Tint the month page's hero high/low and its record values inline. Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix) and cold (Anchorage) cities.
2026-07-16 03:50:59 +00:00
"low_f": low_f,
"range_lo_f": rng_lo,
"range_hi_f": rng_hi,
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
"precip": _precip(precip["mean"]) if precip else "",
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
"precip_v": precip["mean"] if precip else None,
"bar": _range_bar(rng_lo, rng_hi),
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
})
return rows
def _extreme(metric_rec, key: str) -> dict | None:
"""One extreme of a metric — key 'max' (warmest) or 'min' (coldest) — as the
two-unit value, its heat-map tier, and the date it occurred."""
if not metric_rec:
return None
v = metric_rec[key]
return {"txt": _temp(v), "f": v, "cls": temp_class(v), "date": metric_rec[f"{key}_date"]}
SEO: monthly & seasonal records + colour-coded climate pages (#107) * SEO: monthly & seasonal records on the city records page The /climate/<city>/records page showed only all-time records per metric. Expand it into a full records page: record high/low for each of the 12 months (each linking its month page) and for each meteorological season, alongside the existing all-time table. Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities and summer for southern ones (picked from the city's latitude). Records reuse grading.all_time_records over a month-filtered archive, so no new data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD block and richer title/description for the expanded coverage. * SEO: colour-code climate & records pages (heat-map + range strip) The city, records and month pages were plain muted tables — generic climate-site styling. Bring the interactive grader's diverging cold→hot palette onto them so they read as heat maps in the site's own visual language: - Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the temperature cells across the monthly-normals, monthly/seasonal/all-time records tables. The value stays in the ink token; the tint is a wash behind it. Non-temp rows (humidity/wind/precip) and date columns stay untinted. - Add a monthly temperature-range strip on the city page: one gradient bar per month spanning the average low→high on a shared −10..115°F axis, so a city's whole-year rhythm (and hot-vs-cold character) reads at a glance. - Tint the month page's hero high/low and its record values inline. Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix) and cold (Anchorage) cities.
2026-07-16 03:50:59 +00:00
def _period_records(history, months: list[int]) -> dict:
"""For a set of calendar months, the record *warmest and coldest* of BOTH the
daytime high (tmax) and the overnight low (tmin) four extremes with dates.
So each metric shows both ends: the daytime high's hottest day and the coldest a
day ever stayed (its record-low high), and the overnight low's mildest night and
its record low. Reuses grading.all_time_records on the month-filtered archive."""
SEO: monthly & seasonal records + colour-coded climate pages (#107) * SEO: monthly & seasonal records on the city records page The /climate/<city>/records page showed only all-time records per metric. Expand it into a full records page: record high/low for each of the 12 months (each linking its month page) and for each meteorological season, alongside the existing all-time table. Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities and summer for southern ones (picked from the city's latitude). Records reuse grading.all_time_records over a month-filtered archive, so no new data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD block and richer title/description for the expanded coverage. * SEO: colour-code climate & records pages (heat-map + range strip) The city, records and month pages were plain muted tables — generic climate-site styling. Bring the interactive grader's diverging cold→hot palette onto them so they read as heat maps in the site's own visual language: - Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the temperature cells across the monthly-normals, monthly/seasonal/all-time records tables. The value stays in the ink token; the tint is a wash behind it. Non-temp rows (humidity/wind/precip) and date columns stay untinted. - Add a monthly temperature-range strip on the city page: one gradient bar per month spanning the average low→high on a shared −10..115°F axis, so a city's whole-year rhythm (and hot-vs-cold character) reads at a glance. - Tint the month page's hero high/low and its record values inline. Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix) and cold (Anchorage) cities.
2026-07-16 03:50:59 +00:00
if len(months) == 1:
sub = history.filter(pl.col("date").dt.month() == months[0])
else:
sub = history.filter(pl.col("date").dt.month().is_in(months))
rec = grading.all_time_records(sub) if not sub.is_empty() else {}
tmax, tmin = rec.get("tmax"), rec.get("tmin")
return {
"high": {"warm": _extreme(tmax, "max"), "cold": _extreme(tmax, "min")},
"low": {"warm": _extreme(tmin, "max"), "cold": _extreme(tmin, "min")},
SEO: monthly & seasonal records + colour-coded climate pages (#107) * SEO: monthly & seasonal records on the city records page The /climate/<city>/records page showed only all-time records per metric. Expand it into a full records page: record high/low for each of the 12 months (each linking its month page) and for each meteorological season, alongside the existing all-time table. Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities and summer for southern ones (picked from the city's latitude). Records reuse grading.all_time_records over a month-filtered archive, so no new data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD block and richer title/description for the expanded coverage. * SEO: colour-code climate & records pages (heat-map + range strip) The city, records and month pages were plain muted tables — generic climate-site styling. Bring the interactive grader's diverging cold→hot palette onto them so they read as heat maps in the site's own visual language: - Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the temperature cells across the monthly-normals, monthly/seasonal/all-time records tables. The value stays in the ink token; the tint is a wash behind it. Non-temp rows (humidity/wind/precip) and date columns stay untinted. - Add a monthly temperature-range strip on the city page: one gradient bar per month spanning the average low→high on a shared −10..115°F axis, so a city's whole-year rhythm (and hot-vs-cold character) reads at a glance. - Tint the month page's hero high/low and its record values inline. Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix) and cold (Anchorage) cities.
2026-07-16 03:50:59 +00:00
}
def _monthly_records(history) -> list[dict]:
"""Record high and low for each of the 12 months (each row links to its month page)."""
return [
{"name": name, "slug": MONTHS[i - 1], **_period_records(history, [i])}
for i, name in enumerate(MONTHS_TITLE, start=1)
]
def _seasonal_records(history, lat: float) -> list[dict]:
"""Record high and low for each meteorological season, labelled for the city's
hemisphere (DecFeb reads as winter north of the equator, summer south of it)."""
south = lat < 0
return [
{"name": (south_lbl if south else north_lbl), "span": span,
**_period_records(history, months)}
for north_lbl, south_lbl, months, span in SEASONS
]
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
def _today_vs_normal(history, cell) -> dict | None:
"""Grade the latest recorded day against its climatology, for the hero block."""
try:
recent = climate.get_recent_forecast(cell)
except Exception: # noqa: BLE001
return None
if recent is None or recent.is_empty() or "date" not in recent.columns:
return None
today = datetime.date.today()
observed = recent.filter(pl.col("date") <= today).sort("date")
if observed.is_empty():
return None
row = observed.row(observed.height - 1, named=True)
obs = {k: row[k] for k in OBS_COLS if k in row}
graded = grading.grade_day(history, row["date"], obs)
cards = []
for key, label in METRIC_LABELS:
g = graded.get(key)
if not g:
continue
cards.append({
"label": label, "metric": key,
"value": _fmt(key, g.get("value")),
"percentile": g.get("percentile"),
"grade": g.get("grade"), "cls": g.get("class"),
})
date = row["date"]
return {
"date": date.isoformat() if hasattr(date, "isoformat") else str(date),
"cards": cards,
}
def _city_context(request, city, cell, history) -> dict:
name = city["name"]
display = cities.display_name(city)
Lead climate page titles with the city and the payload (#188) Search Console's first 48 hours showed city-month pages drawing impressions at positions 44-77 and converting almost none of them: the title template spent its width on region and country ("Average weather in London, England, United Kingdom in July"), so Google cut it before the month a searcher was looking for. Titles now lead with the bare city name and put the subject next, which keeps both inside the first ~40 characters: London in July: normal weather & records London climate: daily normals, records & how unusual it is now London weather records: hottest & coldest days since 1980 Add cities.title_name() for the short form. 968 of the 1000 cities have a unique name and render bare; the 32 that don't are qualified with their country code ("London, GB"). Five names — Columbus, Arlington, Aurora, Glendale, Gorakhpur — recur *within* one country, where the country code collides too and would hand two different pages the same title, so those fall back to admin1 ("Columbus, Ohio"). A test asserts every rendered title is unique across the set. Meta descriptions now lead with the page's actual numbers rather than restating the template ("London averages highs of 73°F in July and lows of 38°F in January."), capped at the ~155 characters a SERP shows. The records title derives its year from the loaded history rather than a fixed date, so it can't contradict the page. H1s, URLs, breadcrumbs and JSON-LD are unchanged; og:title and og:description follow <title>/<meta> automatically through base.html.j2. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 18:17:13 +00:00
title = cities.title_name(city)
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
years = history["date"].dt.year()
year_range = [int(years.min()), int(years.max())]
months = _monthly_normals(history)
warmest = max((m for m in months if m["high_f"] is not None), key=lambda m: m["high_f"], default=None)
coldest = min((m for m in months if m["low_f"] is not None), key=lambda m: m["low_f"], default=None)
wettest = max((m for m in months if m["precip_v"] is not None), key=lambda m: m["precip_v"], default=None)
records = grading.all_time_records(history)
tool_hash = f"{city['lat']:.5f},{city['lon']:.5f}"
# Unique editorial blurb (Wikipedia, CC BY-SA) so the page isn't just templated
# stats, and a travel/comfort CTA that pre-fills this city on the compare page.
flavor = cities.flavor(city["slug"])
event = city_events.get(city["slug"]) # hand-curated; None → page falls back to the blurb
compare_url = f"{BASE}/compare#loc={city['lat']:.4f},{city['lon']:.4f}"
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
breadcrumb = [("Home", f"{BASE}/"), ("Climate", f"{BASE}/climate")]
if city.get("country"):
breadcrumb.append((city["country"], None))
breadcrumb.append((name, None))
o = origin(request)
page_url = f"{o}{BASE}/climate/{city['slug']}"
jsonld = {
"@context": "https://schema.org",
"@graph": [
{"@type": "Dataset",
"name": f"{display} climate normals and records",
"description": f"Average temperatures, precipitation and record highs and lows for "
f"{display}, from ~{year_range[1] - year_range[0]} years of daily climate history.",
"url": page_url,
"temporalCoverage": f"{year_range[0]}/{year_range[1]}",
"spatialCoverage": {"@type": "Place", "name": display,
"geo": {"@type": "GeoCoordinates",
"latitude": city["lat"], "longitude": city["lon"]}},
"creator": {"@type": "Organization", "name": "Thermograph"},
"isBasedOn": "https://open-meteo.com/ (ERA5 reanalysis)"},
_breadcrumb_jsonld(o, breadcrumb),
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
],
}
return {
"section": "climate",
"city": city, "display": display, "name": name,
"year_range": year_range, "n_years": year_range[1] - year_range[0],
"months": months, "warmest": warmest, "coldest": coldest, "wettest": wettest,
"records": records, "today": _today_vs_normal(history, cell),
"tool_hash": tool_hash, "flavor": flavor, "event": event, "compare_url": compare_url,
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
"breadcrumb": breadcrumb,
"canonical_path": f"/climate/{city['slug']}",
Lead climate page titles with the city and the payload (#188) Search Console's first 48 hours showed city-month pages drawing impressions at positions 44-77 and converting almost none of them: the title template spent its width on region and country ("Average weather in London, England, United Kingdom in July"), so Google cut it before the month a searcher was looking for. Titles now lead with the bare city name and put the subject next, which keeps both inside the first ~40 characters: London in July: normal weather & records London climate: daily normals, records & how unusual it is now London weather records: hottest & coldest days since 1980 Add cities.title_name() for the short form. 968 of the 1000 cities have a unique name and render bare; the 32 that don't are qualified with their country code ("London, GB"). Five names — Columbus, Arlington, Aurora, Glendale, Gorakhpur — recur *within* one country, where the country code collides too and would hand two different pages the same title, so those fall back to admin1 ("Columbus, Ohio"). A test asserts every rendered title is unique across the set. Meta descriptions now lead with the page's actual numbers rather than restating the template ("London averages highs of 73°F in July and lows of 38°F in January."), capped at the ~155 characters a SERP shows. The records title derives its year from the loaded history rather than a fixed date, so it can't contradict the page. H1s, URLs, breadcrumbs and JSON-LD are unchanged; og:title and og:description follow <title>/<meta> automatically through base.html.j2. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 18:17:13 +00:00
"page_title": _titled(f"{title} climate: daily normals, records & how unusual it is now"),
"page_description": _clamp_desc(
f"{title} averages highs of {_temp_text(warmest['high_f']) if warmest else ''} in "
f"{warmest['name'] if warmest else 'summer'} and lows of "
f"{_temp_text(coldest['low_f']) if coldest else ''} in "
f"{coldest['name'] if coldest else 'winter'}. "
f"Every day graded against {year_range[1] - year_range[0]} years of local history."),
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
"jsonld_str": json.dumps(jsonld, ensure_ascii=False, separators=(",", ":")),
}
def _resolve_city(slug: str):
"""(city, cell, history) for a slug, or raise 404 (unknown) / 503 (warming)."""
city = cities.get(slug)
if city is None:
raise HTTPException(status_code=404, detail="Unknown city.")
cell = grid.snap(city["lat"], city["lon"])
history = _history_for(cell)
if history is None:
raise HTTPException(status_code=503, detail="Climate data is warming up; please retry shortly.")
return city, cell, history
def city_page(request: Request, slug: str) -> Response:
city, cell, history = _resolve_city(slug)
Default climate pages to the city's own temperature unit (#189) 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
2026-07-19 18:25:55 +00:00
# The scope has to cover the context builder too, not just the render: it is
# evaluated as an argument, so it runs first — and it is where most of the
# page's temperatures are formatted.
with _unit_scope(unit_for_country(city.get("country_code"))):
return _respond_html(request, "city.html.j2",
**_city_context(request, city, cell, history))
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
# --- month & records pages ----------------------------------------------------
def _month_context(request, city, history, month_idx: int) -> dict:
display = cities.display_name(city)
Lead climate page titles with the city and the payload (#188) Search Console's first 48 hours showed city-month pages drawing impressions at positions 44-77 and converting almost none of them: the title template spent its width on region and country ("Average weather in London, England, United Kingdom in July"), so Google cut it before the month a searcher was looking for. Titles now lead with the bare city name and put the subject next, which keeps both inside the first ~40 characters: London in July: normal weather & records London climate: daily normals, records & how unusual it is now London weather records: hottest & coldest days since 1980 Add cities.title_name() for the short form. 968 of the 1000 cities have a unique name and render bare; the 32 that don't are qualified with their country code ("London, GB"). Five names — Columbus, Arlington, Aurora, Glendale, Gorakhpur — recur *within* one country, where the country code collides too and would hand two different pages the same title, so those fall back to admin1 ("Columbus, Ohio"). A test asserts every rendered title is unique across the set. Meta descriptions now lead with the page's actual numbers rather than restating the template ("London averages highs of 73°F in July and lows of 38°F in January."), capped at the ~155 characters a SERP shows. The records title derives its year from the loaded history rather than a fixed date, so it can't contradict the page. H1s, URLs, breadcrumbs and JSON-LD are unchanged; og:title and og:description follow <title>/<meta> automatically through base.html.j2. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 18:17:13 +00:00
title = cities.title_name(city)
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
name = city["name"]
month_name = MONTHS_TITLE[month_idx - 1]
month_slug = MONTHS[month_idx - 1]
years = history["date"].dt.year()
year_range = [int(years.min()), int(years.max())]
clim = grading.climatology(history, _month_doy(month_idx))
tmax, tmin, precip = clim.get("tmax"), clim.get("tmin"), clim.get("precip")
mdf = history.filter(pl.col("date").dt.month() == month_idx)
mrec = grading.all_time_records(mdf) if not mdf.is_empty() else {}
stats = []
if tmax:
stats.append(("Average high", _temp(tmax["mean"])))
stats.append(("Typical high range", _temp(tmax['p10']) + " to " + _temp(tmax['p90'])))
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
if tmin:
stats.append(("Average low", _temp(tmin["mean"])))
stats.append(("Typical low range", _temp(tmin['p10']) + " to " + _temp(tmin['p90'])))
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
if precip:
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
stats.append(("Average daily precipitation", _precip(precip["mean"])))
# Record high and low for every metric in this calendar month (mirrors the
# all-time records cards, but scoped to the month). Precip is special-cased:
# a per-day "record low" is just zero, and the dry-streak helper would wrongly
# bridge year boundaries on month-filtered rows, so the wettest/driest sides
# show this month's largest and smallest total accumulation, dated to the year.
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
records = []
for key, label in METRIC_LABELS:
r = mrec.get(key)
if not r:
continue
if key == "precip":
# Only whole months count — a partial current month would otherwise win
# "driest" on a fraction of its rainfall.
totals = (mdf.filter(pl.col("precip").is_not_null())
.group_by(pl.col("date").dt.year().alias("yr"))
.agg(pl.col("precip").sum().alias("total"),
pl.len().alias("days"))
.filter(pl.col("days") >= 26)
.sort("total"))
if totals.is_empty():
continue
lo, hi = totals.row(0, named=True), totals.row(totals.height - 1, named=True)
records.append({
"label": label,
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
"high": _precip(hi["total"]), "high_date": str(hi["yr"]), "high_tag": "Wettest",
"low": _precip(lo["total"]), "low_date": str(lo["yr"]), "low_tag": "Driest",
})
else:
records.append({
"label": label,
"high": _fmt(key, r["max"]), "high_date": r["max_date"], "high_tag": "Highest",
"low": _fmt(key, r["min"]), "low_date": r["min_date"], "low_tag": "Lowest",
})
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
prev_i = 12 if month_idx == 1 else month_idx - 1
next_i = 1 if month_idx == 12 else month_idx + 1
breadcrumb = [("Home", f"{BASE}/"), ("Climate", f"{BASE}/climate"),
(name, f"{BASE}/climate/{city['slug']}"), (month_name, None)]
return {
"section": "climate", "city": city, "display": display, "name": name,
"month_name": month_name, "month_slug": month_slug,
"year_range": year_range, "n_years": year_range[1] - year_range[0],
"avg_high": _temp(tmax["mean"]) if tmax else "",
"avg_low": _temp(tmin["mean"]) if tmin else "",
SEO: monthly & seasonal records + colour-coded climate pages (#107) * SEO: monthly & seasonal records on the city records page The /climate/<city>/records page showed only all-time records per metric. Expand it into a full records page: record high/low for each of the 12 months (each linking its month page) and for each meteorological season, alongside the existing all-time table. Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities and summer for southern ones (picked from the city's latitude). Records reuse grading.all_time_records over a month-filtered archive, so no new data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD block and richer title/description for the expanded coverage. * SEO: colour-code climate & records pages (heat-map + range strip) The city, records and month pages were plain muted tables — generic climate-site styling. Bring the interactive grader's diverging cold→hot palette onto them so they read as heat maps in the site's own visual language: - Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the temperature cells across the monthly-normals, monthly/seasonal/all-time records tables. The value stays in the ink token; the tint is a wash behind it. Non-temp rows (humidity/wind/precip) and date columns stay untinted. - Add a monthly temperature-range strip on the city page: one gradient bar per month spanning the average low→high on a shared −10..115°F axis, so a city's whole-year rhythm (and hot-vs-cold character) reads at a glance. - Tint the month page's hero high/low and its record values inline. Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix) and cold (Anchorage) cities.
2026-07-16 03:50:59 +00:00
"avg_high_cls": temp_class(tmax["mean"]) if tmax else "none",
"avg_low_cls": temp_class(tmin["mean"]) if tmin else "none",
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
"stats": stats, "records": records,
"tool_hash": f"{city['lat']:.5f},{city['lon']:.5f}",
"compare_url": f"{BASE}/compare#loc={city['lat']:.4f},{city['lon']:.4f}",
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
"prev": {"name": MONTHS_TITLE[prev_i - 1], "slug": MONTHS[prev_i - 1]},
"next": {"name": MONTHS_TITLE[next_i - 1], "slug": MONTHS[next_i - 1]},
"breadcrumb": breadcrumb,
"canonical_path": f"/climate/{city['slug']}/{month_slug}",
Lead climate page titles with the city and the payload (#188) Search Console's first 48 hours showed city-month pages drawing impressions at positions 44-77 and converting almost none of them: the title template spent its width on region and country ("Average weather in London, England, United Kingdom in July"), so Google cut it before the month a searcher was looking for. Titles now lead with the bare city name and put the subject next, which keeps both inside the first ~40 characters: London in July: normal weather & records London climate: daily normals, records & how unusual it is now London weather records: hottest & coldest days since 1980 Add cities.title_name() for the short form. 968 of the 1000 cities have a unique name and render bare; the 32 that don't are qualified with their country code ("London, GB"). Five names — Columbus, Arlington, Aurora, Glendale, Gorakhpur — recur *within* one country, where the country code collides too and would hand two different pages the same title, so those fall back to admin1 ("Columbus, Ohio"). A test asserts every rendered title is unique across the set. Meta descriptions now lead with the page's actual numbers rather than restating the template ("London averages highs of 73°F in July and lows of 38°F in January."), capped at the ~155 characters a SERP shows. The records title derives its year from the loaded history rather than a fixed date, so it can't contradict the page. H1s, URLs, breadcrumbs and JSON-LD are unchanged; og:title and og:description follow <title>/<meta> automatically through base.html.j2. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 18:17:13 +00:00
"page_title": _titled(f"{title} in {month_name}: normal weather & records"),
"page_description": _clamp_desc(
f"{title} averages {_temp_text(tmax['mean']) if tmax else ''} highs and "
f"{_temp_text(tmin['mean']) if tmin else ''} lows in {month_name}. "
f"Every day graded against {year_range[1] - year_range[0]} years of local history."),
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
}
def month_page(request: Request, slug: str, month: str) -> Response:
if month not in MONTH_INDEX:
raise HTTPException(status_code=404, detail="Unknown month.")
city, cell, history = _resolve_city(slug)
Default climate pages to the city's own temperature unit (#189) 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
2026-07-19 18:25:55 +00:00
with _unit_scope(unit_for_country(city.get("country_code"))):
return _respond_html(request, "month.html.j2",
**_month_context(request, city, history, MONTH_INDEX[month]))
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
def _records_context(request, city, history) -> dict:
display = cities.display_name(city)
Lead climate page titles with the city and the payload (#188) Search Console's first 48 hours showed city-month pages drawing impressions at positions 44-77 and converting almost none of them: the title template spent its width on region and country ("Average weather in London, England, United Kingdom in July"), so Google cut it before the month a searcher was looking for. Titles now lead with the bare city name and put the subject next, which keeps both inside the first ~40 characters: London in July: normal weather & records London climate: daily normals, records & how unusual it is now London weather records: hottest & coldest days since 1980 Add cities.title_name() for the short form. 968 of the 1000 cities have a unique name and render bare; the 32 that don't are qualified with their country code ("London, GB"). Five names — Columbus, Arlington, Aurora, Glendale, Gorakhpur — recur *within* one country, where the country code collides too and would hand two different pages the same title, so those fall back to admin1 ("Columbus, Ohio"). A test asserts every rendered title is unique across the set. Meta descriptions now lead with the page's actual numbers rather than restating the template ("London averages highs of 73°F in July and lows of 38°F in January."), capped at the ~155 characters a SERP shows. The records title derives its year from the loaded history rather than a fixed date, so it can't contradict the page. H1s, URLs, breadcrumbs and JSON-LD are unchanged; og:title and og:description follow <title>/<meta> automatically through base.html.j2. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 18:17:13 +00:00
title = cities.title_name(city)
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
name = city["name"]
years = history["date"].dt.year()
year_range = [int(years.min()), int(years.max())]
SEO: monthly & seasonal records + colour-coded climate pages (#107) * SEO: monthly & seasonal records on the city records page The /climate/<city>/records page showed only all-time records per metric. Expand it into a full records page: record high/low for each of the 12 months (each linking its month page) and for each meteorological season, alongside the existing all-time table. Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities and summer for southern ones (picked from the city's latitude). Records reuse grading.all_time_records over a month-filtered archive, so no new data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD block and richer title/description for the expanded coverage. * SEO: colour-code climate & records pages (heat-map + range strip) The city, records and month pages were plain muted tables — generic climate-site styling. Bring the interactive grader's diverging cold→hot palette onto them so they read as heat maps in the site's own visual language: - Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the temperature cells across the monthly-normals, monthly/seasonal/all-time records tables. The value stays in the ink token; the tint is a wash behind it. Non-temp rows (humidity/wind/precip) and date columns stay untinted. - Add a monthly temperature-range strip on the city page: one gradient bar per month spanning the average low→high on a shared −10..115°F axis, so a city's whole-year rhythm (and hot-vs-cold character) reads at a glance. - Tint the month page's hero high/low and its record values inline. Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix) and cold (Anchorage) cities.
2026-07-16 03:50:59 +00:00
n_years = year_range[1] - year_range[0]
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
rec = grading.all_time_records(history)
Lead climate page titles with the city and the payload (#188) Search Console's first 48 hours showed city-month pages drawing impressions at positions 44-77 and converting almost none of them: the title template spent its width on region and country ("Average weather in London, England, United Kingdom in July"), so Google cut it before the month a searcher was looking for. Titles now lead with the bare city name and put the subject next, which keeps both inside the first ~40 characters: London in July: normal weather & records London climate: daily normals, records & how unusual it is now London weather records: hottest & coldest days since 1980 Add cities.title_name() for the short form. 968 of the 1000 cities have a unique name and render bare; the 32 that don't are qualified with their country code ("London, GB"). Five names — Columbus, Arlington, Aurora, Glendale, Gorakhpur — recur *within* one country, where the country code collides too and would hand two different pages the same title, so those fall back to admin1 ("Columbus, Ohio"). A test asserts every rendered title is unique across the set. Meta descriptions now lead with the page's actual numbers rather than restating the template ("London averages highs of 73°F in July and lows of 38°F in January."), capped at the ~155 characters a SERP shows. The records title derives its year from the loaded history rather than a fixed date, so it can't contradict the page. H1s, URLs, breadcrumbs and JSON-LD are unchanged; og:title and og:description follow <title>/<meta> automatically through base.html.j2. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 18:17:13 +00:00
# The two numbers the meta description leads with — the same all-time extremes
# the page's own lede sentence quotes.
hi_rec = (rec.get("tmax") or {}).get("max")
hi_date = (rec.get("tmax") or {}).get("max_date")
lo_rec = (rec.get("tmin") or {}).get("min")
lo_date = (rec.get("tmin") or {}).get("min_date")
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
rows = []
for key, label in METRIC_LABELS:
r = rec.get(key)
if not r:
continue
if key == "precip":
# "Record low" rain is meaningless (it's just 0), so the low side shows
# the longest dry streak and the date it began instead.
days, dry_start = grading.longest_dry_streak(history)
low = f"{days}-day dry spell" if days else ""
low_date = dry_start or ""
else:
low, low_date = _fmt(key, r["min"]), r["min_date"]
SEO: monthly & seasonal records + colour-coded climate pages (#107) * SEO: monthly & seasonal records on the city records page The /climate/<city>/records page showed only all-time records per metric. Expand it into a full records page: record high/low for each of the 12 months (each linking its month page) and for each meteorological season, alongside the existing all-time table. Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities and summer for southern ones (picked from the city's latitude). Records reuse grading.all_time_records over a month-filtered archive, so no new data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD block and richer title/description for the expanded coverage. * SEO: colour-code climate & records pages (heat-map + range strip) The city, records and month pages were plain muted tables — generic climate-site styling. Bring the interactive grader's diverging cold→hot palette onto them so they read as heat maps in the site's own visual language: - Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the temperature cells across the monthly-normals, monthly/seasonal/all-time records tables. The value stays in the ink token; the tint is a wash behind it. Non-temp rows (humidity/wind/precip) and date columns stay untinted. - Add a monthly temperature-range strip on the city page: one gradient bar per month spanning the average low→high on a shared −10..115°F axis, so a city's whole-year rhythm (and hot-vs-cold character) reads at a glance. - Tint the month page's hero high/low and its record values inline. Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix) and cold (Anchorage) cities.
2026-07-16 03:50:59 +00:00
is_temp = key in _TEMP_METRICS
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
rows.append({
"label": label,
"high": _fmt(key, r["max"]), "high_date": r["max_date"],
SEO: monthly & seasonal records + colour-coded climate pages (#107) * SEO: monthly & seasonal records on the city records page The /climate/<city>/records page showed only all-time records per metric. Expand it into a full records page: record high/low for each of the 12 months (each linking its month page) and for each meteorological season, alongside the existing all-time table. Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities and summer for southern ones (picked from the city's latitude). Records reuse grading.all_time_records over a month-filtered archive, so no new data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD block and richer title/description for the expanded coverage. * SEO: colour-code climate & records pages (heat-map + range strip) The city, records and month pages were plain muted tables — generic climate-site styling. Bring the interactive grader's diverging cold→hot palette onto them so they read as heat maps in the site's own visual language: - Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the temperature cells across the monthly-normals, monthly/seasonal/all-time records tables. The value stays in the ink token; the tint is a wash behind it. Non-temp rows (humidity/wind/precip) and date columns stay untinted. - Add a monthly temperature-range strip on the city page: one gradient bar per month spanning the average low→high on a shared −10..115°F axis, so a city's whole-year rhythm (and hot-vs-cold character) reads at a glance. - Tint the month page's hero high/low and its record values inline. Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix) and cold (Anchorage) cities.
2026-07-16 03:50:59 +00:00
"high_f": r["max"] if is_temp else None,
"low": low, "low_date": low_date,
SEO: monthly & seasonal records + colour-coded climate pages (#107) * SEO: monthly & seasonal records on the city records page The /climate/<city>/records page showed only all-time records per metric. Expand it into a full records page: record high/low for each of the 12 months (each linking its month page) and for each meteorological season, alongside the existing all-time table. Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities and summer for southern ones (picked from the city's latitude). Records reuse grading.all_time_records over a month-filtered archive, so no new data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD block and richer title/description for the expanded coverage. * SEO: colour-code climate & records pages (heat-map + range strip) The city, records and month pages were plain muted tables — generic climate-site styling. Bring the interactive grader's diverging cold→hot palette onto them so they read as heat maps in the site's own visual language: - Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the temperature cells across the monthly-normals, monthly/seasonal/all-time records tables. The value stays in the ink token; the tint is a wash behind it. Non-temp rows (humidity/wind/precip) and date columns stay untinted. - Add a monthly temperature-range strip on the city page: one gradient bar per month spanning the average low→high on a shared −10..115°F axis, so a city's whole-year rhythm (and hot-vs-cold character) reads at a glance. - Tint the month page's hero high/low and its record values inline. Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix) and cold (Anchorage) cities.
2026-07-16 03:50:59 +00:00
"low_f": r["min"] if is_temp else None,
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
})
SEO: monthly & seasonal records + colour-coded climate pages (#107) * SEO: monthly & seasonal records on the city records page The /climate/<city>/records page showed only all-time records per metric. Expand it into a full records page: record high/low for each of the 12 months (each linking its month page) and for each meteorological season, alongside the existing all-time table. Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities and summer for southern ones (picked from the city's latitude). Records reuse grading.all_time_records over a month-filtered archive, so no new data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD block and richer title/description for the expanded coverage. * SEO: colour-code climate & records pages (heat-map + range strip) The city, records and month pages were plain muted tables — generic climate-site styling. Bring the interactive grader's diverging cold→hot palette onto them so they read as heat maps in the site's own visual language: - Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the temperature cells across the monthly-normals, monthly/seasonal/all-time records tables. The value stays in the ink token; the tint is a wash behind it. Non-temp rows (humidity/wind/precip) and date columns stay untinted. - Add a monthly temperature-range strip on the city page: one gradient bar per month spanning the average low→high on a shared −10..115°F axis, so a city's whole-year rhythm (and hot-vs-cold character) reads at a glance. - Tint the month page's hero high/low and its record values inline. Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix) and cold (Anchorage) cities.
2026-07-16 03:50:59 +00:00
monthly = _monthly_records(history)
seasonal = _seasonal_records(history, city["lat"])
hemisphere = "Southern" if city["lat"] < 0 else "Northern"
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
breadcrumb = [("Home", f"{BASE}/"), ("Climate", f"{BASE}/climate"),
(name, f"{BASE}/climate/{city['slug']}"), ("Records", None)]
SEO: monthly & seasonal records + colour-coded climate pages (#107) * SEO: monthly & seasonal records on the city records page The /climate/<city>/records page showed only all-time records per metric. Expand it into a full records page: record high/low for each of the 12 months (each linking its month page) and for each meteorological season, alongside the existing all-time table. Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities and summer for southern ones (picked from the city's latitude). Records reuse grading.all_time_records over a month-filtered archive, so no new data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD block and richer title/description for the expanded coverage. * SEO: colour-code climate & records pages (heat-map + range strip) The city, records and month pages were plain muted tables — generic climate-site styling. Bring the interactive grader's diverging cold→hot palette onto them so they read as heat maps in the site's own visual language: - Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the temperature cells across the monthly-normals, monthly/seasonal/all-time records tables. The value stays in the ink token; the tint is a wash behind it. Non-temp rows (humidity/wind/precip) and date columns stay untinted. - Add a monthly temperature-range strip on the city page: one gradient bar per month spanning the average low→high on a shared −10..115°F axis, so a city's whole-year rhythm (and hot-vs-cold character) reads at a glance. - Tint the month page's hero high/low and its record values inline. Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix) and cold (Anchorage) cities.
2026-07-16 03:50:59 +00:00
o = origin(request)
page_url = f"{o}{BASE}/climate/{city['slug']}/records"
jsonld = {
"@context": "https://schema.org",
"@graph": [
{"@type": "Dataset",
"name": f"{display} monthly and seasonal weather records",
"description": f"Record high and low temperatures for {display} by month and by "
f"meteorological season, with the dates they occurred, from ~{n_years} "
f"years of daily climate history.",
"url": page_url,
"temporalCoverage": f"{year_range[0]}/{year_range[1]}",
"spatialCoverage": {"@type": "Place", "name": display,
"geo": {"@type": "GeoCoordinates",
"latitude": city["lat"], "longitude": city["lon"]}},
"creator": {"@type": "Organization", "name": "Thermograph"},
"isBasedOn": "https://open-meteo.com/ (ERA5 reanalysis)"},
_breadcrumb_jsonld(o, breadcrumb),
SEO: monthly & seasonal records + colour-coded climate pages (#107) * SEO: monthly & seasonal records on the city records page The /climate/<city>/records page showed only all-time records per metric. Expand it into a full records page: record high/low for each of the 12 months (each linking its month page) and for each meteorological season, alongside the existing all-time table. Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities and summer for southern ones (picked from the city's latitude). Records reuse grading.all_time_records over a month-filtered archive, so no new data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD block and richer title/description for the expanded coverage. * SEO: colour-code climate & records pages (heat-map + range strip) The city, records and month pages were plain muted tables — generic climate-site styling. Bring the interactive grader's diverging cold→hot palette onto them so they read as heat maps in the site's own visual language: - Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the temperature cells across the monthly-normals, monthly/seasonal/all-time records tables. The value stays in the ink token; the tint is a wash behind it. Non-temp rows (humidity/wind/precip) and date columns stay untinted. - Add a monthly temperature-range strip on the city page: one gradient bar per month spanning the average low→high on a shared −10..115°F axis, so a city's whole-year rhythm (and hot-vs-cold character) reads at a glance. - Tint the month page's hero high/low and its record values inline. Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix) and cold (Anchorage) cities.
2026-07-16 03:50:59 +00:00
],
}
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
return {
"section": "climate", "city": city, "display": display, "name": name,
SEO: monthly & seasonal records + colour-coded climate pages (#107) * SEO: monthly & seasonal records on the city records page The /climate/<city>/records page showed only all-time records per metric. Expand it into a full records page: record high/low for each of the 12 months (each linking its month page) and for each meteorological season, alongside the existing all-time table. Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities and summer for southern ones (picked from the city's latitude). Records reuse grading.all_time_records over a month-filtered archive, so no new data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD block and richer title/description for the expanded coverage. * SEO: colour-code climate & records pages (heat-map + range strip) The city, records and month pages were plain muted tables — generic climate-site styling. Bring the interactive grader's diverging cold→hot palette onto them so they read as heat maps in the site's own visual language: - Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the temperature cells across the monthly-normals, monthly/seasonal/all-time records tables. The value stays in the ink token; the tint is a wash behind it. Non-temp rows (humidity/wind/precip) and date columns stay untinted. - Add a monthly temperature-range strip on the city page: one gradient bar per month spanning the average low→high on a shared −10..115°F axis, so a city's whole-year rhythm (and hot-vs-cold character) reads at a glance. - Tint the month page's hero high/low and its record values inline. Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix) and cold (Anchorage) cities.
2026-07-16 03:50:59 +00:00
"year_range": year_range, "n_years": n_years,
"rows": rows, "monthly": monthly, "seasonal": seasonal,
"hemisphere": hemisphere, "all_time": rec,
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
"canonical_path": f"/climate/{city['slug']}/records",
"breadcrumb": breadcrumb,
Lead climate page titles with the city and the payload (#188) Search Console's first 48 hours showed city-month pages drawing impressions at positions 44-77 and converting almost none of them: the title template spent its width on region and country ("Average weather in London, England, United Kingdom in July"), so Google cut it before the month a searcher was looking for. Titles now lead with the bare city name and put the subject next, which keeps both inside the first ~40 characters: London in July: normal weather & records London climate: daily normals, records & how unusual it is now London weather records: hottest & coldest days since 1980 Add cities.title_name() for the short form. 968 of the 1000 cities have a unique name and render bare; the 32 that don't are qualified with their country code ("London, GB"). Five names — Columbus, Arlington, Aurora, Glendale, Gorakhpur — recur *within* one country, where the country code collides too and would hand two different pages the same title, so those fall back to admin1 ("Columbus, Ohio"). A test asserts every rendered title is unique across the set. Meta descriptions now lead with the page's actual numbers rather than restating the template ("London averages highs of 73°F in July and lows of 38°F in January."), capped at the ~155 characters a SERP shows. The records title derives its year from the loaded history rather than a fixed date, so it can't contradict the page. H1s, URLs, breadcrumbs and JSON-LD are unchanged; og:title and og:description follow <title>/<meta> automatically through base.html.j2. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 18:17:13 +00:00
"page_title": _titled(
f"{title} weather records: hottest & coldest days since {year_range[0]}"),
"page_description": _clamp_desc(
f"{title}'s hottest day hit {_temp_text(hi_rec)}"
f"{f' ({_month_year(hi_date)})' if hi_date else ''}; its coldest fell to "
f"{_temp_text(lo_rec)}{f' ({_month_year(lo_date)})' if lo_date else ''}. "
f"Every day graded against {n_years} years of local history."),
SEO: monthly & seasonal records + colour-coded climate pages (#107) * SEO: monthly & seasonal records on the city records page The /climate/<city>/records page showed only all-time records per metric. Expand it into a full records page: record high/low for each of the 12 months (each linking its month page) and for each meteorological season, alongside the existing all-time table. Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities and summer for southern ones (picked from the city's latitude). Records reuse grading.all_time_records over a month-filtered archive, so no new data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD block and richer title/description for the expanded coverage. * SEO: colour-code climate & records pages (heat-map + range strip) The city, records and month pages were plain muted tables — generic climate-site styling. Bring the interactive grader's diverging cold→hot palette onto them so they read as heat maps in the site's own visual language: - Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the temperature cells across the monthly-normals, monthly/seasonal/all-time records tables. The value stays in the ink token; the tint is a wash behind it. Non-temp rows (humidity/wind/precip) and date columns stay untinted. - Add a monthly temperature-range strip on the city page: one gradient bar per month spanning the average low→high on a shared −10..115°F axis, so a city's whole-year rhythm (and hot-vs-cold character) reads at a glance. - Tint the month page's hero high/low and its record values inline. Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix) and cold (Anchorage) cities.
2026-07-16 03:50:59 +00:00
"jsonld_str": json.dumps(jsonld, ensure_ascii=False, separators=(",", ":")),
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
}
def records_page(request: Request, slug: str) -> Response:
city, cell, history = _resolve_city(slug)
Default climate pages to the city's own temperature unit (#189) 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
2026-07-19 18:25:55 +00:00
with _unit_scope(unit_for_country(city.get("country_code"))):
return _respond_html(request, "records.html.j2",
**_records_context(request, city, history))
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
# --- hub / glossary / about ---------------------------------------------------
def _breadcrumb(*items):
return list(items)
def hub_page(request: Request) -> Response:
groups = cities.by_country()
ctx = {
"section": "climate",
"groups": groups,
"n_cities": sum(len(v) for v in groups.values()),
"n_countries": len(groups),
"canonical_path": "/climate",
"breadcrumb": [("Home", f"{BASE}/"), ("Climate", None)],
Extract the glossary and static-page SEO copy into a schema-validated content/ tree (#241) The weather-terms glossary (9 entries) and four static pages' SEO title/ description were hardcoded directly in web/content.py - a 1019-line module that also owns all SSR rendering logic - mixed in with code that changes on a completely different cadence and for different reasons. Add content/glossary.yaml and content/pages.yaml (a new repo-root content/ tree, a sibling of backend/ and frontend/ paths.py resolves the same way - the seed of a future thermograph-copy repo per the architecture decision doc's own §4) plus web/content_loader.py: a small loader that validates each file's shape at load time (required fields present and non-empty, no duplicate glossary slugs) and fails loudly on a malformed edit rather than rendering a blank glossary card or an empty <title>. content.py's GLOSSARY dict and the about/privacy/hub/glossary_index page_title/description literals now come from the loader. Scope: only content that is genuinely pure static data with no embedded template logic. cities_flavor.json (Wikipedia extracts, already its own generated file) and the homepage's title/description (embedded in home.html.j2 as Jinja block overrides, a heavily-tested product-critical template) are deliberately left as they are - a future pass, not required for this one. UI microcopy bound to frontend logic stays in frontend/, per the doc's own line between content and frontend. Verified: content/glossary.yaml generated programmatically from the live GLOSSARY dict (not hand-transcribed) and round-tripped byte-for-byte identical against it; content/pages.yaml's four entries checked field-by- field against the original hardcoded strings. Full backend suite green (362 passed, 4 skipped) with zero existing test changes needed beyond one assertion made escaping-aware (a pre-existing Jinja double-escape quirk on the one title containing "&amp;", intentionally preserved not fixed). Built and booted the real Docker image: content/ present at /app/content, and curled /glossary, /glossary/percentine, /about, /privacy from inside the running container - all four render with the exact expected title text.
2026-07-21 01:21:11 +00:00
"page_title": PAGES["hub"]["title"],
"page_description": PAGES["hub"]["description"],
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
}
return _respond_html(request, "hub.html.j2", **ctx)
Extract the glossary and static-page SEO copy into a schema-validated content/ tree (#241) The weather-terms glossary (9 entries) and four static pages' SEO title/ description were hardcoded directly in web/content.py - a 1019-line module that also owns all SSR rendering logic - mixed in with code that changes on a completely different cadence and for different reasons. Add content/glossary.yaml and content/pages.yaml (a new repo-root content/ tree, a sibling of backend/ and frontend/ paths.py resolves the same way - the seed of a future thermograph-copy repo per the architecture decision doc's own §4) plus web/content_loader.py: a small loader that validates each file's shape at load time (required fields present and non-empty, no duplicate glossary slugs) and fails loudly on a malformed edit rather than rendering a blank glossary card or an empty <title>. content.py's GLOSSARY dict and the about/privacy/hub/glossary_index page_title/description literals now come from the loader. Scope: only content that is genuinely pure static data with no embedded template logic. cities_flavor.json (Wikipedia extracts, already its own generated file) and the homepage's title/description (embedded in home.html.j2 as Jinja block overrides, a heavily-tested product-critical template) are deliberately left as they are - a future pass, not required for this one. UI microcopy bound to frontend logic stays in frontend/, per the doc's own line between content and frontend. Verified: content/glossary.yaml generated programmatically from the live GLOSSARY dict (not hand-transcribed) and round-tripped byte-for-byte identical against it; content/pages.yaml's four entries checked field-by- field against the original hardcoded strings. Full backend suite green (362 passed, 4 skipped) with zero existing test changes needed beyond one assertion made escaping-aware (a pre-existing Jinja double-escape quirk on the one title containing "&amp;", intentionally preserved not fixed). Built and booted the real Docker image: content/ present at /app/content, and curled /glossary, /glossary/percentine, /about, /privacy from inside the running container - all four render with the exact expected title text.
2026-07-21 01:21:11 +00:00
# Weather-terms glossary, loaded from content/glossary.yaml (see content_loader.py).
GLOSSARY: dict[str, dict] = content_loader.load_glossary()
# Static-page SEO title/description, loaded from content/pages.yaml. The
# per-city/per-month pages build theirs dynamically from city data instead —
# see their own page_title/page_description assignments above.
PAGES: dict[str, dict] = content_loader.load_pages()
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
def _glossary_body(entry: dict) -> str:
return entry["body"].replace("{base}", BASE)
def glossary_index(request: Request) -> Response:
ctx = {
"terms": [{"slug": s, **e} for s, e in GLOSSARY.items()],
"canonical_path": "/glossary",
"breadcrumb": [("Home", f"{BASE}/"), ("Glossary", None)],
Extract the glossary and static-page SEO copy into a schema-validated content/ tree (#241) The weather-terms glossary (9 entries) and four static pages' SEO title/ description were hardcoded directly in web/content.py - a 1019-line module that also owns all SSR rendering logic - mixed in with code that changes on a completely different cadence and for different reasons. Add content/glossary.yaml and content/pages.yaml (a new repo-root content/ tree, a sibling of backend/ and frontend/ paths.py resolves the same way - the seed of a future thermograph-copy repo per the architecture decision doc's own §4) plus web/content_loader.py: a small loader that validates each file's shape at load time (required fields present and non-empty, no duplicate glossary slugs) and fails loudly on a malformed edit rather than rendering a blank glossary card or an empty <title>. content.py's GLOSSARY dict and the about/privacy/hub/glossary_index page_title/description literals now come from the loader. Scope: only content that is genuinely pure static data with no embedded template logic. cities_flavor.json (Wikipedia extracts, already its own generated file) and the homepage's title/description (embedded in home.html.j2 as Jinja block overrides, a heavily-tested product-critical template) are deliberately left as they are - a future pass, not required for this one. UI microcopy bound to frontend logic stays in frontend/, per the doc's own line between content and frontend. Verified: content/glossary.yaml generated programmatically from the live GLOSSARY dict (not hand-transcribed) and round-tripped byte-for-byte identical against it; content/pages.yaml's four entries checked field-by- field against the original hardcoded strings. Full backend suite green (362 passed, 4 skipped) with zero existing test changes needed beyond one assertion made escaping-aware (a pre-existing Jinja double-escape quirk on the one title containing "&amp;", intentionally preserved not fixed). Built and booted the real Docker image: content/ present at /app/content, and curled /glossary, /glossary/percentine, /about, /privacy from inside the running container - all four render with the exact expected title text.
2026-07-21 01:21:11 +00:00
"page_title": PAGES["glossary_index"]["title"],
"page_description": PAGES["glossary_index"]["description"],
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
}
return _respond_html(request, "glossary.html.j2", **ctx)
def glossary_term(request: Request, term: str) -> Response:
entry = GLOSSARY.get(term)
if entry is None:
raise HTTPException(status_code=404, detail="Unknown term.")
ctx = {
"term": entry["term"], "body": _glossary_body(entry),
"canonical_path": f"/glossary/{term}",
"breadcrumb": [("Home", f"{BASE}/"), ("Glossary", f"{BASE}/glossary"), (entry["term"], None)],
"page_title": f"{entry['term']}: what it means | Thermograph",
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
"page_description": entry["short"],
"others": [{"slug": s, "term": e["term"]} for s, e in GLOSSARY.items() if s != term],
}
return _respond_html(request, "glossary_term.html.j2", **ctx)
def about_page(request: Request) -> Response:
ctx = {
"canonical_path": "/about",
"breadcrumb": [("Home", f"{BASE}/"), ("About", None)],
Extract the glossary and static-page SEO copy into a schema-validated content/ tree (#241) The weather-terms glossary (9 entries) and four static pages' SEO title/ description were hardcoded directly in web/content.py - a 1019-line module that also owns all SSR rendering logic - mixed in with code that changes on a completely different cadence and for different reasons. Add content/glossary.yaml and content/pages.yaml (a new repo-root content/ tree, a sibling of backend/ and frontend/ paths.py resolves the same way - the seed of a future thermograph-copy repo per the architecture decision doc's own §4) plus web/content_loader.py: a small loader that validates each file's shape at load time (required fields present and non-empty, no duplicate glossary slugs) and fails loudly on a malformed edit rather than rendering a blank glossary card or an empty <title>. content.py's GLOSSARY dict and the about/privacy/hub/glossary_index page_title/description literals now come from the loader. Scope: only content that is genuinely pure static data with no embedded template logic. cities_flavor.json (Wikipedia extracts, already its own generated file) and the homepage's title/description (embedded in home.html.j2 as Jinja block overrides, a heavily-tested product-critical template) are deliberately left as they are - a future pass, not required for this one. UI microcopy bound to frontend logic stays in frontend/, per the doc's own line between content and frontend. Verified: content/glossary.yaml generated programmatically from the live GLOSSARY dict (not hand-transcribed) and round-tripped byte-for-byte identical against it; content/pages.yaml's four entries checked field-by- field against the original hardcoded strings. Full backend suite green (362 passed, 4 skipped) with zero existing test changes needed beyond one assertion made escaping-aware (a pre-existing Jinja double-escape quirk on the one title containing "&amp;", intentionally preserved not fixed). Built and booted the real Docker image: content/ present at /app/content, and curled /glossary, /glossary/percentine, /about, /privacy from inside the running container - all four render with the exact expected title text.
2026-07-21 01:21:11 +00:00
"page_title": PAGES["about"]["title"],
"page_description": PAGES["about"]["description"],
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
}
return _respond_html(request, "about.html.j2", **ctx)
Rebuild the homepage as a distribution landing; add an SMTP seam (#178) 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.
2026-07-18 07:39:47 +00:00
def privacy_page(request: Request) -> Response:
ctx = {
"canonical_path": "/privacy",
"breadcrumb": [("Home", f"{BASE}/"), ("Privacy", None)],
Extract the glossary and static-page SEO copy into a schema-validated content/ tree (#241) The weather-terms glossary (9 entries) and four static pages' SEO title/ description were hardcoded directly in web/content.py - a 1019-line module that also owns all SSR rendering logic - mixed in with code that changes on a completely different cadence and for different reasons. Add content/glossary.yaml and content/pages.yaml (a new repo-root content/ tree, a sibling of backend/ and frontend/ paths.py resolves the same way - the seed of a future thermograph-copy repo per the architecture decision doc's own §4) plus web/content_loader.py: a small loader that validates each file's shape at load time (required fields present and non-empty, no duplicate glossary slugs) and fails loudly on a malformed edit rather than rendering a blank glossary card or an empty <title>. content.py's GLOSSARY dict and the about/privacy/hub/glossary_index page_title/description literals now come from the loader. Scope: only content that is genuinely pure static data with no embedded template logic. cities_flavor.json (Wikipedia extracts, already its own generated file) and the homepage's title/description (embedded in home.html.j2 as Jinja block overrides, a heavily-tested product-critical template) are deliberately left as they are - a future pass, not required for this one. UI microcopy bound to frontend logic stays in frontend/, per the doc's own line between content and frontend. Verified: content/glossary.yaml generated programmatically from the live GLOSSARY dict (not hand-transcribed) and round-tripped byte-for-byte identical against it; content/pages.yaml's four entries checked field-by- field against the original hardcoded strings. Full backend suite green (362 passed, 4 skipped) with zero existing test changes needed beyond one assertion made escaping-aware (a pre-existing Jinja double-escape quirk on the one title containing "&amp;", intentionally preserved not fixed). Built and booted the real Docker image: content/ present at /app/content, and curled /glossary, /glossary/percentine, /about, /privacy from inside the running container - all four render with the exact expected title text.
2026-07-21 01:21:11 +00:00
"page_title": PAGES["privacy"]["title"],
"page_description": PAGES["privacy"]["description"],
Rebuild the homepage as a distribution landing; add an SMTP seam (#178) 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.
2026-07-18 07:39:47 +00:00
}
return _respond_html(request, "privacy.html.j2", **ctx)
# --- homepage -----------------------------------------------------------------
# The homepage is the Weekly tool plus the distribution surfaces around it. It is
# server-rendered like the SEO pages (rather than a static file with placeholder
# substitution) so a cold visitor with no JS still gets the headline, a real
# graded example, the records strip and the city links.
_HOME_JSONLD = {
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Thermograph",
"applicationCategory": "WeatherApplication",
"operatingSystem": "Web, iOS, Android",
"isAccessibleForFree": True,
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"},
"description": ("How unusual is your weather? Any day, anywhere on Earth, graded "
Rebuild the homepage as a distribution landing; add an SMTP seam (#178) 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.
2026-07-18 07:39:47 +00:00
"against 45 years of that place's own history."),
}
# 12 city chips, chosen for geographic spread rather than raw population, so the
# strip reads as "anywhere on Earth" and seeds crawl paths across the hub.
HOME_CITY_SLUGS = (
"new-york-city-new-york-us", "london-england-gb", "tokyo-jp",
"sydney-new-south-wales-au", "sao-paulo-br", "lagos-ng",
"mumbai-maharashtra-in", "mexico-city-mx", "cairo-eg",
"toronto-ontario-ca", "berlin-state-of-berlin-de", "seattle-washington-us",
)
def _home_cities() -> list[dict]:
"""The chip set, skipping any slug not in the routable city list so a
regenerated cities.json can never 404 a homepage link."""
out = []
for slug in HOME_CITY_SLUGS:
city = cities.get(slug)
if city:
out.append({"slug": slug, "name": city["name"]})
return out
def home_page(request: Request) -> Response:
feed = homepage.load()
stale = bool(feed) and homepage.is_stale(feed)
unusual = None
ranked: list = []
if feed:
ranked = feed.get("ranked") or []
pick = (feed.get("picks") or {}).get("extreme")
if pick:
unusual = dict(pick, is_default=True)
ctx = {
"section": "home",
# The hero headline owns the page's sole h1, so the brand degrades to a <p>.
"brand_tag": "p",
# The tool needs the wide app column, not the 880px reading column.
"main_class": "",
"canonical_path": "/",
"unusual": unusual,
"stale": stale,
"ranked": ranked,
"cities": _home_cities(),
"jsonld_str": Markup(json.dumps({**_HOME_JSONLD, "url": f"{origin(request)}{BASE}/"})),
}
return _respond_html(request, "home.html.j2", **ctx)
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
# --- registration ------------------------------------------------------------
def register(app) -> None:
"""Attach all content routes. Call from app.py BEFORE the StaticFiles mount."""
app.add_api_route(f"{BASE}/robots.txt", robots_txt, methods=["GET"], include_in_schema=False)
app.add_api_route(f"{BASE}/sitemap.xml", sitemap_xml, methods=["GET"], include_in_schema=False)
# IndexNow ownership key, served as a text file at the site root (/{key}.txt)
# so Bing/DuckDuckGo/Yandex can verify our submissions. The key is fixed at
# startup, so registering its literal path here is safe.
import indexnow
_inkey = indexnow.key()
def _indexnow_key_file() -> Response:
return PlainTextResponse(_inkey + "\n")
app.add_api_route(
f"{BASE}/{_inkey}.txt", _indexnow_key_file,
methods=["GET", "HEAD"], include_in_schema=False,
)
Rebuild the homepage as a distribution landing; add an SMTP seam (#178) 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.
2026-07-18 07:39:47 +00:00
# The homepage. Registered here (not as a static file in app.py) so it is
# server-rendered from the same Jinja environment as the SEO pages.
app.add_api_route(f"{BASE}/", home_page, methods=["GET", "HEAD"], include_in_schema=False)
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
app.add_api_route(f"{BASE}/about", about_page, methods=["GET", "HEAD"], include_in_schema=False)
Rebuild the homepage as a distribution landing; add an SMTP seam (#178) 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.
2026-07-18 07:39:47 +00:00
app.add_api_route(f"{BASE}/privacy", privacy_page, methods=["GET", "HEAD"], include_in_schema=False)
SEO: crawlable programmatic climate pages + technical hygiene (#96) * 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.
2026-07-15 23:53:11 +00:00
app.add_api_route(f"{BASE}/glossary", glossary_index, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/glossary/{{term}}", glossary_term, methods=["GET", "HEAD"], include_in_schema=False)
# Hub before /climate/{slug} so the literal path wins.
app.add_api_route(f"{BASE}/climate", hub_page, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/climate/{{slug}}", city_page, methods=["GET", "HEAD"], include_in_schema=False)
# Records before the {month} param so the literal path wins.
app.add_api_route(f"{BASE}/climate/{{slug}}/records", records_page, methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/climate/{{slug}}/{{month}}", month_page, methods=["GET", "HEAD"], include_in_schema=False)