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
This commit is contained in:
Emi Griffith 2026-07-19 11:25:55 -07:00 committed by GitHub
parent 1720ac1da6
commit 5a921cf01d
3 changed files with 202 additions and 25 deletions

View file

@ -5,10 +5,13 @@ These are the SEO surface: real URLs with the climate stats in the HTML, rendere
with Jinja2 from the same builders the API uses, linking into the interactive tool. with Jinja2 from the same builders the API uses, linking into the interactive tool.
Registered on the app (via register()) BEFORE the StaticFiles mount so they win. Registered on the app (via register()) BEFORE the StaticFiles mount so they win.
""" """
import contextlib
import contextvars
import datetime import datetime
import functools import functools
import hashlib import hashlib
import json import json
import math
import os import os
import polars as pl import polars as pl
@ -60,18 +63,78 @@ METRIC_LABELS = [
_TEMP_METRICS = {"tmax", "tmin", "feels"} _TEMP_METRICS = {"tmax", "tmin", "feels"}
# Countries that actually use Fahrenheit. Mirrors F_REGIONS in frontend/units.js —
# the client applies it to the visitor's locale, we apply it to the city's country.
# A test asserts the two lists stay identical.
F_COUNTRIES = frozenset({"US", "PR", "GU", "VI", "AS", "MP", "UM",
"BS", "BZ", "KY", "PW", "FM", "MH", "LR"})
# The unit the current page renders temperatures in. None = °F, and also means
# "this page has no city", which is what keeps the interactive pages on the
# client-side locale default instead of being pinned server-side.
#
# A ContextVar rather than a parameter because _temp() is reached from both sides:
# templates call it as a Jinja global, and the context builders call it directly
# (the records tables are pre-rendered into strings in Python). Threading a `unit`
# argument would mean touching ~20 call sites across three levels of nesting, where
# forgetting one yields a silently wrong unit rather than an error.
_UNIT: contextvars.ContextVar[str | None] = contextvars.ContextVar("display_unit", default=None)
@contextlib.contextmanager
def _unit_scope(unit: str | None):
"""Render temperatures in `unit` for the duration of the block.
The reset is not optional: the page handlers are sync `def`, so Starlette runs
them on a recycled threadpool thread, and a value left set would leak into
whatever request that thread serves next including pages with no city.
"""
token = _UNIT.set(unit)
try:
yield
finally:
_UNIT.reset(token)
def unit_for_country(code: str | None) -> str:
"""The temperature unit a reader in this country expects."""
return "F" if (code or "").upper() in F_COUNTRIES else "C"
def _round_half_up(x: float) -> int:
"""Round like JS's Math.round, not like Python's round().
Python rounds halves to even, JS rounds them up. Where they disagree the
server would render one number and climate.js would repaint a different one
a visible flicker for exactly the visitor whose unit already matched.
"""
return math.floor(x + 0.5)
def _c(f: float) -> int: def _c(f: float) -> int:
return round((f - 32) * 5 / 9) return _round_half_up((f - 32) * 5 / 9)
def _shown(f: float) -> int:
"""The number to print, in the active unit."""
return _c(f) if _UNIT.get() == "C" else _round_half_up(f)
def _letter() -> str:
return "C" if _UNIT.get() == "C" else "F"
def _temp(f): def _temp(f):
"""A Fahrenheit temperature as a client-convertible span, e.g. """A Fahrenheit temperature as a client-convertible span, e.g.
'<span class="temp" data-temp-f="72.3">72°F</span>'. Renders °F by default '<span class="temp" data-temp-f="72.3">72°F</span>'. The text is rendered in
(so crawlers and no-JS visitors see a real value); climate.js rewrites it to the page's unit (the city's country convention see _unit_scope), so crawlers
the active unit on load and on toggle. Returns '' for a missing value.""" and no-JS visitors get the local convention rather than a conversion they have
to do themselves; climate.js repaints on toggle. data-temp-f stays Fahrenheit
either way it is the conversion source of truth. '' for a missing value."""
if f is None: if f is None:
return "" return ""
return Markup('<span class="temp" data-temp-f="{:.1f}">{}°F</span>').format(f, round(f)) return Markup('<span class="temp" data-temp-f="{:.1f}">{}°{}</span>').format(
f, _shown(f), _letter())
def _temp_bare(f): def _temp_bare(f):
@ -79,16 +142,18 @@ def _temp_bare(f):
axis already implies the unit. Still carries data-temp-f so it converts.""" axis already implies the unit. Still carries data-temp-f so it converts."""
if f is None: if f is None:
return "" return ""
return Markup('<span class="temp" data-temp-f="{:.1f}" data-bare>{}°</span>').format(f, round(f)) return Markup('<span class="temp" data-temp-f="{:.1f}" data-bare>{}°</span>').format(
f, _shown(f))
def _temp_text(f) -> str: def _temp_text(f) -> str:
"""'72°F' as plain text. The span _temp() returns can't go in a """'72°F' as plain text. The span _temp() returns can't go in a
<meta content="">, and a meta description is the one place a temperature is <meta content="">, and a meta description is the one place a temperature is
never converted client-side it is what the search snippet quotes.""" never converted client-side it is what the search snippet quotes, which is
why it has to be in the city's own unit at render time."""
if f is None: if f is None:
return "" return ""
return f"{round(f)}°F" return f"{_shown(f)}°{_letter()}"
def _month_year(date_s: str) -> str: def _month_year(date_s: str) -> str:
@ -203,6 +268,10 @@ def _breadcrumb_jsonld(o: str, breadcrumb: list[tuple[str, str | None]]) -> dict
def _respond_html(request: Request, template: str, **ctx) -> Response: def _respond_html(request: Request, template: str, **ctx) -> Response:
o = origin(request) o = origin(request)
# Taken from the same ContextVar the numbers were rendered through, so the
# attribute units.js reads can't drift from what the page actually says.
# None on any page without a city -> base.html.j2 omits it entirely.
ctx.setdefault("unit_default", _UNIT.get())
html = _env.get_template(template).render(base=BASE, origin=o, base_url=f"{o}{BASE}", **ctx) html = _env.get_template(template).render(base=BASE, origin=o, base_url=f"{o}{BASE}", **ctx)
etag = f'W/"{hashlib.sha1(html.encode()).hexdigest()[:20]}"' etag = f'W/"{hashlib.sha1(html.encode()).hexdigest()[:20]}"'
inm = request.headers.get("if-none-match") inm = request.headers.get("if-none-match")
@ -484,7 +553,12 @@ def _resolve_city(slug: str):
def city_page(request: Request, slug: str) -> Response: def city_page(request: Request, slug: str) -> Response:
city, cell, history = _resolve_city(slug) city, cell, history = _resolve_city(slug)
return _respond_html(request, "city.html.j2", **_city_context(request, city, cell, history)) # The scope has to cover the context builder too, not just the render: it is
# evaluated as an argument, so it runs first — and it is where most of the
# page's temperatures are formatted.
with _unit_scope(unit_for_country(city.get("country_code"))):
return _respond_html(request, "city.html.j2",
**_city_context(request, city, cell, history))
# --- month & records pages ---------------------------------------------------- # --- month & records pages ----------------------------------------------------
@ -576,7 +650,9 @@ def month_page(request: Request, slug: str, month: str) -> Response:
if month not in MONTH_INDEX: if month not in MONTH_INDEX:
raise HTTPException(status_code=404, detail="Unknown month.") raise HTTPException(status_code=404, detail="Unknown month.")
city, cell, history = _resolve_city(slug) city, cell, history = _resolve_city(slug)
return _respond_html(request, "month.html.j2", **_month_context(request, city, history, MONTH_INDEX[month])) with _unit_scope(unit_for_country(city.get("country_code"))):
return _respond_html(request, "month.html.j2",
**_month_context(request, city, history, MONTH_INDEX[month]))
def _records_context(request, city, history) -> dict: def _records_context(request, city, history) -> dict:
@ -660,7 +736,9 @@ def _records_context(request, city, history) -> dict:
def records_page(request: Request, slug: str) -> Response: def records_page(request: Request, slug: str) -> Response:
city, cell, history = _resolve_city(slug) city, cell, history = _resolve_city(slug)
return _respond_html(request, "records.html.j2", **_records_context(request, city, history)) with _unit_scope(unit_for_country(city.get("country_code"))):
return _respond_html(request, "records.html.j2",
**_records_context(request, city, history))
# --- hub / glossary / about --------------------------------------------------- # --- hub / glossary / about ---------------------------------------------------

View file

@ -28,7 +28,7 @@
<link rel="stylesheet" href="{{ base }}/style.css" /> <link rel="stylesheet" href="{{ base }}/style.css" />
{% block jsonld %}{% endblock %} {% block jsonld %}{% endblock %}
</head> </head>
<body> <body{% if unit_default %} data-unit-default="{{ unit_default }}"{% endif %}>
<header> <header>
<div class="brand"> <div class="brand">
<div> <div>

View file

@ -1,6 +1,7 @@
"""Tests for the crawlable SEO content pages: the city set, robots/sitemap, and """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 that the server-rendered pages contain the stats in the HTML (with the weather
layer faked, like test_api.py).""" layer faked, like test_api.py)."""
import math
import re import re
import pytest import pytest
@ -55,8 +56,8 @@ def test_city_page_renders_stats_in_html(client):
assert 'rel="canonical"' in b and f"/climate/{SLUG}" in b assert 'rel="canonical"' in b and f"/climate/{SLUG}" in b
assert '"@type":"Dataset"' in b assert '"@type":"Dataset"' in b
assert "average temperatures by month" in b.lower() assert "average temperatures by month" in b.lower()
# a real number rendered (°F appears in the normals/today text) # a real number rendered in the normals/today text (London is GB, so °C)
assert "°F" in b assert re.search(r"-?\d+°C</span>", b)
def _jsonld_breadcrumbs(html: str) -> list[dict]: def _jsonld_breadcrumbs(html: str) -> list[dict]:
@ -150,10 +151,11 @@ def test_records_page_has_monthly_and_seasonal(client):
assert "Northern Hemisphere" in b assert "Northern Hemisphere" in b
assert 'Winter <span class="season-span">(DecFeb)</span>' in b assert 'Winter <span class="season-span">(DecFeb)</span>' in b
assert 'Summer <span class="season-span">(JunAug)</span>' in b assert 'Summer <span class="season-span">(JunAug)</span>' in b
# All-time table still present, plus structured data and real °F values. # All-time table still present, plus structured data and real values.
assert "All-time records" in b assert "All-time records" in b
assert '"@type":"Dataset"' in b and "°F" in b assert '"@type":"Dataset"' in b
# Title/description advertise the new coverage. assert re.search(r"-?\d+°C</span>", b)
# The Dataset structured data still advertises the month/season coverage.
assert "monthly" in b.lower() and "seasonal" in b.lower() assert "monthly" in b.lower() and "seasonal" in b.lower()
@ -244,8 +246,10 @@ def test_meta_descriptions_lead_with_real_numbers(client):
b = client.get(path).text b = client.get(path).text
desc = re.search(r'<meta name="description" content="(.*?)"', b, re.S).group(1) desc = re.search(r'<meta name="description" content="(.*?)"', b, re.S).group(1)
assert len(desc) <= 155, f"{len(desc)}: {desc}" assert len(desc) <= 155, f"{len(desc)}: {desc}"
# A real temperature, not a generic "averages, records and rainfall". # A real temperature, not a generic "averages, records and rainfall" —
assert re.search(r"-?\d+°F", desc), desc # 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
assert desc.endswith("years of local history."), desc assert desc.endswith("years of local history."), desc
# The records description names the dates the extremes were set. # The records description names the dates the extremes were set.
rec = client.get(f"{B}/climate/{SLUG}/records").text rec = client.get(f"{B}/climate/{SLUG}/records").text
@ -253,18 +257,113 @@ def test_meta_descriptions_lead_with_real_numbers(client):
assert re.search(r"\([A-Z][a-z]{2} \d{4}\)", desc), desc assert re.search(r"\([A-Z][a-z]{2} \d{4}\)", desc), desc
_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)]
def test_climate_pages_carry_convertible_temps(client): def test_climate_pages_carry_convertible_temps(client):
# Every temperature is a client-convertible span (default °F text so crawlers # Every temperature is a client-convertible span; the old inline "(NN°C)" dual
# and no-JS visitors still see a real value); the old inline "(NN°C)" dual form # form is gone (the toggle now supplies the other unit).
# is gone (the toggle now supplies Celsius).
import re
for path in (f"{B}/climate/{SLUG}", f"{B}/climate/{SLUG}/july", f"{B}/climate/{SLUG}/records"): for path in (f"{B}/climate/{SLUG}", f"{B}/climate/{SLUG}/july", f"{B}/climate/{SLUG}/records"):
b = client.get(path).text b = client.get(path).text
assert 'class="temp" data-temp-f=' in b spans = _temp_spans(b)
assert "°F" in b assert spans, f"no temperature spans on {path}"
assert re.search(r"\d+°F \(-?\d+°C\)", b) is None # no leftover dual-unit strings assert re.search(r"\d+°F \(-?\d+°C\)", b) is None # no leftover dual-unit strings
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
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
def test_seo_pages_load_unit_and_account_scripts(client): def test_seo_pages_load_unit_and_account_scripts(client):
# Header parity with the interactive pages: the °F/°C toggle, account/bell, and # Header parity with the interactive pages: the °F/°C toggle, account/bell, and
# the temperature converter are all loaded. # the temperature converter are all loaded.