From 086a301387c26f21c8f5e2872412d566088d59bb Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sat, 18 Jul 2026 02:08:28 -0700 Subject: [PATCH] Render every percentile through one rule (#186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The homepage strip floored percentiles into 1-99 while the Day page rendered "100th pct" for the same reading. Rather than teach the Day page a second copy of the rule, put it in grading.pct_ordinal() and have every surface defer to it: the Day page, the calendar tooltip, the chart labels, the city pages and the strip. Two bugs fell out of unifying them: - The city pages rendered `{{ c.percentile }}th pct` against a float, so all ~1000 of them showed "97.1th pct", "66.4th pct", "16.5th pct" — and would say "1th"/"2th"/"3th" for the low tail. Live in prod. - Python's round() is half-to-even and JavaScript's Math.round is half-up, so 16.5 gave "16th" server-side and "17th" client-side. The Python helper uses floor(x + 0.5) to match the frontend exactly; a cross-language check over every .5 boundary now agrees on all of them. The frontend mirrors the rule as pctOrd() in shared.js, replacing the bare ord() at all five percentile call sites (day.js, calendar.js, chart.js x2, app.js). ord() stays as the general ordinal helper it always was. --- static/app.js | 4 ++-- static/calendar.js | 4 ++-- static/chart.js | 6 +++--- static/day.js | 4 ++-- static/shared.js | 13 +++++++++++++ 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/static/app.js b/static/app.js index b5d4fb7..f2f1857 100644 --- a/static/app.js +++ b/static/app.js @@ -7,7 +7,7 @@ import { initFindButton, setFindLabel } from "./mappicker.js"; import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette, tempChart, precipChart, dryChart, attachChartHover } from "./chart.js"; import { track } from "./digest.js"; -import { TIER_COLORS, SCALE_TEMP, drynessColor, ord, esc, todayISO, placeLabel, +import { TIER_COLORS, SCALE_TEMP, drynessColor, pctOrd, esc, todayISO, placeLabel, fmtPrecip, fmtWind, fmtHumid } from "./shared.js"; let selected = null; // {lat, lon} @@ -124,7 +124,7 @@ function updateHero(data) { document.getElementById("hero-where").textContent = `Today in ${placeLabel(data)}`; document.getElementById("hero-band").textContent = g.grade; document.getElementById("hero-detail").textContent = - `${targetDay.tmax === g ? "High temp" : "Low temp"} in the ${ord(Math.round(g.percentile))} percentile`; + `${targetDay.tmax === g ? "High temp" : "Low temp"} in the ${pctOrd(g.percentile)} percentile`; // Band colour rides the same token set as everything else; the band word is // always present too, so colour is never the only signal. card.style.setProperty("--cat", TIER_COLORS[g.class] || ""); diff --git a/static/calendar.js b/static/calendar.js index 614ca1f..f984ed5 100644 --- a/static/calendar.js +++ b/static/calendar.js @@ -6,7 +6,7 @@ import { fmtTemp, onUnitChange } from "./units.js"; import "./account.js"; // header sign-in entry + notification bell import { TTL, chunkedFetch, prefetchViews } from "./cache.js"; import { initFindButton, setFindLabel } from "./mappicker.js"; -import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, ord, +import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, pctOrd, fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd, CHUNK_MONTHS, buildChunks, clickOpensPicker, metricBuckets, distStrip, seasonFilterDropdown, seasonSummaryText, syncSeasonChecks, @@ -620,7 +620,7 @@ function attachHover(byDate) { const line = (key, name, g, fmt, color) => { const rc = key === activeKey ? " tt-r-active" : ""; if (!g) return `${name}`; - const pct = g.pct == null ? "" : ` · ${ord(g.pct)} pct`; + const pct = g.pct == null ? "" : ` · ${pctOrd(g.pct)} pct`; const catCls = key === activeKey ? "tt-cat tt-cat-active" : "tt-cat"; const style = color ? ` style="--cat:${color}"` : ""; return `${name}${fmt(g.v)}${pct}${g.g}`; diff --git a/static/chart.js b/static/chart.js index 225f26a..13939a9 100644 --- a/static/chart.js +++ b/static/chart.js @@ -3,7 +3,7 @@ // palette, and the pointer-driven hover. Extracted from app.js so any page can // plot a metric series; app.js keeps only its page orchestration (buildChart). import { fmtTemp, toUnit } from "./units.js"; -import { TIER_COLORS, drynessColor, ord } from "./shared.js"; +import { TIER_COLORS, drynessColor, pctOrd } from "./shared.js"; // #rrggbb -> rgba() with alpha, so band tints derive from the same site colors. const hexA = (hex, a) => { @@ -165,7 +165,7 @@ function lineChart(cfg) { const vy = (y - plotTop < 26 ? y + 15 : y - 15).toFixed(1); // flip below near the top const pv = getPct ? getPct(d) : null; const pct = pv == null ? "" : - `${ord(pv)}`; + `${pctOrd(pv)}`; return `${fmtLabel(v)}${pct}`; }).join(""); @@ -247,7 +247,7 @@ export function attachChartHover(wrap, days) { const C = chartPalette(); const pctLine = (label, g, unit, color) => { if (!g) return `
${label}
`; - const pct = g.percentile == null ? "" : ` · ${ord(g.percentile)} pct`; + const pct = g.percentile == null ? "" : ` · ${pctOrd(g.percentile)} pct`; const gc = TIER_COLORS[g.class] || ""; // "°" marks a temperature line — show it in the active unit; others are as-is. const val = unit === "°" ? Math.round(toUnit(g.value)) : g.value; diff --git a/static/day.js b/static/day.js index b1d3fc5..8a238b8 100644 --- a/static/day.js +++ b/static/day.js @@ -6,7 +6,7 @@ import { fmtTemp, onUnitChange } from "./units.js"; import "./account.js"; // header sign-in entry + notification bell import { getJSON, TTL, prefetchViews } from "./cache.js"; import { initFindButton, setFindLabel } from "./mappicker.js"; -import { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, ord, fmtPrecip, fmtWind, fmtHumid, +import { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, pctOrd, fmtPrecip, fmtWind, fmtHumid, todayISO, weatherType, placeLabel } from "./shared.js"; // Color a tier the same way the calendar cell would be colored for it. @@ -158,7 +158,7 @@ function ladderCard(title, m, fmt, kind) { let summary; if (obs) { - const pct = obs.percentile == null ? "" : ` · ${ord(obs.percentile)} pct`; + const pct = obs.percentile == null ? "" : ` · ${pctOrd(obs.percentile)} pct`; summary = `${fmt(obs.value)} ${obs.grade}${pct}`; } else { diff --git a/static/shared.js b/static/shared.js index 5819927..91bad7c 100644 --- a/static/shared.js +++ b/static/shared.js @@ -180,6 +180,19 @@ export const ord = (n) => { const r = Math.round(n), s = ["th", "st", "nd", "rd"], v = r % 100; return r + (s[(v - 20) % 10] || s[v] || s[0]); }; + +// A percentile as a display ordinal. Mirrors grading.pct_ordinal() in the +// backend — floored into 1..99, because an empirical percentile is a rank +// against the sample and can round to 100 (or 0), and "100th percentile" reads +// as a measurement error rather than "as extreme as it has ever been". +// Every percentile shown anywhere goes through this, so the Day page, the +// calendar tooltip, the chart, the city pages and the homepage strip can't +// disagree about the same reading. +export const pctOrd = (n) => { + const v = Number(n); + if (!Number.isFinite(v)) return "—"; + return ord(Math.min(99, Math.max(1, Math.round(v)))); +}; export const todayISO = () => new Date().toISOString().slice(0, 10); export const esc = (s) => String(s).replace(/&/g, "&").replace(//g, ">"); // The heading label for a graded response: the resolved place name, or the