diff --git a/content.py b/content.py
index fc31e2d..844c6c4 100644
--- a/content.py
+++ b/content.py
@@ -69,6 +69,11 @@ _TEMP_METRICS = {"tmax", "tmin", "feels"}
F_COUNTRIES = frozenset({"US", "PR", "GU", "VI", "AS", "MP", "UM",
"BS", "BZ", "KY", "PW", "FM", "MH", "LR"})
+# One flag drives every measure: a °C page also gets mm and km/h. Mirrors the same
+# decision in frontend/units.js.
+MM_PER_IN = 25.4
+KMH_PER_MPH = 1.609344
+
# The unit the current page renders temperatures in. None = °F, and also means
# "this page has no city", which is what keeps the interactive pages on the
# client-side locale default instead of being pinned server-side.
@@ -156,6 +161,38 @@ def _temp_text(f) -> str:
return f"{_shown(f)}°{_letter()}"
+def _precip(v):
+ """Precipitation as a client-convertible span, the same contract as _temp():
+ text in the page's unit, data-precip-in always inches."""
+ if v is None:
+ return "—"
+ return Markup('{}').format(
+ v, _precip_text(v))
+
+
+def _precip_text(v) -> str:
+ """Millimetres get one decimal and inches two — 0.04 in and 1.0 mm are the same
+ amount, so '1.02 mm' would advertise precision the source doesn't have."""
+ if v is None:
+ return "—"
+ return f"{v * MM_PER_IN:.1f} mm" if _UNIT.get() == "C" else f"{v:.2f} in"
+
+
+def _wind(v):
+ """Wind/gust as a client-convertible span; data-wind-mph is always mph."""
+ if v is None:
+ return "—"
+ return Markup('{}').format(
+ v, _wind_text(v))
+
+
+def _wind_text(v) -> str:
+ if v is None:
+ return "—"
+ return (f"{_round_half_up(v * KMH_PER_MPH)} km/h" if _UNIT.get() == "C"
+ else f"{_round_half_up(v)} mph")
+
+
def _month_year(date_s: str) -> str:
"""'1995-07-13' -> 'Jul 1995'. Meta descriptions have ~155 characters to
spend, so a record's date gives up its day."""
@@ -186,10 +223,12 @@ def _fmt(metric: str, v) -> str:
if metric in _TEMP_METRICS:
return _temp(v)
if metric == "precip":
- return f"{v:.2f} in"
+ return _precip(v)
if metric == "humid":
+ # Absolute humidity is g/m³ in both systems — there is no imperial unit for
+ # it anyone would recognise — so it is never converted.
return f"{v:.1f} g/m³"
- return f"{round(v)} mph" # wind, gust
+ return _wind(v) # wind, gust
# Absolute-temperature colour tiers (°F upper bounds) mapped to the site's diverging
@@ -390,7 +429,7 @@ def _monthly_normals(history) -> list[dict]:
"low_f": low_f,
"range_lo_f": rng_lo,
"range_hi_f": rng_hi,
- "precip": f"{precip['mean']:.2f} in" if precip else "—",
+ "precip": _precip(precip["mean"]) if precip else "—",
"precip_v": precip["mean"] if precip else None,
"bar": _range_bar(rng_lo, rng_hi),
})
@@ -584,7 +623,7 @@ def _month_context(request, city, history, month_idx: int) -> dict:
stats.append(("Average low", _temp(tmin["mean"])))
stats.append(("Typical low range", _temp(tmin['p10']) + " to " + _temp(tmin['p90'])))
if precip:
- stats.append(("Average daily precipitation", f"{precip['mean']:.2f} in"))
+ stats.append(("Average daily precipitation", _precip(precip["mean"])))
# Record high and low for every metric in this calendar month (mirrors the
# all-time records cards, but scoped to the month). Precip is special-cased:
# a per-day "record low" is just zero, and the dry-streak helper would wrongly
@@ -609,8 +648,8 @@ def _month_context(request, city, history, month_idx: int) -> dict:
lo, hi = totals.row(0, named=True), totals.row(totals.height - 1, named=True)
records.append({
"label": label,
- "high": f"{hi['total']:.1f} in", "high_date": str(hi["yr"]), "high_tag": "Wettest",
- "low": f"{lo['total']:.1f} in", "low_date": str(lo["yr"]), "low_tag": "Driest",
+ "high": _precip(hi["total"]), "high_date": str(hi["yr"]), "high_tag": "Wettest",
+ "low": _precip(lo["total"]), "low_date": str(lo["yr"]), "low_tag": "Driest",
})
else:
records.append({
diff --git a/tests/test_content.py b/tests/test_content.py
index 9311328..5b59e19 100644
--- a/tests/test_content.py
+++ b/tests/test_content.py
@@ -304,6 +304,74 @@ def test_units_default_to_the_citys_country(client):
assert 'data-unit-default="F"' in us
+_PRECIP_SPAN = re.compile(r'([\d.]+) (in|mm)')
+
+
+def test_precip_and_wind_follow_the_citys_unit(client):
+ # One toggle drives every measure: a °C page shows mm and km/h, not °C mixed
+ # with inches and mph. The data-* attribute stays imperial in both, so
+ # climate.js converts from the same source of truth it does for temperature.
+ gb = client.get(f"{B}/climate/{SLUG}/records").text # London, GB
+ us = client.get(f"{B}/climate/seattle-washington-us/records").text
+
+ gb_p, us_p = _PRECIP_SPAN.findall(gb), _PRECIP_SPAN.findall(us)
+ assert gb_p and us_p
+
+ assert {u for _raw, _shown, u in gb_p} == {"mm"}
+ assert {u for _raw, _shown, u in us_p} == {"in"}
+
+ # The conversion is real, not just a relabel.
+ for raw, shown, _u in gb_p:
+ assert abs(float(shown) - float(raw) * 25.4) < 0.05, (raw, shown)
+ # On an imperial page the attribute and the text agree.
+ for raw, shown, _u in us_p:
+ assert abs(float(shown) - float(raw)) < 0.005, (raw, shown)
+
+
+def test_wind_and_humidity_render_per_unit_system():
+ # The synthetic history carries only tmax/tmin/precip, so wind and humidity
+ # never reach a rendered page in tests — exercise the renderers directly
+ # rather than assert on a page that structurally cannot show them.
+ import content
+ with content._unit_scope("F"):
+ assert content._wind_text(10) == "10 mph"
+ assert '10 mph' == content._wind(10)
+ assert content._fmt("humid", 7.75) == "7.8 g/m³"
+ with content._unit_scope("C"):
+ # 10 mph -> 16.09 km/h, rounded half-up like JS.
+ assert content._wind_text(10) == "16 km/h"
+ # data-wind-mph stays imperial: it is what climate.js converts from.
+ assert 'data-wind-mph="10.0"' in content._wind(10)
+ # Absolute humidity has no imperial equivalent anyone would recognise, so
+ # it reads the same in both systems.
+ assert content._fmt("humid", 7.75) == "7.8 g/m³"
+
+
+def test_precip_precision_differs_by_unit():
+ # 0.04 in and 1.0 mm are the same amount; rendering "1.02 mm" would advertise
+ # precision the source doesn't have.
+ import content
+ with content._unit_scope("F"):
+ assert content._precip_text(0.04) == "0.04 in"
+ with content._unit_scope("C"):
+ assert content._precip_text(0.04) == "1.0 mm"
+ assert content._precip_text(1.81) == "46.0 mm"
+
+
+def test_measure_conversion_constants_match_the_frontend():
+ # Same drift risk as the country list: two hand-maintained copies.
+ import os
+ import content
+ units_js = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ os.pardir, "frontend", "units.js")
+ with open(units_js, encoding="utf-8") as f:
+ src = f.read()
+ mm = float(re.search(r"MM_PER_IN\s*=\s*([\d.]+)", src).group(1))
+ kmh = float(re.search(r"KMH_PER_MPH\s*=\s*([\d.]+)", src).group(1))
+ assert mm == content.MM_PER_IN
+ assert kmh == content.KMH_PER_MPH
+
+
def test_unit_default_absent_without_a_city(client):
# No city -> no attribute -> units.js keeps today's locale behaviour. Pinning
# these to "F" would silently regress the interactive pages.