SEO: monthly & seasonal records + colour-coded climate pages (#107)

* SEO: monthly & seasonal records on the city records page

The /climate/<city>/records page showed only all-time records per metric.
Expand it into a full records page: record high/low for each of the 12
months (each linking its month page) and for each meteorological season,
alongside the existing all-time table.

Seasons are hemisphere-aware — Dec–Feb reads as winter for northern cities
and summer for southern ones (picked from the city's latitude). Records
reuse grading.all_time_records over a month-filtered archive, so no new
data fetching or warming is needed. Adds a Dataset + breadcrumb JSON-LD
block and richer title/description for the expanded coverage.

* SEO: colour-code climate & records pages (heat-map + range strip)

The city, records and month pages were plain muted tables — generic climate-site
styling. Bring the interactive grader's diverging cold→hot palette onto them so
they read as heat maps in the site's own visual language:

- Map absolute °F to the 9-tier palette (temp_class, a Jinja global) and tint the
  temperature cells across the monthly-normals, monthly/seasonal/all-time records
  tables. The value stays in the ink token; the tint is a wash behind it. Non-temp
  rows (humidity/wind/precip) and date columns stay untinted.
- Add a monthly temperature-range strip on the city page: one gradient bar per
  month spanning the average low→high on a shared −10..115°F axis, so a city's
  whole-year rhythm (and hot-vs-cold character) reads at a glance.
- Tint the month page's hero high/low and its record values inline.

Palette, light/dark, and 390–1920px layouts verified by rendering hot (Phoenix)
and cold (Anchorage) cities.
This commit is contained in:
Emi Griffith 2026-07-15 20:50:59 -07:00 committed by GitHub
parent d24049a78d
commit 4e8d1e9e59
5 changed files with 276 additions and 34 deletions

View file

@ -38,6 +38,17 @@ MONTHS = ["january", "february", "march", "april", "may", "june",
MONTHS_TITLE = [m.capitalize() for m in MONTHS]
MONTH_INDEX = {m: i + 1 for i, m in enumerate(MONTHS)}
# Meteorological seasons as (northern-hemisphere label, southern-hemisphere label,
# month numbers, span text). The same three months are one season everywhere — only
# the name flips across the equator (DecFeb is winter in the north, summer in the
# south), so a city's latitude picks the label.
SEASONS = [
("Winter", "Summer", [12, 1, 2], "DecFeb"),
("Spring", "Autumn", [3, 4, 5], "MarMay"),
("Summer", "Winter", [6, 7, 8], "JunAug"),
("Autumn", "Spring", [9, 10, 11], "SepNov"),
]
# Display labels for the graded metrics (order = how they appear on the page).
METRIC_LABELS = [
("tmax", "High"), ("tmin", "Low"), ("feels", "Feels-like"),
@ -69,6 +80,47 @@ def _fmt(metric: str, v) -> str:
return f"{round(v)} mph" # wind, gust
# Absolute-temperature colour tiers (°F upper bounds) mapped to the site's diverging
# cold→hot palette — the same 9 tiers the interactive grader uses. Colouring records
# and normals by these turns the tables into a heat map in the site's own visual
# language, rather than a plain grid.
_TEMP_TIERS = [
(20, "rec-cold"), (32, "very-cold"), (45, "cold"), (58, "cool"),
(70, "normal"), (80, "warm"), (90, "hot"), (100, "very-hot"),
]
# °F axis for the monthly temperature-range strip on the city page.
_AXIS_LO, _AXIS_HI = -10.0, 115.0
def temp_class(f) -> str:
"""Diverging-palette tier name for an absolute Fahrenheit temperature (or 'none')."""
if f is None:
return "none"
for upper, cls in _TEMP_TIERS:
if f < upper:
return cls
return "rec-hot"
def _range_bar(low_f, high_f) -> dict | None:
"""Geometry for one month's low→high bar on the shared _AXIS_LO.._AXIS_HI axis:
left offset + width as percentages, and the tier colour at each end (for a
gradient fill). None when a value is missing."""
if low_f is None or high_f is None:
return None
lo = max(_AXIS_LO, min(_AXIS_HI, low_f))
hi = max(_AXIS_LO, min(_AXIS_HI, high_f))
span = _AXIS_HI - _AXIS_LO
return {
"left": round((lo - _AXIS_LO) / span * 100, 1),
"width": round(max(2.0, (hi - lo) / span * 100), 1),
"c1": temp_class(low_f), "c2": temp_class(high_f),
}
_env.globals["temp_class"] = temp_class
def _month_doy(month_idx: int) -> int:
return datetime.date(2001, month_idx, 15).timetuple().tm_yday
@ -152,19 +204,61 @@ def _monthly_normals(history) -> list[dict]:
for i, name in enumerate(MONTHS_TITLE, start=1):
clim = grading.climatology(history, _month_doy(i))
tmax, tmin, precip = clim.get("tmax"), clim.get("tmin"), clim.get("precip")
high_f = tmax["mean"] if tmax else None
low_f = tmin["mean"] if tmin else None
rows.append({
"name": name,
"slug": MONTHS[i - 1],
"high": _temp(tmax["mean"]) if tmax else "",
"high_f": tmax["mean"] if tmax else None,
"high_f": high_f,
"low": _temp(tmin["mean"]) if tmin else "",
"low_f": tmin["mean"] if tmin else None,
"low_f": low_f,
"precip": f"{precip['mean']:.2f} in" if precip else "",
"precip_v": precip["mean"] if precip else None,
"bar": _range_bar(low_f, high_f),
})
return rows
def _period_records(history, months: list[int]) -> dict:
"""Record high (tmax) and record low (tmin), with the dates they occurred, over a
set of calendar months the shared primitive behind the monthly and seasonal
records tables. Reuses grading.all_time_records on the month-filtered archive."""
if len(months) == 1:
sub = history.filter(pl.col("date").dt.month() == months[0])
else:
sub = history.filter(pl.col("date").dt.month().is_in(months))
rec = grading.all_time_records(sub) if not sub.is_empty() else {}
tmax, tmin = rec.get("tmax"), rec.get("tmin")
return {
"high": _temp(tmax["max"]) if tmax else "",
"high_f": tmax["max"] if tmax else None,
"high_date": tmax["max_date"] if tmax else "",
"low": _temp(tmin["min"]) if tmin else "",
"low_f": tmin["min"] if tmin else None,
"low_date": tmin["min_date"] if tmin else "",
}
def _monthly_records(history) -> list[dict]:
"""Record high and low for each of the 12 months (each row links to its month page)."""
return [
{"name": name, "slug": MONTHS[i - 1], **_period_records(history, [i])}
for i, name in enumerate(MONTHS_TITLE, start=1)
]
def _seasonal_records(history, lat: float) -> list[dict]:
"""Record high and low for each meteorological season, labelled for the city's
hemisphere (DecFeb reads as winter north of the equator, summer south of it)."""
south = lat < 0
return [
{"name": (south_lbl if south else north_lbl), "span": span,
**_period_records(history, months)}
for north_lbl, south_lbl, months, span in SEASONS
]
def _today_vs_normal(history, cell) -> dict | None:
"""Grade the latest recorded day against its climatology, for the hero block."""
try:
@ -302,9 +396,11 @@ def _month_context(request, city, history, month_idx: int) -> dict:
stats.append(("Average daily precipitation", f"{precip['mean']:.2f} in"))
records = []
if mrec.get("tmax"):
records.append(("Warmest on record", _temp(mrec["tmax"]["max"]), mrec["tmax"]["max_date"]))
records.append({"label": "Warmest on record", "value": _temp(mrec["tmax"]["max"]),
"date": mrec["tmax"]["max_date"], "cls": temp_class(mrec["tmax"]["max"])})
if mrec.get("tmin"):
records.append(("Coldest on record", _temp(mrec["tmin"]["min"]), mrec["tmin"]["min_date"]))
records.append({"label": "Coldest on record", "value": _temp(mrec["tmin"]["min"]),
"date": mrec["tmin"]["min_date"], "cls": temp_class(mrec["tmin"]["min"])})
prev_i = 12 if month_idx == 1 else month_idx - 1
next_i = 1 if month_idx == 12 else month_idx + 1
@ -316,6 +412,8 @@ def _month_context(request, city, history, month_idx: int) -> dict:
"year_range": year_range, "n_years": year_range[1] - year_range[0],
"avg_high": _temp(tmax["mean"]) if tmax else "",
"avg_low": _temp(tmin["mean"]) if tmin else "",
"avg_high_cls": temp_class(tmax["mean"]) if tmax else "none",
"avg_low_cls": temp_class(tmin["mean"]) if tmin else "none",
"stats": stats, "records": records,
"tool_hash": f"{city['lat']:.5f},{city['lon']:.5f}",
"compare_url": f"{BASE}/compare#loc={city['lat']:.4f},{city['lon']:.4f}",
@ -341,6 +439,7 @@ def _records_context(request, city, history) -> dict:
name = city["name"]
years = history["date"].dt.year()
year_range = [int(years.min()), int(years.max())]
n_years = year_range[1] - year_range[0]
rec = grading.all_time_records(history)
rows = []
for key, label in METRIC_LABELS:
@ -355,22 +454,56 @@ def _records_context(request, city, history) -> dict:
low_date = dry_start or ""
else:
low, low_date = _fmt(key, r["min"]), r["min_date"]
is_temp = key in _TEMP_METRICS
rows.append({
"label": label,
"high": _fmt(key, r["max"]), "high_date": r["max_date"],
"high_f": r["max"] if is_temp else None,
"low": low, "low_date": low_date,
"low_f": r["min"] if is_temp else None,
})
monthly = _monthly_records(history)
seasonal = _seasonal_records(history, city["lat"])
hemisphere = "Southern" if city["lat"] < 0 else "Northern"
breadcrumb = [("Home", f"{BASE}/"), ("Climate", f"{BASE}/climate"),
(name, f"{BASE}/climate/{city['slug']}"), ("Records", None)]
o = origin(request)
page_url = f"{o}{BASE}/climate/{city['slug']}/records"
jsonld = {
"@context": "https://schema.org",
"@graph": [
{"@type": "Dataset",
"name": f"{display} monthly and seasonal weather records",
"description": f"Record high and low temperatures for {display} by month and by "
f"meteorological season, with the dates they occurred, from ~{n_years} "
f"years of daily climate history.",
"url": page_url,
"temporalCoverage": f"{year_range[0]}/{year_range[1]}",
"spatialCoverage": {"@type": "Place", "name": display,
"geo": {"@type": "GeoCoordinates",
"latitude": city["lat"], "longitude": city["lon"]}},
"creator": {"@type": "Organization", "name": "Thermograph"},
"isBasedOn": "https://open-meteo.com/ (ERA5 reanalysis)"},
{"@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": i + 1, "name": nm,
**({"item": f"{o}{href}"} if href else {})}
for i, (nm, href) in enumerate(breadcrumb)]},
],
}
return {
"section": "climate", "city": city, "display": display, "name": name,
"year_range": year_range, "n_years": year_range[1] - year_range[0],
"rows": rows,
"year_range": year_range, "n_years": n_years,
"rows": rows, "monthly": monthly, "seasonal": seasonal,
"hemisphere": hemisphere, "all_time": rec,
"canonical_path": f"/climate/{city['slug']}/records",
"breadcrumb": breadcrumb,
"page_title": f"{display} weather records: hottest and coldest days on record",
"page_description": (f"All-time record high and low temperatures (and the dates they occurred) for "
f"{display}, from ~{year_range[1] - year_range[0]} years of daily climate history."),
"page_title": f"{display} weather records: monthly & seasonal record highs and lows",
"page_description": (f"Record high and low temperatures for {display} by month and by season, with the "
f"dates they occurred, plus all-time extremes — from ~{n_years} years of daily "
f"climate history."),
"jsonld_str": json.dumps(jsonld, ensure_ascii=False, separators=(",", ":")),
}

View file

@ -59,12 +59,30 @@
{% for m in months %}
<tr>
<td><a href="{{ base }}/climate/{{ city.slug }}/{{ m.slug }}">{{ m.name }}</a></td>
<td>{{ m.high }}</td><td>{{ m.low }}</td><td>{{ m.precip }}</td>
<td class="t-cell t-{{ temp_class(m.high_f) }}">{{ m.high }}</td>
<td class="t-cell t-{{ temp_class(m.low_f) }}">{{ m.low }}</td>
<td>{{ m.precip }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="range-strip" aria-hidden="true">
{% for m in months %}
<div class="range-row">
<span class="range-month">{{ m.name[:3] }}</span>
<span class="range-track">
{% 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.low_f is not none and m.high_f is not none %}
<span class="range-vals">{{ m.low_f | round | int }}° {{ m.high_f | round | int }}°</span>
{% else %}<span class="range-vals">—</span>{% endif %}
</div>
{% endfor %}
</div>
<p class="range-note muted">Each bar spans the average daily low to high, 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>
</section>
<section class="city-travel">

View file

@ -9,9 +9,10 @@
<article class="climate-page">
<h1>Weather in {{ display }} in {{ month_name }}</h1>
<p class="lede">In an average {{ month_name }}, {{ name }} sees a daily high around <b>{{ avg_high }}</b>
and a low around <b>{{ avg_low }}</b>, based on {{ year_range[0] }}{{ year_range[1] }} of local
climate records.</p>
<p class="lede">In an average {{ month_name }}, {{ name }} sees a daily high around
<b class="t-inline t-{{ avg_high_cls }}">{{ avg_high }}</b> and a low around
<b class="t-inline t-{{ avg_low_cls }}">{{ avg_low }}</b>, based on
{{ year_range[0] }}{{ year_range[1] }} of local climate records.</p>
<table class="normals-table" style="max-width:520px">
<tbody>
@ -24,7 +25,8 @@
{% if records %}
<h2>{{ month_name }} records</h2>
<ul class="climate-records">
{% for label, value, date in records %}<li><b>{{ label }}:</b> {{ value }} on {{ date }}</li>{% endfor %}
{% for r in records %}<li><b>{{ r.label }}:</b>
<span class="t-inline t-{{ r.cls }}">{{ r.value }}</span> on {{ r.date }}</li>{% endfor %}
</ul>
{% endif %}

View file

@ -2,6 +2,7 @@
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block jsonld %}<script type="application/ld+json">{{ jsonld_str | safe }}</script>{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
@ -9,28 +10,77 @@
<article class="climate-page">
<h1>{{ display }} weather records</h1>
<p class="lede">All-time record highs and lows for {{ display }}, and the dates they occurred, across
{{ year_range[0] }}{{ year_range[1] }} of daily climate history.</p>
<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>
<div class="records-grid">
{% for r in rows %}
<div class="record-card">
<h2 class="rc-metric">{{ r.label }}</h2>
<div class="rc-row rc-high">
<span class="rc-tag">{% if r.label == 'Precip' %}Wettest{% else %}Highest{% endif %}</span>
<span class="rc-val">{{ r.high }}</span>
<span class="rc-date">{{ r.high_date }}</span>
</div>
<div class="rc-row rc-low">
<span class="rc-tag">{% if r.label == 'Precip' %}Driest{% else %}Lowest{% endif %}</span>
<span class="rc-val">{{ r.low }}</span>
<span class="rc-date">{{ r.low_date }}</span>
</div>
<section class="climate-records-section">
<h2>Records by month</h2>
<p>The warmest and coldest {{ name }} has ever been in each calendar month. Tap a month for its full
averages and typical range.</p>
<div class="table-wrap">
<table class="normals-table records-table">
<thead><tr><th>Month</th><th>Record high</th><th>On</th><th>Record low</th><th>On</th></tr></thead>
<tbody>
{% for m in monthly %}
<tr>
<td><a href="{{ base }}/climate/{{ city.slug }}/{{ m.slug }}">{{ m.name }}</a></td>
<td class="t-cell t-{{ temp_class(m.high_f) }}">{{ m.high }}</td><td class="rec-date">{{ m.high_date }}</td>
<td class="t-cell t-{{ temp_class(m.low_f) }}">{{ m.low }}</td><td class="rec-date">{{ m.low_date }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
</div>
<p class="records-note muted">For rainfall, the “driest” figure is the <b>longest run of consecutive
days without measurable rain</b>, dated to when that dry spell began.</p>
</section>
<section class="climate-records-section">
<h2>Records by season</h2>
<p>Meteorological seasons for the {{ hemisphere }} Hemisphere — each a three-month span — and the
most extreme {{ name }} has recorded within each.</p>
<div class="table-wrap">
<table class="normals-table records-table">
<thead><tr><th>Season</th><th>Record high</th><th>On</th><th>Record low</th><th>On</th></tr></thead>
<tbody>
{% for s in seasonal %}
<tr>
<td>{{ s.name }} <span class="season-span">({{ s.span }})</span></td>
<td class="t-cell t-{{ temp_class(s.high_f) }}">{{ s.high }}</td><td class="rec-date">{{ s.high_date }}</td>
<td class="t-cell t-{{ temp_class(s.low_f) }}">{{ s.low }}</td><td class="rec-date">{{ s.low_date }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="climate-records-section">
<h2>All-time records</h2>
<p>The most extreme value {{ name }} has recorded for each metric across its entire history.</p>
<div class="records-grid">
{% for r in rows %}
<div class="record-card">
<h3 class="rc-metric">{{ r.label }}</h3>
<div class="rc-row rc-high">
<span class="rc-tag">{% if r.label == 'Precip' %}Wettest{% else %}Highest{% endif %}</span>
<span class="rc-val">{{ r.high }}</span>
<span class="rc-date">{{ r.high_date }}</span>
</div>
<div class="rc-row rc-low">
<span class="rc-tag">{% if r.label == 'Precip' %}Driest{% else %}Lowest{% endif %}</span>
<span class="rc-val">{{ r.low }}</span>
<span class="rc-date">{{ r.low_date }}</span>
</div>
</div>
{% endfor %}
</div>
<p class="records-note muted">For rainfall, the “driest” figure is the <b>longest run of consecutive
days without measurable rain</b>, dated to when that dry spell began.</p>
</section>
<p><a class="cta" href="{{ base }}/#{{ '%.5f,%.5f' | format(city.lat, city.lon) }}">Grade {{ name }}'s weather now →</a></p>
<p class="climate-foot muted"><a href="{{ base }}/climate/{{ city.slug }}"> Back to {{ name }} climate</a></p>

View file

@ -100,6 +100,45 @@ def test_month_and_records(client):
assert client.get(f"{B}/climate/{SLUG}/notamonth").status_code == 404
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">(DecFeb)</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.
assert "All-time records" in b
assert '"@type":"Dataset"' in b and "°F" in b
# Title/description advertise the new coverage.
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 DecFeb 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">(DecFeb)</span>' in b
assert 'Winter <span class="season-span">(JunAug)</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
assert "t-cell t-" in recs
month = client.get(f"{B}/climate/{SLUG}/july").text
assert "t-inline t-" in month # tinted hero numbers + records
def test_hub_glossary_about(client):
assert client.get(f"{B}/climate").status_code == 200
assert client.get(f"{B}/glossary").status_code == 200