diff --git a/content.py b/content.py
index 752649c..7521f21 100644
--- a/content.py
+++ b/content.py
@@ -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 (Dec–Feb is winter in the north, summer in the
+# south), so a city's latitude picks the label.
+SEASONS = [
+ ("Winter", "Summer", [12, 1, 2], "Dec–Feb"),
+ ("Spring", "Autumn", [3, 4, 5], "Mar–May"),
+ ("Summer", "Winter", [6, 7, 8], "Jun–Aug"),
+ ("Autumn", "Spring", [9, 10, 11], "Sep–Nov"),
+]
+
# 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 (Dec–Feb 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=(",", ":")),
}
diff --git a/templates/city.html.j2 b/templates/city.html.j2
index 5d88aed..048a37f 100644
--- a/templates/city.html.j2
+++ b/templates/city.html.j2
@@ -59,12 +59,30 @@
{% for m in months %}
{{ m.name }}
- {{ m.high }} {{ m.low }} {{ m.precip }}
+ {{ m.high }}
+ {{ m.low }}
+ {{ m.precip }}
{% endfor %}
+
+
+ {% for m in months %}
+
+ {{ m.name[:3] }}
+
+ {% if m.bar %} {% endif %}
+
+ {% if m.low_f is not none and m.high_f is not none %}
+ {{ m.low_f | round | int }}° – {{ m.high_f | round | int }}°
+ {% else %}— {% endif %}
+
+ {% endfor %}
+
+ Each bar spans the average daily low to high, coloured cold → hot
+ on a shared −10 °F to 115 °F scale — so the whole year's rhythm reads at a glance.
diff --git a/templates/month.html.j2 b/templates/month.html.j2
index adc2403..c4fa3c1 100644
--- a/templates/month.html.j2
+++ b/templates/month.html.j2
@@ -9,9 +9,10 @@
Weather in {{ display }} in {{ month_name }}
- In an average {{ month_name }}, {{ name }} sees a daily high around {{ avg_high }}
- and a low around {{ avg_low }} , based on {{ year_range[0] }}–{{ year_range[1] }} of local
- climate records.
+ In an average {{ month_name }}, {{ name }} sees a daily high around
+ {{ avg_high }} and a low around
+ {{ avg_low }} , based on
+ {{ year_range[0] }}–{{ year_range[1] }} of local climate records.
@@ -24,7 +25,8 @@
{% if records %}
{{ month_name }} records
- {% for label, value, date in records %}{{ label }}: {{ value }} on {{ date }} {% endfor %}
+ {% for r in records %}{{ r.label }}:
+ {{ r.value }} on {{ r.date }} {% endfor %}
{% endif %}
diff --git a/templates/records.html.j2 b/templates/records.html.j2
index 8cbb7c0..5b865da 100644
--- a/templates/records.html.j2
+++ b/templates/records.html.j2
@@ -2,6 +2,7 @@
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
+{% block jsonld %}{% endblock %}
{% block content %}
{% for label, href in breadcrumb %}{% if href %}{{ label }} {% else %}{{ label }} {% endif %}{% if not loop.last %} › {% endif %}{% endfor %}
@@ -9,28 +10,77 @@
{{ display }} weather records
- All-time record highs and lows for {{ display }}, and the dates they occurred, across
- {{ year_range[0] }}–{{ year_range[1] }} of daily climate history.
+ Record high and low temperatures for {{ display }} — 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 %}
-
- {% for r in rows %}
-
-
{{ r.label }}
-
- {% if r.label == 'Precip' %}Wettest{% else %}Highest{% endif %}
- {{ r.high }}
- {{ r.high_date }}
-
-
- {% if r.label == 'Precip' %}Driest{% else %}Lowest{% endif %}
- {{ r.low }}
- {{ r.low_date }}
-
+
+ Records by month
+ The warmest and coldest {{ name }} has ever been in each calendar month. Tap a month for its full
+ averages and typical range.
+
+
+ Month Record high On Record low On
+
+ {% for m in monthly %}
+
+ {{ m.name }}
+ {{ m.high }} {{ m.high_date }}
+ {{ m.low }} {{ m.low_date }}
+
+ {% endfor %}
+
+
- {% endfor %}
-
-
For rainfall, the “driest” figure is the longest run of consecutive
- days without measurable rain , dated to when that dry spell began.
+
+
+
+ Records by season
+ Meteorological seasons for the {{ hemisphere }} Hemisphere — each a three-month span — and the
+ most extreme {{ name }} has recorded within each.
+
+
+ Season Record high On Record low On
+
+ {% for s in seasonal %}
+
+ {{ s.name }} ({{ s.span }})
+ {{ s.high }} {{ s.high_date }}
+ {{ s.low }} {{ s.low_date }}
+
+ {% endfor %}
+
+
+
+
+
+
+ All-time records
+ The most extreme value {{ name }} has recorded for each metric across its entire history.
+
+ {% for r in rows %}
+
+
{{ r.label }}
+
+ {% if r.label == 'Precip' %}Wettest{% else %}Highest{% endif %}
+ {{ r.high }}
+ {{ r.high_date }}
+
+
+ {% if r.label == 'Precip' %}Driest{% else %}Lowest{% endif %}
+ {{ r.low }}
+ {{ r.low_date }}
+
+
+ {% endfor %}
+
+ For rainfall, the “driest” figure is the longest run of consecutive
+ days without measurable rain , dated to when that dry spell began.
+
Grade {{ name }}'s weather now →
diff --git a/tests/test_content.py b/tests/test_content.py
index b4f3c5e..b8954b0 100644
--- a/tests/test_content.py
+++ b/tests/test_content.py
@@ -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
(Dec–Feb) ' in b
+ assert 'Summer
(Jun–Aug) ' 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 Dec–Feb is summer, not winter.
+ b = client.get(f"{B}/climate/sao-paulo-br/records").text
+ assert "Southern Hemisphere" in b
+ assert 'Summer
(Dec–Feb) ' in b
+ assert 'Winter
(Jun–Aug) ' 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