Give climate/city/record pages full header parity + working °F/°C (#124)

The server-rendered SEO pages shared base.html.j2's nav but carried no JS, so
they lacked the °F/°C toggle and account/bell, and their temperatures were baked
as dual-unit strings that couldn't respond to a toggle.

- Load units.js + account.js + climate.js on base.html.j2, so every SEO page gets
  the same header as the interactive pages (nav + °F/°C toggle + account + bell).
- Emit each temperature as <span class="temp" data-temp-f> (default °F text, real
  for crawlers/no-JS); climate.js rewrites them to the active unit on load and on
  toggle. Tint classes stay pinned to absolute °F. Drops the inline "(NN°C)".
- Make account.js base-aware: derive the app base from import.meta.url so its
  api/v2 fetches and the alerts links resolve from deep /climate/{slug} URLs
  instead of 404ing. Equivalent on the depth-1 interactive pages.
- Default the unit from the browser locale for first-time visitors (no stored
  pref): en-US → °F, everyone else → °C. A stored choice always wins.
This commit is contained in:
Emi Griffith 2026-07-16 10:48:30 -07:00 committed by GitHub
parent 959a077c63
commit 90bded90b7
6 changed files with 51 additions and 16 deletions

View file

@ -14,6 +14,7 @@ import polars as pl
from fastapi import HTTPException, Request, Response from fastapi import HTTPException, Request, Response
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
from markupsafe import Markup
import cities import cities
import city_events import city_events
@ -61,11 +62,22 @@ def _c(f: float) -> int:
return round((f - 32) * 5 / 9) return round((f - 32) * 5 / 9)
def _temp(f) -> str: def _temp(f):
"""A Fahrenheit value shown in both units: '72°F (22°C)'.""" """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
(so crawlers and no-JS visitors see a real value); climate.js rewrites it to
the active unit on load and on toggle. Returns '' for a missing value."""
if f is None: if f is None:
return "" return ""
return f"{round(f)}°F ({_c(f)}°C)" return Markup('<span class="temp" data-temp-f="{:.1f}">{}°F</span>').format(f, round(f))
def _temp_bare(f):
"""Like _temp() but with no unit letter ('72°'), for the range strip where the
axis already implies the unit. Still carries data-temp-f so it converts."""
if f is None:
return ""
return Markup('<span class="temp" data-temp-f="{:.1f}" data-bare>{}°</span>').format(f, round(f))
def _fmt(metric: str, v) -> str: def _fmt(metric: str, v) -> str:
@ -119,6 +131,8 @@ def _range_bar(low_f, high_f) -> dict | None:
_env.globals["temp_class"] = temp_class _env.globals["temp_class"] = temp_class
_env.globals["temp"] = _temp
_env.globals["temp_bare"] = _temp_bare
def _month_doy(month_idx: int) -> int: def _month_doy(month_idx: int) -> int:
@ -402,10 +416,10 @@ def _month_context(request, city, history, month_idx: int) -> dict:
stats = [] stats = []
if tmax: if tmax:
stats.append(("Average high", _temp(tmax["mean"]))) stats.append(("Average high", _temp(tmax["mean"])))
stats.append(("Typical high range", f"{_temp(tmax['p10'])} to {_temp(tmax['p90'])}")) stats.append(("Typical high range", _temp(tmax['p10']) + " to " + _temp(tmax['p90'])))
if tmin: if tmin:
stats.append(("Average low", _temp(tmin["mean"]))) stats.append(("Average low", _temp(tmin["mean"])))
stats.append(("Typical low range", f"{_temp(tmin['p10'])} to {_temp(tmin['p90'])}")) stats.append(("Typical low range", _temp(tmin['p10']) + " to " + _temp(tmin['p90'])))
if precip: if precip:
stats.append(("Average daily precipitation", f"{precip['mean']:.2f} in")) stats.append(("Average daily precipitation", f"{precip['mean']:.2f} in"))
# Record high and low for every metric in this calendar month (mirrors the # Record high and low for every metric in this calendar month (mirrors the

View file

@ -23,7 +23,7 @@
within <b>&plusmn;7 days of that day-of-year</b> — a 15-day seasonal window across all years. The within <b>&plusmn;7 days of that day-of-year</b> — a 15-day seasonal window across all years. The
observed value is placed on that distribution as an <a href="{{ base }}/glossary/percentile">empirical observed value is placed on that distribution as an <a href="{{ base }}/glossary/percentile">empirical
percentile</a>, then mapped to a grade from “Below Normal” through “High” to “Near Record”.</p> percentile</a>, then mapped to a grade from “Below Normal” through “High” to “Near Record”.</p>
<p>Everything is <b>relative to the location</b>: a 60&deg;F day can be “Above Normal” in one place and <p>Everything is <b>relative to the location</b>: a {{ temp(60) }} day can be “Above Normal” in one place and
“Below Normal” in another. Precipitation is graded only among days that actually rained, so “Heavy” “Below Normal” in another. Precipitation is graded only among days that actually rained, so “Heavy”
means heavy for a rainy day <i>here</i>. See the <a href="{{ base }}/legend">grade guide</a> for the full scale.</p> means heavy for a rainy day <i>here</i>. See the <a href="{{ base }}/legend">grade guide</a> for the full scale.</p>

View file

@ -60,5 +60,10 @@
<p class="muted">Thermograph grades weather by its percentile against ~45 years of local <p class="muted">Thermograph grades weather by its percentile against ~45 years of local
climate history. Climate data via Open-Meteo (ERA5 reanalysis).</p> climate history. Climate data via Open-Meteo (ERA5 reanalysis).</p>
</footer> </footer>
<!-- Header parity with the interactive pages: the °F/°C toggle, account + bell,
and the client-side temperature converter. All self-contained modules. -->
<script type="module" src="{{ base }}/units.js"></script>
<script type="module" src="{{ base }}/account.js"></script>
<script type="module" src="{{ base }}/climate.js"></script>
</body> </body>
</html> </html>

View file

@ -76,14 +76,14 @@
{% if m.bar %}<span class="range-fill" style="left:{{ m.bar.left }}%;width:{{ m.bar.width }}%;--c1:var(--{{ m.bar.c1 }});--c2:var(--{{ m.bar.c2 }})"></span>{% endif %} {% if m.bar %}<span class="range-fill" style="left:{{ m.bar.left }}%;width:{{ m.bar.width }}%;--c1:var(--{{ m.bar.c1 }});--c2:var(--{{ m.bar.c2 }})"></span>{% endif %}
</span> </span>
{% if m.range_lo_f is not none and m.range_hi_f is not none %} {% if m.range_lo_f is not none and m.range_hi_f is not none %}
<span class="range-vals">{{ m.range_lo_f | round | int }}°–{{ m.range_hi_f | round | int }}°</span> <span class="range-vals">{{ temp_bare(m.range_lo_f) }}{{ temp_bare(m.range_hi_f) }}</span>
{% else %}<span class="range-vals">—</span>{% endif %} {% else %}<span class="range-vals">—</span>{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<p class="range-note muted">Each bar spans the 10th-percentile daily low to the 90th-percentile daily <p class="range-note muted">Each bar spans the 10th-percentile daily low to the 90th-percentile daily
high — the range most days fall within — coloured cold&nbsp;→&nbsp;hot on a shared high — the range most days fall within — coloured cold&nbsp;→&nbsp;hot on a shared
10&nbsp;°F to 115&nbsp;°F scale, so the whole year's rhythm reads at a glance.</p> {{ temp(-10) }} to {{ temp(115) }} scale, so the whole year's rhythm reads at a glance.</p>
</section> </section>
<section class="city-travel"> <section class="city-travel">
@ -97,10 +97,8 @@
<section class="climate-records"> <section class="climate-records">
<h2>Record extremes</h2> <h2>Record extremes</h2>
<ul> <ul>
{% if records.tmax %}<li><b>Hottest day on record:</b> {{ records.tmax.max | round | int }}°F {% if records.tmax %}<li><b>Hottest day on record:</b> {{ temp(records.tmax.max) }} on {{ records.tmax.max_date }}</li>{% endif %}
({{ ((records.tmax.max - 32) * 5 / 9) | round | int }}°C) on {{ records.tmax.max_date }}</li>{% endif %} {% if records.tmin %}<li><b>Coldest day on record:</b> {{ temp(records.tmin.min) }} on {{ records.tmin.min_date }}</li>{% endif %}
{% if records.tmin %}<li><b>Coldest day on record:</b> {{ records.tmin.min | round | int }}°F
({{ ((records.tmin.min - 32) * 5 / 9) | round | int }}°C) on {{ records.tmin.min_date }}</li>{% endif %}
</ul> </ul>
<p><a href="{{ base }}/climate/{{ city.slug }}/records">All {{ name }} weather records →</a></p> <p><a href="{{ base }}/climate/{{ city.slug }}/records">All {{ name }} weather records →</a></p>
</section> </section>

View file

@ -22,10 +22,8 @@
<p class="lede">Record high and low temperatures for <b>{{ display }}</b> — by month, by season, and <p class="lede">Record high and low temperatures for <b>{{ display }}</b> — by month, by season, and
all-time — with the dates they occurred, across {{ year_range[0] }}{{ year_range[1] }} of daily all-time — with the dates they occurred, across {{ year_range[0] }}{{ year_range[1] }} of daily
climate history.{% if all_time.tmax and all_time.tmin %} Its hottest day on record reached climate history.{% if all_time.tmax and all_time.tmin %} Its hottest day on record reached
{{ all_time.tmax.max | round | int }}°F ({{ ((all_time.tmax.max - 32) * 5 / 9) | round | int }}°C) {{ temp(all_time.tmax.max) }} on {{ all_time.tmax.max_date }}; its coldest fell to
on {{ all_time.tmax.max_date }}; its coldest fell to {{ temp(all_time.tmin.min) }} on {{ all_time.tmin.min_date }}.{% endif %}</p>
{{ all_time.tmin.min | round | int }}°F ({{ ((all_time.tmin.min - 32) * 5 / 9) | round | int }}°C)
on {{ all_time.tmin.min_date }}.{% endif %}</p>
<section class="climate-records-section"> <section class="climate-records-section">
<h2>Records by month</h2> <h2>Records by month</h2>

View file

@ -171,3 +171,23 @@ def test_hub_glossary_about(client):
assert client.get(f"{B}/glossary/percentile").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}/glossary/not-a-term").status_code == 404
assert client.get(f"{B}/about").status_code == 200 assert client.get(f"{B}/about").status_code == 200
def test_climate_pages_carry_convertible_temps(client):
# Every temperature is a client-convertible span (default °F text so crawlers
# and no-JS visitors still see a real value); the old inline "(NN°C)" dual form
# 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"):
b = client.get(path).text
assert 'class="temp" data-temp-f=' in b
assert "°F" in b
assert re.search(r"\d+°F \(-?\d+°C\)", b) is None # no leftover dual-unit strings
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