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:
parent
959a077c63
commit
90bded90b7
6 changed files with 51 additions and 16 deletions
24
content.py
24
content.py
|
|
@ -14,6 +14,7 @@ import polars as pl
|
|||
from fastapi import HTTPException, Request, Response
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from markupsafe import Markup
|
||||
|
||||
import cities
|
||||
import city_events
|
||||
|
|
@ -61,11 +62,22 @@ def _c(f: float) -> int:
|
|||
return round((f - 32) * 5 / 9)
|
||||
|
||||
|
||||
def _temp(f) -> str:
|
||||
"""A Fahrenheit value shown in both units: '72°F (22°C)'."""
|
||||
def _temp(f):
|
||||
"""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:
|
||||
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:
|
||||
|
|
@ -119,6 +131,8 @@ def _range_bar(low_f, high_f) -> dict | None:
|
|||
|
||||
|
||||
_env.globals["temp_class"] = temp_class
|
||||
_env.globals["temp"] = _temp
|
||||
_env.globals["temp_bare"] = _temp_bare
|
||||
|
||||
|
||||
def _month_doy(month_idx: int) -> int:
|
||||
|
|
@ -402,10 +416,10 @@ def _month_context(request, city, history, month_idx: int) -> dict:
|
|||
stats = []
|
||||
if tmax:
|
||||
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:
|
||||
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:
|
||||
stats.append(("Average daily precipitation", f"{precip['mean']:.2f} in"))
|
||||
# Record high and low for every metric in this calendar month (mirrors the
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
within <b>±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
|
||||
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°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”
|
||||
means heavy for a rainy day <i>here</i>. See the <a href="{{ base }}/legend">grade guide</a> for the full scale.</p>
|
||||
|
||||
|
|
|
|||
|
|
@ -60,5 +60,10 @@
|
|||
<p class="muted">Thermograph grades weather by its percentile against ~45 years of local
|
||||
climate history. Climate data via Open-Meteo (ERA5 reanalysis).</p>
|
||||
</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>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
</span>
|
||||
{% 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 %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<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 → hot on a shared
|
||||
−10 °F to 115 °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 class="city-travel">
|
||||
|
|
@ -97,10 +97,8 @@
|
|||
<section class="climate-records">
|
||||
<h2>Record extremes</h2>
|
||||
<ul>
|
||||
{% if records.tmax %}<li><b>Hottest day on record:</b> {{ records.tmax.max | round | int }}°F
|
||||
({{ ((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> {{ records.tmin.min | round | int }}°F
|
||||
({{ ((records.tmin.min - 32) * 5 / 9) | round | int }}°C) on {{ records.tmin.min_date }}</li>{% endif %}
|
||||
{% if records.tmax %}<li><b>Hottest day on record:</b> {{ temp(records.tmax.max) }} 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 %}
|
||||
</ul>
|
||||
<p><a href="{{ base }}/climate/{{ city.slug }}/records">All {{ name }} weather records →</a></p>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -22,10 +22,8 @@
|
|||
<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
|
||||
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)
|
||||
on {{ all_time.tmax.max_date }}; its coldest fell to
|
||||
{{ 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>
|
||||
{{ temp(all_time.tmax.max) }} on {{ all_time.tmax.max_date }}; its coldest fell to
|
||||
{{ temp(all_time.tmin.min) }} on {{ all_time.tmin.min_date }}.{% endif %}</p>
|
||||
|
||||
<section class="climate-records-section">
|
||||
<h2>Records by month</h2>
|
||||
|
|
|
|||
|
|
@ -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/not-a-term").status_code == 404
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue