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
|
|
|
|
"""Tests for the crawlable SEO content pages: the city set, robots/sitemap, and
|
|
|
|
|
|
that the server-rendered pages contain the stats in the HTML (with the weather
|
|
|
|
|
|
layer faked, like test_api.py)."""
|
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
|
Render every percentile through one rule (#186)
The homepage strip floored percentiles into 1-99 while the Day page rendered
"100th pct" for the same reading. Rather than teach the Day page a second copy
of the rule, put it in grading.pct_ordinal() and have every surface defer to it:
the Day page, the calendar tooltip, the chart labels, the city pages and the
strip.
Two bugs fell out of unifying them:
- The city pages rendered `{{ c.percentile }}th pct` against a float, so all
~1000 of them showed "97.1th pct", "66.4th pct", "16.5th pct" — and would say
"1th"/"2th"/"3th" for the low tail. Live in prod.
- Python's round() is half-to-even and JavaScript's Math.round is half-up, so
16.5 gave "16th" server-side and "17th" client-side. The Python helper uses
floor(x + 0.5) to match the frontend exactly; a cross-language check over every
.5 boundary now agrees on all of them.
The frontend mirrors the rule as pctOrd() in shared.js, replacing the bare ord()
at all five percentile call sites (day.js, calendar.js, chart.js x2, app.js).
ord() stays as the general ordinal helper it always was.
2026-07-18 09:08:28 +00:00
|
|
|
|
import re
|
|
|
|
|
|
|
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 pytest
|
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
|
|
|
|
|
|
import app as appmod
|
|
|
|
|
|
import cities
|
|
|
|
|
|
import climate
|
Render every percentile through one rule (#186)
The homepage strip floored percentiles into 1-99 while the Day page rendered
"100th pct" for the same reading. Rather than teach the Day page a second copy
of the rule, put it in grading.pct_ordinal() and have every surface defer to it:
the Day page, the calendar tooltip, the chart labels, the city pages and the
strip.
Two bugs fell out of unifying them:
- The city pages rendered `{{ c.percentile }}th pct` against a float, so all
~1000 of them showed "97.1th pct", "66.4th pct", "16.5th pct" — and would say
"1th"/"2th"/"3th" for the low tail. Live in prod.
- Python's round() is half-to-even and JavaScript's Math.round is half-up, so
16.5 gave "16th" server-side and "17th" client-side. The Python helper uses
floor(x + 0.5) to match the frontend exactly; a cross-language check over every
.5 boundary now agrees on all of them.
The frontend mirrors the rule as pctOrd() in shared.js, replacing the bare ord()
at all five percentile call sites (day.js, calendar.js, chart.js x2, app.js).
ord() stays as the general ordinal helper it always was.
2026-07-18 09:08:28 +00:00
|
|
|
|
import grading
|
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 defaults to /thermograph in tests (THERMOGRAPH_BASE unset).
|
|
|
|
|
|
B = "/thermograph"
|
|
|
|
|
|
SLUG = "london-england-gb" # present in cities.json
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def client(monkeypatch, history, recent):
|
|
|
|
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: history.clone())
|
|
|
|
|
|
monkeypatch.setattr(climate, "get_recent_forecast", lambda cell: recent.clone())
|
|
|
|
|
|
monkeypatch.setattr(climate, "get_history", lambda cell: (history.clone(), {"cached": True}))
|
|
|
|
|
|
return TestClient(appmod.app)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_city_set_slugs_unique_and_lookup():
|
|
|
|
|
|
slugs = cities.all_slugs()
|
|
|
|
|
|
assert len(slugs) == len(set(slugs)) >= 100
|
|
|
|
|
|
assert cities.get(SLUG) is not None
|
|
|
|
|
|
assert cities.get("does-not-exist") is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_robots_txt(client):
|
|
|
|
|
|
r = client.get(f"{B}/robots.txt")
|
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
|
assert "Sitemap:" in r.text and "/sitemap.xml" in r.text
|
|
|
|
|
|
assert "Disallow: /thermograph/api/" in r.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sitemap_lists_city_urls(client):
|
|
|
|
|
|
r = client.get(f"{B}/sitemap.xml")
|
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
|
assert "<urlset" in r.text
|
|
|
|
|
|
assert f"/climate/{SLUG}</loc>" in r.text
|
|
|
|
|
|
assert f"/climate/{SLUG}/july</loc>" in r.text
|
|
|
|
|
|
assert f"/climate/{SLUG}/records</loc>" in r.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_city_page_renders_stats_in_html(client):
|
|
|
|
|
|
r = client.get(f"{B}/climate/{SLUG}")
|
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
|
b = r.text
|
|
|
|
|
|
assert "London" in b and "climate" in b.lower()
|
|
|
|
|
|
assert 'rel="canonical"' in b and f"/climate/{SLUG}" in b
|
|
|
|
|
|
assert '"@type":"Dataset"' in b
|
|
|
|
|
|
assert "average temperatures by month" in b.lower()
|
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
|
|
|
|
# a real number rendered in the normals/today text (London is GB, so °C)
|
|
|
|
|
|
assert re.search(r"-?\d+°C</span>", b)
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 13:04:51 +00:00
|
|
|
|
def _jsonld_breadcrumbs(html: str) -> list[dict]:
|
|
|
|
|
|
import json, re
|
|
|
|
|
|
m = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S)
|
|
|
|
|
|
assert m, "no JSON-LD block on page"
|
|
|
|
|
|
graph = json.loads(m.group(1))["@graph"]
|
|
|
|
|
|
return [n for n in graph if n["@type"] == "BreadcrumbList"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("path", [f"/climate/{SLUG}", f"/climate/{SLUG}/records"])
|
|
|
|
|
|
def test_breadcrumb_jsonld_items_valid_for_google(client, path):
|
|
|
|
|
|
# Google: every ListItem except the last must carry an ``item`` URL, so
|
|
|
|
|
|
# unlinked intermediate crumbs (the country) must not appear in JSON-LD.
|
|
|
|
|
|
(bc,) = _jsonld_breadcrumbs(client.get(B + path).text)
|
|
|
|
|
|
els = bc["itemListElement"]
|
|
|
|
|
|
assert [e["position"] for e in els] == list(range(1, len(els) + 1))
|
|
|
|
|
|
for e in els[:-1]:
|
|
|
|
|
|
assert e["item"].startswith("http"), f"crumb {e['name']!r} missing item"
|
|
|
|
|
|
|
|
|
|
|
|
|
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 test_city_404(client):
|
|
|
|
|
|
assert client.get(f"{B}/climate/nope-not-a-city").status_code == 404
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 01:23:45 +00:00
|
|
|
|
def test_curated_events_have_valid_slugs():
|
|
|
|
|
|
import city_events
|
|
|
|
|
|
slugs = set(cities.all_slugs())
|
|
|
|
|
|
assert len(city_events.EVENTS) >= 20
|
|
|
|
|
|
assert all(s in slugs for s in city_events.EVENTS), \
|
|
|
|
|
|
[s for s in city_events.EVENTS if s not in slugs]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_curated_event_renders(client):
|
|
|
|
|
|
b = client.get(f"{B}/climate/new-orleans-louisiana-us").text
|
|
|
|
|
|
assert "Notable weather in New Orleans" in b
|
|
|
|
|
|
assert "Hurricane Katrina" in b
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_uncurated_city_has_no_event_section(client):
|
|
|
|
|
|
b = client.get(f"{B}/climate/shanghai-cn").text
|
|
|
|
|
|
assert "city-event" not in b # falls back to the flavor blurb
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 00:39:47 +00:00
|
|
|
|
def test_city_travel_cta_prefills_compare(client):
|
|
|
|
|
|
b = client.get(f"{B}/climate/{SLUG}").text
|
|
|
|
|
|
assert "Thinking of visiting" in b
|
|
|
|
|
|
assert "/compare#loc=" in b # the city is pre-loaded on the compare page
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_city_blurb_renders_with_attribution(client, monkeypatch):
|
|
|
|
|
|
monkeypatch.setattr(cities, "flavor",
|
|
|
|
|
|
lambda slug: {"extract": "Testville is a lovely place to test.",
|
|
|
|
|
|
"url": "https://en.wikipedia.org/wiki/Testville", "title": "Testville"})
|
|
|
|
|
|
b = client.get(f"{B}/climate/{SLUG}").text
|
|
|
|
|
|
assert "Testville is a lovely place to test." in b
|
|
|
|
|
|
assert "via Wikipedia" in b # CC BY-SA attribution link
|
|
|
|
|
|
|
|
|
|
|
|
|
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 test_month_and_records(client):
|
|
|
|
|
|
assert client.get(f"{B}/climate/{SLUG}/july").status_code == 200
|
|
|
|
|
|
assert client.get(f"{B}/climate/{SLUG}/records").status_code == 200
|
|
|
|
|
|
assert client.get(f"{B}/climate/{SLUG}/notamonth").status_code == 404
|
|
|
|
|
|
|
|
|
|
|
|
|
Month pages: show record high and low for every metric (#113)
The per-month records section listed only the warmest and coldest temperature.
Extend it to a card per metric (High, Low, Feels-like, Humidity, Wind, Gust,
Precip), each showing that calendar month's record high and low with the date.
Precip is special-cased: a per-day record low is just zero, and the dry-streak
helper would bridge year boundaries on month-filtered rows, so the wettest and
driest sides use the month's largest and smallest total accumulation, dated to
the year. Partial current months (< 26 days) are excluded so an unfinished month
can't win "driest" on a fraction of its rainfall.
Reuses the records page's card grid, so it's already vertical on mobile.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-16 05:13:51 +00:00
|
|
|
|
def test_month_records_cover_every_metric_high_and_low(client):
|
|
|
|
|
|
# The month page's records show a record high AND low for each metric present, not
|
|
|
|
|
|
# just warmest/coldest temperature — one card per metric with both extremes. (The
|
|
|
|
|
|
# test fixture carries tmax/tmin/precip; real cities add feels/humidity/wind/gust.)
|
|
|
|
|
|
b = client.get(f"{B}/climate/{SLUG}/july").text
|
|
|
|
|
|
assert "July records" in b
|
|
|
|
|
|
for label in ("High", "Low", "Precip"):
|
|
|
|
|
|
assert f'class="rc-metric">{label}<' in b
|
|
|
|
|
|
# Every card carries a labelled high and low row (temp metrics use Highest/Lowest).
|
|
|
|
|
|
n = b.count('rc-row rc-high')
|
|
|
|
|
|
assert n >= 3 and b.count('rc-row rc-low') == n
|
|
|
|
|
|
assert "Highest" in b and "Lowest" in b
|
|
|
|
|
|
# Precip's extremes are the wettest/driest whole month by total accumulation.
|
|
|
|
|
|
assert "Wettest" in b and "Driest" in b
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 03:50:59 +00:00
|
|
|
|
def test_records_page_has_monthly_and_seasonal(client):
|
|
|
|
|
|
b = client.get(f"{B}/climate/{SLUG}/records").text
|
|
|
|
|
|
# Monthly records: all 12 months, each linking to its month page.
|
|
|
|
|
|
assert "Records by month" in b
|
|
|
|
|
|
for month in ("January", "July", "December"):
|
|
|
|
|
|
assert month in b
|
|
|
|
|
|
assert f"/climate/{SLUG}/january" in b
|
|
|
|
|
|
# Seasonal records: labelled for London's Northern Hemisphere, with month spans.
|
|
|
|
|
|
assert "Records by season" in b
|
|
|
|
|
|
assert "Northern Hemisphere" in b
|
|
|
|
|
|
assert 'Winter <span class="season-span">(Dec–Feb)</span>' in b
|
|
|
|
|
|
assert 'Summer <span class="season-span">(Jun–Aug)</span>' in b
|
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
|
|
|
|
# All-time table still present, plus structured data and real values.
|
2026-07-16 03:50:59 +00:00
|
|
|
|
assert "All-time records" in b
|
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
|
|
|
|
assert '"@type":"Dataset"' in b
|
|
|
|
|
|
assert re.search(r"-?\d+°C</span>", b)
|
|
|
|
|
|
# The Dataset structured data still advertises the month/season coverage.
|
2026-07-16 03:50:59 +00:00
|
|
|
|
assert "monthly" in b.lower() and "seasonal" in b.lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_records_seasons_flip_in_southern_hemisphere(client):
|
|
|
|
|
|
# São Paulo is south of the equator, so Dec–Feb is summer, not winter.
|
|
|
|
|
|
b = client.get(f"{B}/climate/sao-paulo-br/records").text
|
|
|
|
|
|
assert "Southern Hemisphere" in b
|
|
|
|
|
|
assert 'Summer <span class="season-span">(Dec–Feb)</span>' in b
|
|
|
|
|
|
assert 'Winter <span class="season-span">(Jun–Aug)</span>' in b
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_climate_pages_are_colour_coded(client):
|
|
|
|
|
|
# Temperatures wear the diverging cold→hot palette (a heat map, not a plain grid),
|
|
|
|
|
|
# and the city page carries the monthly temperature-range strip.
|
|
|
|
|
|
city = client.get(f"{B}/climate/{SLUG}").text
|
|
|
|
|
|
assert "t-cell t-" in city # tinted temperature cells in the normals table
|
|
|
|
|
|
assert "range-fill" in city and "--c1:var(--" in city # the monthly range strip's gradient bars
|
|
|
|
|
|
recs = client.get(f"{B}/climate/{SLUG}/records").text
|
2026-07-16 04:57:13 +00:00
|
|
|
|
assert "t-inline t-" in recs # tinted record chips
|
2026-07-16 03:50:59 +00:00
|
|
|
|
month = client.get(f"{B}/climate/{SLUG}/july").text
|
|
|
|
|
|
assert "t-inline t-" in month # tinted hero numbers + records
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 04:57:13 +00:00
|
|
|
|
def test_records_show_both_extremes_per_metric(client):
|
|
|
|
|
|
# Each metric column shows BOTH its record warmest (▲) and coldest (▼) — so the
|
|
|
|
|
|
# daytime high carries its record-low high, and the overnight low its record-high low.
|
|
|
|
|
|
b = client.get(f"{B}/climate/{SLUG}/records").text
|
|
|
|
|
|
assert "Daytime high" in b and "Overnight low" in b
|
|
|
|
|
|
assert "▲" in b and "▼" in b
|
|
|
|
|
|
# 12 months × 2 metric columns × 2 extremes = 48 chips just in the monthly table.
|
|
|
|
|
|
assert b.count('class="rec-ext"') >= 48
|
|
|
|
|
|
|
|
|
|
|
|
|
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 test_hub_glossary_about(client):
|
|
|
|
|
|
assert client.get(f"{B}/climate").status_code == 200
|
|
|
|
|
|
assert client.get(f"{B}/glossary").status_code == 200
|
|
|
|
|
|
assert client.get(f"{B}/glossary/percentile").status_code == 200
|
|
|
|
|
|
assert client.get(f"{B}/glossary/not-a-term").status_code == 404
|
|
|
|
|
|
assert client.get(f"{B}/about").status_code == 200
|
2026-07-16 17:48:30 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-20 04:14:56 +00:00
|
|
|
|
def test_footer_has_no_leaked_jinja_comment(client):
|
|
|
|
|
|
# A comment whose prose contained a literal "#}" closed early and leaked
|
|
|
|
|
|
# "wrapper. #}" into the footer. Guard every base-template page against a
|
|
|
|
|
|
# stray Jinja comment delimiter reaching the rendered HTML.
|
|
|
|
|
|
import re
|
|
|
|
|
|
for path in (f"{B}/about", f"{B}/climate/{SLUG}", f"{B}/climate/{SLUG}/records"):
|
|
|
|
|
|
b = client.get(path).text
|
|
|
|
|
|
footer = re.search(r"<footer.*?</footer>", b, re.S)
|
|
|
|
|
|
assert footer, f"no footer on {path}"
|
|
|
|
|
|
assert "#}" not in footer.group(0) and "{#" not in footer.group(0), path
|
|
|
|
|
|
|
|
|
|
|
|
|
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 test_title_name_is_short_and_unique():
|
|
|
|
|
|
# Titles lead with the bare city name so the payload ("… in July") survives
|
|
|
|
|
|
# SERP truncation — display_name()'s region+country is what pushed it off the
|
|
|
|
|
|
# end. The exception is a name that recurs: those must still be told apart,
|
|
|
|
|
|
# and every rendered title must stay unique across the whole city set.
|
|
|
|
|
|
all_cities = cities.all_cities()
|
|
|
|
|
|
titles = [cities.title_name(c) for c in all_cities]
|
|
|
|
|
|
assert len(titles) == len(set(titles)), "two cities share a title"
|
|
|
|
|
|
# The overwhelming majority pay no disambiguation cost at all.
|
|
|
|
|
|
assert sum(1 for t in titles if "," not in t) > 0.9 * len(titles)
|
|
|
|
|
|
|
|
|
|
|
|
by_slug = {c["slug"]: cities.title_name(c) for c in all_cities}
|
|
|
|
|
|
assert by_slug["seattle-washington-us"] == "Seattle"
|
|
|
|
|
|
# Same name, different countries -> country code is enough.
|
|
|
|
|
|
assert by_slug["london-england-gb"] == "London, GB"
|
|
|
|
|
|
assert by_slug["london-ontario-ca"] == "London, CA"
|
|
|
|
|
|
# Same name AND same country -> the country code would collide, so fall back
|
|
|
|
|
|
# to admin1. Without this, both Columbuses would render "Columbus, US".
|
|
|
|
|
|
assert by_slug["columbus-ohio-us"] == "Columbus, Ohio"
|
|
|
|
|
|
assert by_slug["columbus-georgia-us"] == "Columbus, Georgia"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_page_titles_lead_with_city_and_payload(client):
|
|
|
|
|
|
for path, must_start, payload in (
|
|
|
|
|
|
(f"{B}/climate/{SLUG}", "London, GB climate", "how unusual it is now"),
|
|
|
|
|
|
(f"{B}/climate/{SLUG}/july", "London, GB in July", "normal weather"),
|
|
|
|
|
|
(f"{B}/climate/{SLUG}/records", "London, GB weather records", "hottest & coldest"),
|
|
|
|
|
|
):
|
|
|
|
|
|
b = client.get(path).text
|
|
|
|
|
|
title = re.search(r"<title>(.*?)</title>", b, re.S).group(1)
|
|
|
|
|
|
title = title.replace("&", "&")
|
|
|
|
|
|
assert title.startswith(must_start), title
|
|
|
|
|
|
assert payload in title, title
|
|
|
|
|
|
# The region+country the old template spent its width on is gone.
|
|
|
|
|
|
assert "England, United Kingdom" not in title
|
|
|
|
|
|
# Whatever else happens, the city and the page's subject are inside the
|
|
|
|
|
|
# first ~40 chars, so a SERP cut can no longer remove the payload.
|
|
|
|
|
|
assert len(must_start) <= 40
|
|
|
|
|
|
# og:title mirrors <title> via base.html.j2's self.title().
|
|
|
|
|
|
og = re.search(r'<meta property="og:title" content="(.*?)"', b, re.S).group(1)
|
|
|
|
|
|
assert og == re.search(r"<title>(.*?)</title>", b, re.S).group(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meta_descriptions_lead_with_real_numbers(client):
|
|
|
|
|
|
for path in (f"{B}/climate/{SLUG}", f"{B}/climate/{SLUG}/july",
|
|
|
|
|
|
f"{B}/climate/{SLUG}/records"):
|
|
|
|
|
|
b = client.get(path).text
|
|
|
|
|
|
desc = re.search(r'<meta name="description" content="(.*?)"', b, re.S).group(1)
|
|
|
|
|
|
assert len(desc) <= 155, f"{len(desc)}: {desc}"
|
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
|
|
|
|
# A real temperature, not a generic "averages, records and rainfall" —
|
|
|
|
|
|
# in the city's own unit, since a meta description is never converted
|
|
|
|
|
|
# client-side. London is GB, so these read °C.
|
|
|
|
|
|
assert re.search(r"-?\d+°C", desc), desc
|
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
|
|
|
|
assert desc.endswith("years of local history."), desc
|
|
|
|
|
|
# The records description names the dates the extremes were set.
|
|
|
|
|
|
rec = client.get(f"{B}/climate/{SLUG}/records").text
|
|
|
|
|
|
desc = re.search(r'<meta name="description" content="(.*?)"', rec, re.S).group(1)
|
|
|
|
|
|
assert re.search(r"\([A-Z][a-z]{2} \d{4}\)", desc), desc
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
_TEMP_SPAN = re.compile(r'<span class="temp" data-temp-f="(-?[\d.]+)"([^>]*)>(-?\d+)°([CF]?)</span>')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _temp_spans(html):
|
|
|
|
|
|
"""(fahrenheit_attr, shown_number, shown_letter) for every rendered temp span.
|
|
|
|
|
|
|
|
|
|
|
|
Reading the spans rather than the raw document matters: base.html.j2 mentions
|
|
|
|
|
|
"°F/°C" in an HTML comment, so a bare `"°F" in html` assertion passes on a page
|
|
|
|
|
|
whose every temperature is Celsius.
|
|
|
|
|
|
"""
|
|
|
|
|
|
return [(float(f), int(n), letter) for f, _attrs, n, letter in _TEMP_SPAN.findall(html)]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 17:48:30 +00:00
|
|
|
|
def test_climate_pages_carry_convertible_temps(client):
|
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
|
|
|
|
# Every temperature is a client-convertible span; the old inline "(NN°C)" dual
|
|
|
|
|
|
# form is gone (the toggle now supplies the other unit).
|
2026-07-16 17:48:30 +00:00
|
|
|
|
for path in (f"{B}/climate/{SLUG}", f"{B}/climate/{SLUG}/july", f"{B}/climate/{SLUG}/records"):
|
|
|
|
|
|
b = client.get(path).text
|
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
|
|
|
|
spans = _temp_spans(b)
|
|
|
|
|
|
assert spans, f"no temperature spans on {path}"
|
2026-07-16 17:48:30 +00:00
|
|
|
|
assert re.search(r"\d+°F \(-?\d+°C\)", b) is None # no leftover dual-unit strings
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
def test_units_default_to_the_citys_country(client):
|
|
|
|
|
|
# The search snippet shows whatever the server rendered, and impressions skew
|
|
|
|
|
|
# to °C countries — so a UK city page must say °C in the HTML itself, with no
|
|
|
|
|
|
# JS and no stored preference. data-temp-f stays Fahrenheit either way: it is
|
|
|
|
|
|
# what climate.js converts from.
|
|
|
|
|
|
gb = client.get(f"{B}/climate/{SLUG}").text # London, GB
|
|
|
|
|
|
us = client.get(f"{B}/climate/seattle-washington-us").text
|
|
|
|
|
|
|
|
|
|
|
|
gb_spans, us_spans = _temp_spans(gb), _temp_spans(us)
|
|
|
|
|
|
assert gb_spans and us_spans
|
|
|
|
|
|
assert {letter for _f, _n, letter in gb_spans if letter} == {"C"}
|
|
|
|
|
|
assert {letter for _f, _n, letter in us_spans if letter} == {"F"}
|
|
|
|
|
|
|
|
|
|
|
|
# The attribute is still Fahrenheit, and really does differ from the Celsius
|
|
|
|
|
|
# text — this is what breaks if someone "helpfully" converts the attribute too.
|
|
|
|
|
|
assert any(math.floor(f + 0.5) != n for f, n, _l in gb_spans), "data-temp-f was converted"
|
|
|
|
|
|
# On a °F page the attribute and the text agree by definition. Rounded half-up,
|
|
|
|
|
|
# not with Python's round(): the page renders the number JS would.
|
|
|
|
|
|
assert all(math.floor(f + 0.5) == n for f, n, _l in us_spans)
|
|
|
|
|
|
|
|
|
|
|
|
assert 'data-unit-default="C"' in gb
|
|
|
|
|
|
assert 'data-unit-default="F"' in us
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
_PRECIP_SPAN = re.compile(r'<span class="precip" data-precip-in="([\d.]+)">([\d.]+) (in|mm)</span>')
|
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 test_precip_and_wind_follow_the_citys_unit(client):
|
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 toggle drives every measure: a °C page shows mm and km/h, not °C mixed
|
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
|
|
|
|
# with inches and mph. The data-* attribute stays imperial in both, so
|
|
|
|
|
|
# climate.js converts from the same source of truth it does for temperature.
|
|
|
|
|
|
gb = client.get(f"{B}/climate/{SLUG}/records").text # London, GB
|
|
|
|
|
|
us = client.get(f"{B}/climate/seattle-washington-us/records").text
|
|
|
|
|
|
|
|
|
|
|
|
gb_p, us_p = _PRECIP_SPAN.findall(gb), _PRECIP_SPAN.findall(us)
|
|
|
|
|
|
assert gb_p and us_p
|
|
|
|
|
|
|
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
|
|
|
|
assert {u for _raw, _shown, u in gb_p} == {"mm"}
|
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
|
|
|
|
assert {u for _raw, _shown, u in us_p} == {"in"}
|
|
|
|
|
|
|
|
|
|
|
|
# The conversion is real, not just a relabel.
|
|
|
|
|
|
for raw, shown, _u in gb_p:
|
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
|
|
|
|
assert abs(float(shown) - float(raw) * 25.4) < 0.51, (raw, shown)
|
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
|
|
|
|
# On an imperial page the attribute and the text agree.
|
|
|
|
|
|
for raw, shown, _u in us_p:
|
|
|
|
|
|
assert abs(float(shown) - float(raw)) < 0.005, (raw, shown)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_wind_and_humidity_render_per_unit_system():
|
|
|
|
|
|
# The synthetic history carries only tmax/tmin/precip, so wind and humidity
|
|
|
|
|
|
# never reach a rendered page in tests — exercise the renderers directly
|
|
|
|
|
|
# rather than assert on a page that structurally cannot show them.
|
|
|
|
|
|
import content
|
|
|
|
|
|
with content._unit_scope("F"):
|
|
|
|
|
|
assert content._wind_text(10) == "10 mph"
|
|
|
|
|
|
assert '<span class="wind" data-wind-mph="10.0">10 mph</span>' == content._wind(10)
|
|
|
|
|
|
assert content._fmt("humid", 7.75) == "7.8 g/m³"
|
|
|
|
|
|
with content._unit_scope("C"):
|
|
|
|
|
|
# 10 mph -> 16.09 km/h, rounded half-up like JS.
|
|
|
|
|
|
assert content._wind_text(10) == "16 km/h"
|
|
|
|
|
|
# data-wind-mph stays imperial: it is what climate.js converts from.
|
|
|
|
|
|
assert 'data-wind-mph="10.0"' in content._wind(10)
|
|
|
|
|
|
# Absolute humidity has no imperial equivalent anyone would recognise, so
|
|
|
|
|
|
# it reads the same in both systems.
|
|
|
|
|
|
assert content._fmt("humid", 7.75) == "7.8 g/m³"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_precip_precision_differs_by_unit():
|
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
|
|
|
|
# Rainfall is reported in whole millimetres; a tenth of a mm is below what
|
|
|
|
|
|
# the source resolves. Inches keep two decimals.
|
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
|
|
|
|
import content
|
|
|
|
|
|
with content._unit_scope("F"):
|
|
|
|
|
|
assert content._precip_text(0.04) == "0.04 in"
|
|
|
|
|
|
with content._unit_scope("C"):
|
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
|
|
|
|
assert content._precip_text(0.04) == "1 mm"
|
|
|
|
|
|
assert content._precip_text(1.81) == "46 mm"
|
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 test_measure_conversion_constants_match_the_frontend():
|
|
|
|
|
|
# Same drift risk as the country list: two hand-maintained copies.
|
|
|
|
|
|
import os
|
|
|
|
|
|
import content
|
|
|
|
|
|
units_js = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
|
|
|
|
os.pardir, "frontend", "units.js")
|
|
|
|
|
|
with open(units_js, encoding="utf-8") as f:
|
|
|
|
|
|
src = f.read()
|
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 = float(re.search(r"MM_PER_IN\s*=\s*([\d.]+)", src).group(1))
|
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 = float(re.search(r"KMH_PER_MPH\s*=\s*([\d.]+)", src).group(1))
|
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
|
|
|
|
assert mm == content.MM_PER_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
|
|
|
|
assert kmh == content.KMH_PER_MPH
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
def test_unit_default_absent_without_a_city(client):
|
|
|
|
|
|
# No city -> no attribute -> units.js keeps today's locale behaviour. Pinning
|
|
|
|
|
|
# these to "F" would silently regress the interactive pages.
|
|
|
|
|
|
for path in (f"{B}/", f"{B}/climate", f"{B}/about", f"{B}/glossary"):
|
|
|
|
|
|
assert "data-unit-default" not in client.get(path).text, path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_unit_does_not_leak_between_requests(client):
|
|
|
|
|
|
# The page handlers are sync `def`, so Starlette runs them on a recycled
|
|
|
|
|
|
# threadpool thread: a unit left set would bleed into the next request served
|
|
|
|
|
|
# by that thread. Interleave and re-check rather than trusting the reset.
|
|
|
|
|
|
assert 'data-unit-default="C"' in client.get(f"{B}/climate/{SLUG}").text
|
|
|
|
|
|
about = client.get(f"{B}/about").text
|
|
|
|
|
|
assert "data-unit-default" not in about
|
|
|
|
|
|
# about.html.j2 renders an illustrative temperature; it must stay °F.
|
|
|
|
|
|
assert {l for _f, _n, l in _temp_spans(about) if l} == {"F"}
|
|
|
|
|
|
|
|
|
|
|
|
us = client.get(f"{B}/climate/seattle-washington-us").text
|
|
|
|
|
|
assert {l for _f, _n, l in _temp_spans(us) if l} == {"F"}
|
|
|
|
|
|
gb_again = client.get(f"{B}/climate/{SLUG}").text
|
|
|
|
|
|
assert {l for _f, _n, l in _temp_spans(gb_again) if l} == {"C"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_f_country_list_matches_the_frontend():
|
|
|
|
|
|
# Two hand-maintained copies of the same 14 codes; nothing else stops them
|
|
|
|
|
|
# drifting apart, and drift means the server and the client disagree about
|
|
|
|
|
|
# what a first-time visitor should see.
|
|
|
|
|
|
import os
|
|
|
|
|
|
import content
|
|
|
|
|
|
units_js = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
|
|
|
|
os.pardir, "frontend", "units.js")
|
|
|
|
|
|
with open(units_js, encoding="utf-8") as f:
|
|
|
|
|
|
src = f.read()
|
|
|
|
|
|
literal = re.search(r"F_REGIONS = new Set\(\[(.*?)\]\)", src, re.S).group(1)
|
|
|
|
|
|
assert set(re.findall(r'"([A-Z]{2})"', literal)) == set(content.F_COUNTRIES)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_celsius_rounding_matches_js():
|
|
|
|
|
|
# Python's round() goes to even, JS's Math.round goes up. Where they disagree
|
|
|
|
|
|
# the server prints one number and climate.js repaints another — a flicker for
|
|
|
|
|
|
# the visitor whose unit already matched.
|
|
|
|
|
|
import content
|
|
|
|
|
|
assert content._round_half_up(0.5) == 1 # round(0.5) would give 0
|
|
|
|
|
|
assert content._round_half_up(1.5) == 2
|
|
|
|
|
|
assert content._round_half_up(2.5) == 3 # round(2.5) would give 2
|
|
|
|
|
|
assert content._round_half_up(-0.5) == 0 # Math.round(-0.5) === 0
|
|
|
|
|
|
# 31.1°F -> -0.5°C exactly: the case that actually shows up on a cold page.
|
|
|
|
|
|
assert content._c(31.1) == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_city_page_etag_is_stable(client):
|
|
|
|
|
|
# The unit comes from the URL's city, never from a request header, so the
|
|
|
|
|
|
# response stays a pure function of the URL — no Vary, and the ETag still works.
|
|
|
|
|
|
r1 = client.get(f"{B}/climate/{SLUG}")
|
|
|
|
|
|
r2 = client.get(f"{B}/climate/{SLUG}")
|
|
|
|
|
|
assert r1.headers["etag"] == r2.headers["etag"]
|
|
|
|
|
|
r3 = client.get(f"{B}/climate/{SLUG}", headers={"If-None-Match": r1.headers["etag"]})
|
|
|
|
|
|
assert r3.status_code == 304
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 17:48:30 +00:00
|
|
|
|
def test_seo_pages_load_unit_and_account_scripts(client):
|
|
|
|
|
|
# Header parity with the interactive pages: the °F/°C toggle, account/bell, and
|
|
|
|
|
|
# the temperature converter are all loaded.
|
|
|
|
|
|
b = client.get(f"{B}/climate/{SLUG}").text
|
|
|
|
|
|
for src in (f"{B}/units.js", f"{B}/account.js", f"{B}/climate.js"):
|
|
|
|
|
|
assert f'src="{src}"' in b
|
2026-07-16 18:08:09 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_indexnow_key_file_served(client):
|
|
|
|
|
|
import indexnow
|
|
|
|
|
|
k = indexnow.key()
|
|
|
|
|
|
r = client.get(f"{B}/{k}.txt")
|
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
|
assert r.text.strip() == k
|
|
|
|
|
|
assert r.headers["content-type"].startswith("text/plain")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sitemap_lastmod_is_stable(client):
|
|
|
|
|
|
import datetime
|
|
|
|
|
|
import re
|
|
|
|
|
|
body = client.get(f"{B}/sitemap.xml").text
|
|
|
|
|
|
mods = set(re.findall(r"<lastmod>([^<]+)</lastmod>", body))
|
|
|
|
|
|
assert len(mods) == 1 # one build date, not a per-request churn
|
|
|
|
|
|
datetime.date.fromisoformat(next(iter(mods))) # a valid ISO date
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_indexnow_submit_all_builds_payload(monkeypatch):
|
|
|
|
|
|
import indexnow
|
|
|
|
|
|
|
|
|
|
|
|
calls = []
|
|
|
|
|
|
|
|
|
|
|
|
class _Resp:
|
|
|
|
|
|
status_code = 200
|
|
|
|
|
|
text = "ok"
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(indexnow.httpx, "post",
|
|
|
|
|
|
lambda url, json=None, **kw: (calls.append((url, json)), _Resp())[1])
|
|
|
|
|
|
res = indexnow.submit_all("https://thermograph.org")
|
|
|
|
|
|
assert res["submitted"] == res["total"] > 100
|
|
|
|
|
|
first = calls[0][1]
|
|
|
|
|
|
assert first["host"] == "thermograph.org"
|
|
|
|
|
|
assert first["keyLocation"] == f"https://thermograph.org/{indexnow.key()}.txt"
|
|
|
|
|
|
assert first["urlList"][0] == "https://thermograph.org/"
|
|
|
|
|
|
assert all(len(c[1]["urlList"]) <= 10000 for c in calls) # batched under the IndexNow cap
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 20:38:59 +00:00
|
|
|
|
def test_indexnow_if_changed_state(monkeypatch, tmp_path):
|
|
|
|
|
|
import indexnow
|
|
|
|
|
|
monkeypatch.setattr(indexnow, "_STATE_PATH", str(tmp_path / "state.txt"))
|
|
|
|
|
|
sig = indexnow.url_signature()
|
|
|
|
|
|
assert len(sig) == 40 and sig == indexnow.url_signature() # stable sha1 of the URL set
|
|
|
|
|
|
assert indexnow._read_state() == "" # first deploy → would submit
|
|
|
|
|
|
indexnow._write_state(sig)
|
|
|
|
|
|
assert indexnow._read_state() == sig # unchanged → deploy would skip
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 18:08:09 +00:00
|
|
|
|
def test_search_verification_meta(client, monkeypatch):
|
|
|
|
|
|
monkeypatch.setenv("THERMOGRAPH_GOOGLE_VERIFY", "gtok123")
|
|
|
|
|
|
monkeypatch.setenv("THERMOGRAPH_BING_VERIFY", "btok456")
|
|
|
|
|
|
seo = client.get(f"{B}/climate/{SLUG}").text # Jinja SEO page
|
|
|
|
|
|
home = client.get(f"{B}/").text # static homepage via _page()
|
|
|
|
|
|
for b in (seo, home):
|
|
|
|
|
|
assert '<meta name="google-site-verification" content="gtok123">' in b
|
|
|
|
|
|
assert '<meta name="msvalidate.01" content="btok456">' in b
|
Make the brand lockup a home link on every page (#182)
The mark and wordmark were already a home link on the Jinja pages (homepage,
about, climate, glossary, privacy), but the five static tool pages — calendar,
day, compare, legend, alerts — built their header separately and left the brand
as a bare <h1>, so clicking it did nothing.
Wrap the lockup in `<a href="./">` there, matching the relative form the nav
already uses: it resolves to the app root whether the app is served at the
domain root or under a base path.
The link wraps the whole lockup, so the mark is clickable too, not just the
word. Its colour/decoration moves from `.site-name a` up to the shared
`.brand h1 a, .brand .site-name a` rule so it covers both the static pages'
<h1> form and the homepage's <p>, with an accent hover so the affordance is
visible.
Adds a parametrized test over every page: the two header implementations are
easy to change independently, so this asserts each one carries a home link that
wraps the mark.
2026-07-18 08:06:14 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# The brand lockup is the site-wide "go home" affordance. The static tool pages
|
|
|
|
|
|
# and the Jinja pages build their headers separately, so this is easy to add to
|
|
|
|
|
|
# one and forget on the other — assert every page carries it.
|
|
|
|
|
|
BRAND_PAGES = ["/", "/calendar", "/day", "/compare", "/legend", "/alerts",
|
|
|
|
|
|
"/about", "/privacy", "/climate", "/glossary"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("path", BRAND_PAGES)
|
|
|
|
|
|
def test_brand_lockup_links_home(client, path):
|
|
|
|
|
|
html = client.get(f"{B}{path}").text
|
|
|
|
|
|
brand = html.split('<div class="brand">', 1)[1].split("</header>", 1)[0]
|
|
|
|
|
|
head = brand.split("</h1>")[0] if "<h1" in brand else brand.split("</p>")[0]
|
|
|
|
|
|
# The link must wrap the whole lockup, so clicking the mark works too, not
|
|
|
|
|
|
# just the word.
|
|
|
|
|
|
assert "<a href=" in head, f"{path}: brand is not a link"
|
|
|
|
|
|
href = head.split('<a href="', 1)[1].split('"', 1)[0]
|
|
|
|
|
|
assert href in ("./", f"{B}/"), f"{path}: brand links to {href!r}, not home"
|
|
|
|
|
|
assert '<span class="logo">' in head.split("<a href=", 1)[1], \
|
|
|
|
|
|
f"{path}: the mark sits outside the home link"
|
Render every percentile through one rule (#186)
The homepage strip floored percentiles into 1-99 while the Day page rendered
"100th pct" for the same reading. Rather than teach the Day page a second copy
of the rule, put it in grading.pct_ordinal() and have every surface defer to it:
the Day page, the calendar tooltip, the chart labels, the city pages and the
strip.
Two bugs fell out of unifying them:
- The city pages rendered `{{ c.percentile }}th pct` against a float, so all
~1000 of them showed "97.1th pct", "66.4th pct", "16.5th pct" — and would say
"1th"/"2th"/"3th" for the low tail. Live in prod.
- Python's round() is half-to-even and JavaScript's Math.round is half-up, so
16.5 gave "16th" server-side and "17th" client-side. The Python helper uses
floor(x + 0.5) to match the frontend exactly; a cross-language check over every
.5 boundary now agrees on all of them.
The frontend mirrors the rule as pctOrd() in shared.js, replacing the bare ord()
at all five percentile call sites (day.js, calendar.js, chart.js x2, app.js).
ord() stays as the general ordinal helper it always was.
2026-07-18 09:08:28 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Percentiles are shown on the Day page, the calendar tooltip, the chart, the
|
|
|
|
|
|
# city pages and the homepage strip. They all have to say the same thing about
|
|
|
|
|
|
# the same reading, so the rule lives in grading.pct_ordinal() and every surface
|
|
|
|
|
|
# defers to it (the frontend mirrors it as pctOrd in shared.js).
|
|
|
|
|
|
def test_pct_ordinal_rule():
|
|
|
|
|
|
assert grading.pct_ordinal(66.4) == "66th"
|
|
|
|
|
|
assert grading.pct_ordinal(1.2) == "1st"
|
|
|
|
|
|
assert grading.pct_ordinal(22) == "22nd"
|
|
|
|
|
|
assert grading.pct_ordinal(3) == "3rd"
|
|
|
|
|
|
assert grading.pct_ordinal(11) == "11th"
|
|
|
|
|
|
# Floored into 1..99: an empirical percentile is a rank against the sample,
|
|
|
|
|
|
# so "100th percentile" reads as a measurement error.
|
|
|
|
|
|
assert grading.pct_ordinal(99.6) == "99th"
|
|
|
|
|
|
assert grading.pct_ordinal(100) == "99th"
|
|
|
|
|
|
assert grading.pct_ordinal(0) == "1st"
|
|
|
|
|
|
assert grading.pct_ordinal(None) == "—"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_city_page_percentiles_are_real_ordinals(client):
|
|
|
|
|
|
"""The city page used to render `{{ c.percentile }}th pct` against a float,
|
|
|
|
|
|
producing "97.1th pct" and "1th pct" on every one of the ~1000 city pages."""
|
|
|
|
|
|
html = client.get(f"{B}/climate/{SLUG}").text
|
|
|
|
|
|
assert "th pct" in html or "st pct" in html or "nd pct" in html or "rd pct" in html
|
|
|
|
|
|
# No float ordinals, no impossible ones, no "1th"/"2th"/"3th".
|
|
|
|
|
|
for bad in re.findall(r"\d+\.\d+(?:st|nd|rd|th)|100th|\b0th|\b\d*[123]th", html):
|
|
|
|
|
|
raise AssertionError(f"malformed percentile ordinal: {bad!r}")
|