Give the records strip real context, and colour the explore cards (#183)

Every card in "Unusual right now" read the same: a city, "100th", "Near Record".
Nothing said what was unusual, by how much, or even whether it was hot or cold.

Cards now lead with the reading and say what it is measured against:

  Tehran, Iran
  108°F
  Near Record hot
  High temp · 99th pct · normal 97°F

- "Near Record" is the band label at BOTH ends of the scale, so a cold extreme
  and a hot one rendered identically and colour was the only thing telling them
  apart. Qualify that tier with its direction; the other tiers already read
  directionally ("Very High", "Low") and are left alone.
- Carry the ±7-day median through to the card. A percentile means little without
  the value it describes and the normal it departs from.
- Floor the percentile label into 1-99. An empirical percentile can round to 100,
  and "100th percentile" reads as a measurement error.
- Shorten the city label to "City, Country". display_name keeps admin1 when it
  differs from the city, which gave "Dar es Salaam, Dar es Salaam Region,
  Tanzania" — truncated to nonsense in a one-line card.
- Temperatures render as .temp spans and the homepage now loads climate.js, so
  the strip follows the °F/°C toggle like every other server-rendered page.

Colour: the strip cards and the explore cards take a wash and a border from
their token instead of being uniform grey, and Calendar carries the nine grade
tiers in order — it is a miniature of the calendar itself.

Text colours mix 50% band colour with the foreground. That is the most colour
they can carry and still clear 4.5:1 against the tinted card in both schemes;
the worst case is the deliberately-dark --rec-cold/--rec-hot tiers, which at the
previous 78% measured 2.33:1 in dark mode. Measured, not eyeballed: worst text
contrast is now 4.79 (light) and 4.81 (dark).
This commit is contained in:
Emi Griffith 2026-07-18 01:19:27 -07:00 committed by GitHub
parent 43e9e4994b
commit 3163586cd2
3 changed files with 123 additions and 10 deletions

View file

@ -64,6 +64,28 @@ def _seasonal_window_label(d: datetime.date) -> str:
return f"{part}-{d.strftime('%B')}"
def _pct_label(pct: float) -> str:
"""A percentile as an ordinal, clamped to 1..99.
An empirical percentile can round to 100 (or 0), and "100th percentile" reads
as a measurement error rather than "as hot as it has ever been". Flooring into
the real 1-99 range keeps it honest the value and the band label carry the
"how extreme" part anyway.
"""
n = min(99, max(1, int(round(pct))))
suffix = "th" if 10 <= n % 100 <= 20 else {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
return f"{n}{suffix}"
def _grade_label(grade: str | None, tail: str) -> str:
"""The band label, disambiguated when it is the same at both ends."""
if not grade:
return ""
if grade == "Near Record":
return f"Near Record {'cold' if tail == 'cold' else 'hot'}"
return grade
def _grade_city(city: dict, today: datetime.date) -> dict | None:
"""Grade one city's latest observed day from cache alone. None when the city
has no warm cache, no observed row, or nothing gradeable."""
@ -103,10 +125,18 @@ def _grade_city(city: dict, today: datetime.date) -> dict | None:
departure, metric, g = best
date = row["date"]
date_s = date.isoformat() if hasattr(date, "isoformat") else str(date)
pct = g["percentile"]
tail = "cold" if pct < 50 else "warm"
# The median for this metric's ±7-day window — "104°F, normal 97°F" is the
# line that makes a percentile mean something.
normals = (graded.get("normals") or {}).get(metric) or {}
return {
"slug": city["slug"],
"name": city["name"],
"display": cities.display_name(city),
# "Tehran, Iran", not "Dar es Salaam, Dar es Salaam Region, Tanzania":
# the card is one line wide and the region almost always restates the
# city on the metros we track.
"display": f"{city['name']}, {city['country']}" if city.get("country") else city["name"],
"lat": round(city["lat"], 4),
"lon": round(city["lon"], 4),
"cell_id": cell["id"],
@ -117,13 +147,20 @@ def _grade_city(city: dict, today: datetime.date) -> dict | None:
date if hasattr(date, "timetuple") else today
),
"value": g.get("value"),
"percentile": g.get("percentile"),
"normal": normals.get("p50"),
"percentile": pct,
"pct_label": _pct_label(pct),
"grade": g.get("grade"),
# "Near Record" is the band label at BOTH ends of the scale, so on its own
# a card can't say whether it's a hot or a cold extreme — colour would be
# the only signal. Qualify it; the other tiers already read directionally
# ("Very High", "Low"), so they're left alone.
"grade_label": _grade_label(g.get("grade"), tail),
# grading's css class is already the style.css token name minus the
# leading '--', so the template emits it with no mapping table.
"cls": g.get("class"),
"departure": round(departure, 1),
"tail": "cold" if g["percentile"] < 50 else "warm",
"tail": tail,
}

View file

@ -115,8 +115,14 @@
<a href="{{ base }}/day#lat={{ r.lat }}&amp;lon={{ r.lon }}"
data-event="home.records_click">
<span class="unusual-city">{{ r.display }}</span>
<span class="unusual-pct">{{ r.percentile|ordinal }}</span>
<span class="unusual-band">{{ r.grade }}</span>
{# The reading itself, then what it is and what it should be —
a percentile means little without the value it describes. #}
<span class="unusual-value">{{ temp(r.value) }}</span>
<span class="unusual-band">{{ r.grade_label }}</span>
<span class="unusual-meta">
{{ r.metric_label }} &middot; {{ r.pct_label }} pct
{%- if r.normal is not none %} &middot; normal {{ temp(r.normal) }}{% endif %}
</span>
</a>
</li>
{% endfor %}
@ -142,20 +148,32 @@
{# --- Explore --------------------------------------------------------- #}
<section class="explore" aria-labelledby="explore-title">
<h2 id="explore-title">Explore</h2>
{# Each card takes a colour from the grade ramp rather than a decorative
palette, so the section reads as part of the product's own scale.
Calendar carries the ramp itself — it IS the heatmap. #}
<div class="explore-cards">
<a class="explore-card" href="{{ base }}/calendar" data-event="home.nav_calendar">
<a class="explore-card" href="{{ base }}/calendar" data-event="home.nav_calendar"
style="--cat: var(--warm)">
<span class="explore-name">Calendar</span>
<span class="explore-ramp" aria-hidden="true">
{% for t in ('rec-cold','very-cold','cold','cool','normal','warm','hot','very-hot','rec-hot') %}
<i style="background: var(--{{ t }})"></i>
{% endfor %}
</span>
<span class="explore-desc">Two years of your days, each colored by how unusual it was.</span>
</a>
<a class="explore-card" href="{{ base }}/compare" data-event="home.nav_compare">
<a class="explore-card" href="{{ base }}/compare" data-event="home.nav_compare"
style="--cat: var(--normal)">
<span class="explore-name">Compare</span>
<span class="explore-desc">Rank any places by comfort for a trip or a move.</span>
</a>
<a class="explore-card" href="{{ base }}/day" data-event="home.nav_day">
<a class="explore-card" href="{{ base }}/day" data-event="home.nav_day"
style="--cat: var(--cool)">
<span class="explore-name">Day</span>
<span class="explore-desc">Any date since 1980, anywhere: what was normal, what happened.</span>
</a>
<a class="explore-card" href="{{ base }}/alerts" data-event="home.nav_alerts">
<a class="explore-card" href="{{ base }}/alerts" data-event="home.nav_alerts"
style="--cat: var(--rec-hot)">
<span class="explore-name">Alerts</span>
<span class="explore-desc">Free percentile alerts: get pinged when your weather goes statistically weird.</span>
</a>
@ -179,4 +197,8 @@
{% block body_scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script type="module" src="{{ base }}/app.js"></script>
{# The records strip renders real temperatures server-side as .temp spans, so
it needs the same converter the SEO pages use or they'd stay in °F while
the toggle says °C. Both import units.js, which the module loader dedupes. #}
<script type="module" src="{{ base }}/climate.js"></script>
{% endblock %}

View file

@ -41,7 +41,9 @@ def _pick(name, pct, cls, grade, tail, slug=None):
"lat": 1.0, "lon": 2.0, "cell_id": "1_2",
"date": datetime.date.today().isoformat(),
"metric": "tmax", "metric_label": "High temp", "window_label": "mid-July",
"value": 99.0, "percentile": pct, "grade": grade, "cls": cls,
"value": 99.0, "normal": 88.0, "percentile": pct,
"pct_label": homepage._pct_label(pct),
"grade": grade, "grade_label": homepage._grade_label(grade, tail), "cls": cls,
"departure": abs(pct - 50), "tail": tail,
}
@ -67,6 +69,39 @@ def test_build_grades_cached_cities(monkeypatch, history, recent):
assert row["percentile"] is not None
assert row["cls"] and not row["cls"].startswith("--") # token name, no prefix
assert row["tail"] in ("warm", "cold")
# The card needs the reading itself and something to compare it against —
# a percentile alone says nothing about what the weather actually is.
assert row["value"] is not None
assert row["normal"] is not None, "the ±7-day median must reach the card"
assert row["metric_label"] in ("High temp", "Low temp")
assert row["pct_label"].endswith(("st", "nd", "rd", "th"))
# "Tehran, Iran", not "Dar es Salaam, Dar es Salaam Region, Tanzania".
assert row["display"].count(",") <= 1
def test_percentile_label_never_reads_100th_or_0th():
"""An empirical percentile can round to 100 (or 0). "100th percentile" reads
as a bug rather than "as hot as it has ever been", so the label floors into
the real 1-99 range."""
assert homepage._pct_label(99.6) == "99th"
assert homepage._pct_label(100.0) == "99th"
assert homepage._pct_label(0.4) == "1st"
assert homepage._pct_label(0.0) == "1st"
# The ordinary teens/ones rules still apply in between.
assert homepage._pct_label(11) == "11th"
assert homepage._pct_label(22) == "22nd"
assert homepage._pct_label(3) == "3rd"
def test_near_record_is_disambiguated_by_tail():
""""Near Record" is the band label at BOTH ends of the scale. Unqualified, a
cold extreme and a hot one render identically and colour becomes the only
signal which the design rules forbid."""
assert homepage._grade_label("Near Record", "warm") == "Near Record hot"
assert homepage._grade_label("Near Record", "cold") == "Near Record cold"
# Tiers that already read directionally are left alone.
assert homepage._grade_label("Very High", "warm") == "Very High"
assert homepage._grade_label("Low", "cold") == "Low"
def test_missing_cell_cache_is_skipped_not_fatal(monkeypatch):
@ -184,6 +219,25 @@ def test_records_strip_shows_both_tails(client, monkeypatch):
assert "Near Record" in html and "Very Low" in html
def test_strip_cards_carry_value_metric_and_normal(client, monkeypatch):
feed = {
"generated_at": time.time(), "date": datetime.date.today().isoformat(),
"considered": 800, "picks": {},
"ranked": [_pick("Tehran", 99.6, "rec-hot", "Near Record", "warm"),
_pick("Ushuaia", 0.4, "rec-cold", "Near Record", "cold")],
}
monkeypatch.setattr(homepage, "load", lambda: feed)
html = client.get(f"{B}/").text
# Both tails must be tellable apart in WORDS, not just by colour.
assert "Near Record hot" in html and "Near Record cold" in html
assert "normal" in html and "High temp" in html
assert "100th" not in html and "0th" not in html
# Temperatures render as convertible spans, and the converter is loaded, or
# the strip would stay in °F while the °F/°C toggle says °C.
assert 'class="temp" data-temp-f' in html
assert "climate.js" in html
def test_hero_uses_most_unusual_default(client, monkeypatch):
feed = {
"generated_at": time.time(), "date": datetime.date.today().isoformat(),