diff --git a/static/cache.js b/static/cache.js
index a8b0df2..d55aa42 100644
--- a/static/cache.js
+++ b/static/cache.js
@@ -13,7 +13,7 @@
// etag is stored, so an unchanged payload costs an empty 304).
// * network failure with any entry cached → the stale copy, rather than an error.
-export const TTL = { grade: 600000, forecast: 1800000, calendar: 21600000, day: 600000 }; // calendar: 6h
+export const TTL = { grade: 600000, forecast: 1800000, calendar: 21600000, day: 600000, score: 21600000 }; // calendar/score: 6h
const IDB_NAME = "thermograph-cache", IDB_STORE = "resp";
const IDB_MAX_AGE = 14 * 86400000; // prune entries untouched for 2 weeks
diff --git a/static/calendar.html b/static/calendar.html
index a1e46c8..f319988 100644
--- a/static/calendar.html
+++ b/static/calendar.html
@@ -43,6 +43,7 @@
Calendar
Day Detail
Compare
+ Score
Climate
Alerts
diff --git a/static/compare.html b/static/compare.html
index 03a74d7..6d54c96 100644
--- a/static/compare.html
+++ b/static/compare.html
@@ -44,6 +44,7 @@
Calendar
Day Detail
Compare
+ Score
Climate
Alerts
diff --git a/static/day.html b/static/day.html
index 72296ce..cf0294e 100644
--- a/static/day.html
+++ b/static/day.html
@@ -44,6 +44,7 @@
Calendar
Day Detail
Compare
+ Score
Climate
Alerts
diff --git a/static/legend.html b/static/legend.html
index 786ac88..cf4598e 100644
--- a/static/legend.html
+++ b/static/legend.html
@@ -43,6 +43,7 @@
Calendar
Day Detail
Compare
+ Score
Climate
Alerts
diff --git a/static/score.html b/static/score.html
new file mode 100644
index 0000000..c75880d
--- /dev/null
+++ b/static/score.html
@@ -0,0 +1,75 @@
+
+
+
+
+
+ Thermograph — climate score
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
How far the last 6 years have drifted from 45 years of local climate
+
+
+
+
+
+
+
+
+
+
+
Pick a place — or open this from the map or calendar — to see how much its
+ recent climate has drifted from its long-term normal, metric by metric.
+
+
+
+
+
+
+
+
diff --git a/static/score.js b/static/score.js
new file mode 100644
index 0000000..e1a9f5a
--- /dev/null
+++ b/static/score.js
@@ -0,0 +1,266 @@
+// Thermograph climate-score subpage — for one location, shows how far the last 6
+// years have drifted from the full 45-year record, per metric and season, with a
+// weighted overall score and a button-revealed summary. Talks to /api/v2/score.
+import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
+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, pctOrd, fmtPrecip, fmtWind, fmtHumid, esc, placeLabel } from "./shared.js";
+
+// Display order (site convention leads with Precip), and how each metric's raw
+// per-percentile value is formatted in the detail table.
+const METRIC_ORDER = ["precip", "tmax", "tmin", "feels", "humid", "wetbulb", "wind", "gust"];
+const FMT = {
+ tmax: fmtTemp, tmin: fmtTemp, feels: fmtTemp, wetbulb: fmtTemp,
+ humid: fmtHumid, wind: fmtWind, gust: fmtWind, precip: fmtPrecip,
+};
+const SEASON_NAMES = { djf: "Winter", mam: "Spring", jja: "Summer", son: "Fall" };
+const SEASON_ORDER = ["djf", "mam", "jja", "son"];
+
+const tintColor = (cls) => TIER_COLORS[cls] || "";
+const tintClass = (cls) => (cls ? `t-${cls}` : "t-none");
+
+let selected = null; // {lat, lon}
+let lastData = null; // most recent /score response, for repainting on a unit switch
+
+const placeholder = document.getElementById("score-placeholder");
+const scoreHead = document.getElementById("score-head");
+const scoreBody = document.getElementById("score-body");
+
+// ---- location picker ----
+const findBtn = document.getElementById("find-btn");
+const locLabel = document.getElementById("loc-label");
+initFindButton(findBtn, "Find a location", () => selected,
+ (lat, lon) => {
+ selected = { lat, lon };
+ locLabel.textContent = `${lat.toFixed(3)}, ${lon.toFixed(3)}`;
+ locLabel.hidden = false;
+ fetchScore();
+ });
+
+// ---- fetch + render ----
+let seqCounter = 0; // supersedes an in-flight load when the user picks a new spot
+
+async function fetchScore() {
+ if (!selected) return;
+ history.replaceState(null, "", locHash(selected.lat, selected.lon));
+ placeholder.hidden = true;
+ scoreHead.hidden = false;
+ const seq = ++seqCounter;
+ // Delay the spinner: a cache-served render lands in a few ms, so blanking the
+ // page immediately would just flash. Only a slow (network) load shows it.
+ const spin = setTimeout(() => {
+ if (seq !== seqCounter) return;
+ scoreHead.innerHTML = `Scoring 45 years of climate…
`;
+ scoreBody.innerHTML = "";
+ }, 150);
+ let data;
+ try {
+ // Stale-while-revalidate: a stale cached copy renders immediately; the update
+ // callback repaints if the background revalidation finds new data.
+ data = await getJSON(
+ `api/v2/score?lat=${selected.lat}&lon=${selected.lon}`, TTL.score,
+ false, (upd) => { if (seq === seqCounter) finishScore(upd); });
+ } catch (err) {
+ clearTimeout(spin);
+ if (seq !== seqCounter) return;
+ scoreHead.innerHTML = `${esc(err.message)}
`;
+ scoreBody.innerHTML = "";
+ return;
+ }
+ clearTimeout(spin);
+ if (seq !== seqCounter) return;
+ finishScore(data);
+}
+
+function finishScore(data) {
+ saveLastLocation(selected.lat, selected.lon);
+ render(data);
+ prefetchViews(selected.lat, selected.lon, []); // warm the other views
+}
+
+onUnitChange(() => { if (lastData) render(lastData); });
+
+function render(data) {
+ lastData = data;
+ const place = placeLabel(data);
+ locLabel.textContent = place;
+ locLabel.hidden = false;
+ setFindLabel(findBtn, "Change location");
+
+ const s = data.scores;
+ if (s.unavailable) {
+ scoreHead.innerHTML = `${esc(place)}
+
${esc(s.unavailable)}
`;
+ scoreBody.innerHTML = "";
+ return;
+ }
+
+ const annual = s.slices.annual;
+ const ov = annual.overall;
+ const rr = s.recent_range, br = s.baseline_range;
+ const recentYrs = `${rr[0].slice(0, 4)}–${rr[1].slice(0, 4)}`;
+ const baseYrs = `${br[0].slice(0, 4)}–${br[1].slice(0, 4)}`;
+
+ // ---- hero: the overall annual score ----
+ scoreHead.innerHTML = `
+
+
Climate-shift score · ${esc(place)}
+
+
${ov.score}
+
+ ${esc(ov.grade)}
+ ${recentYrs} vs ${baseYrs} · ${s.n_recent.toLocaleString()} recent days scored
+
+
+
0 = no change from the long-term normal · 100 = an extreme shift.
+ Temperature, feels-like and humidity are weighted most.
+
`;
+
+ // ---- per-metric cards (annual) ----
+ const cards = METRIC_ORDER.map((k) => metricCard(annual.metrics[k])).join("");
+
+ // ---- by-season rows ----
+ const seasonRows = SEASON_ORDER.map((sk) => {
+ const sl = s.slices[sk];
+ const chips = METRIC_ORDER.map((k) => seasonChip(sl.metrics[k])).join("");
+ const oc = sl.overall;
+ const overallChip = oc
+ ? `${oc.score}`
+ : `—`;
+ return `
+ ${SEASON_NAMES[sk]}
+ ${overallChip}
+ ${chips}
+
`;
+ }).join("");
+
+ scoreBody.innerHTML = `
+
+
+ By season
+ ${METRIC_ORDER.map((k) => shortLabel(k)).join(" · ")}
+ ${seasonRows}
+
+
+ `;
+
+ wireSummaryToggle();
+}
+
+// One metric score card (reuses .normal-card). Null metrics show a muted reason.
+function metricCard(m) {
+ if (!m) return "";
+ if (m.score == null) {
+ return `
+ ${esc(m.label)}
+ —
+ ${esc(m.reason || "no data")}
+ `;
+ }
+ return `
+ ${esc(m.label)}
${esc(m.grade)}
+ ${m.score}
+ ${scoreDirection(m)}
+ `;
+}
+
+// A short "shifted N pts warmer" line for a metric card.
+function scoreDirection(m) {
+ if (m.score < 15) return "steady vs the long-term normal";
+ const pts = Math.abs(m.bias).toFixed(0);
+ return `~${pts} pts ${esc(m.direction)}`;
+}
+
+function seasonChip(m) {
+ if (!m || m.score == null) return `—`;
+ return `${m.score}`;
+}
+
+const shortLabel = (k) => ({ precip: "Precip", tmax: "High", tmin: "Low", feels: "Feels",
+ humid: "Humid", wetbulb: "Wet bulb", wind: "Wind", gust: "Gust" })[k] || k;
+
+// ---- summary (revealed by the button) ----
+function summaryHTML(s) {
+ return sentencesHTML(s.slices.annual) + scoreTableHTML(s) + perQTableHTML(s.slices.annual);
+}
+
+// A plain-language sentence per metric that actually shifted.
+function sentencesHTML(annual) {
+ const lines = METRIC_ORDER
+ .map((k) => annual.metrics[k])
+ .filter((m) => m && m.score != null && m.score >= 15)
+ .map((m) => {
+ const mid = m.per_q && m.per_q.find((q) => q.q === 50);
+ const where = mid ? ` Its recent median now lands near the ${pctOrd(mid.pct)} percentile of the full record.` : "";
+ return `${esc(m.label)}: ${esc(m.grade)} — about ${Math.abs(m.bias).toFixed(0)} percentile points ${esc(m.direction)}.${where}`;
+ });
+ if (!lines.length) return `Every metric is close to its long-term normal here — no notable recent drift.
`;
+ return `What has shifted the most:
`;
+}
+
+// Metrics × slices table of scores, each cell tinted by its tier.
+function scoreTableHTML(s) {
+ const slices = ["annual", ...SEASON_ORDER];
+ const head = `| Metric | ${slices.map((sk) =>
+ `${sk === "annual" ? "Annual" : SEASON_NAMES[sk]} | `).join("")}
`;
+ const rows = METRIC_ORDER.map((k) => {
+ const cells = slices.map((sk) => {
+ const m = s.slices[sk].metrics[k];
+ if (!m || m.score == null) return `— | `;
+ return `${m.score} | `;
+ }).join("");
+ const label = s.slices.annual.metrics[k]?.label || k;
+ return `| ${esc(label)} | ${cells}
`;
+ }).join("");
+ const overallCells = slices.map((sk) => {
+ const o = s.slices[sk].overall;
+ if (!o) return `— | `;
+ return `${o.score} | `;
+ }).join("");
+ const overallRow = `| Overall | ${overallCells}
`;
+ return `All scores
+
+ ${head}${rows}${overallRow}
`;
+}
+
+// The annual per-percentile detail: for each category, the recent value and where
+// it lands in the 45-year record.
+function perQTableHTML(annual) {
+ const rows = METRIC_ORDER
+ .map((k) => annual.metrics[k])
+ .filter((m) => m && m.score != null && m.per_q && m.per_q.length)
+ .map((m) => {
+ const fmt = FMT[m.key] || ((v) => v);
+ const cells = m.per_q.map((q) =>
+ `p${q.q}: ${esc(String(fmt(q.v6)))} → ${pctOrd(q.pct)} (${q.d >= 0 ? "+" : ""}${q.d}) | `).join("");
+ return `| ${esc(m.label)} | ${cells}
`;
+ }).join("");
+ if (!rows) return "";
+ return `Annual percentile detail
+ Each recent-years percentile value, and where it lands in the full record (▲/▼ = shift in percentile points).
+ `;
+}
+
+// Mirror of shared.js initSeasonExpand: flip [hidden] + aria-expanded.
+function wireSummaryToggle() {
+ const btn = scoreBody.querySelector(".summary-toggle");
+ const panel = scoreBody.querySelector("#score-summary");
+ if (!btn || !panel) return;
+ btn.addEventListener("click", () => {
+ const show = panel.hidden;
+ panel.hidden = !show;
+ btn.setAttribute("aria-expanded", String(show));
+ });
+}
+
+// ---- restore (hash from a shared link, else the last place you looked at) ----
+(function restore() {
+ const loc = loadLastLocation();
+ if (loc) {
+ selected = { lat: loc.lat, lon: loc.lon };
+ fetchScore();
+ }
+})();
diff --git a/static/style.css b/static/style.css
index 6e51e54..938933a 100644
--- a/static/style.css
+++ b/static/style.css
@@ -1704,6 +1704,86 @@ td.rec-date { color: var(--muted); font-size: 13px; }
so the colour reads as a chip around the number. */
.t-inline { padding: 1px 7px; border-radius: 7px; }
+/* ---- Climate score page ---------------------------------------------------
+ Hero (overall score), per-metric cards reuse .normal-card, a by-season chip
+ grid, and a button-revealed summary with tinted score tables. All color comes
+ from the shared temperature scale via --cat / the .t-* washes. */
+.score-hero {
+ background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
+ padding: 18px 20px; margin: 4px 0 18px;
+}
+.score-eyebrow {
+ font-size: 12px; text-transform: uppercase; letter-spacing: .05em;
+ color: var(--muted); margin: 0 0 8px;
+}
+.score-hero-row { display: flex; align-items: center; gap: 16px; }
+.score-num {
+ font-size: 52px; font-weight: 800; line-height: 1;
+ color: color-mix(in oklab, var(--cat, var(--text)), var(--text) 15%);
+}
+.score-hero-label { display: flex; flex-direction: column; gap: 4px; }
+.score-grade {
+ font-size: 18px; font-weight: 700;
+ color: color-mix(in oklab, var(--cat, var(--text)), var(--text) 22%);
+}
+.score-sub { font-size: 13px; color: var(--muted); }
+.score-note { font-size: 12px; color: var(--muted); margin: 12px 0 0; line-height: 1.4; }
+
+.score-section { margin: 20px 0; }
+.score-legend { font-size: 12px; color: var(--muted); margin: -6px 0 10px; }
+
+/* One row per season: name · overall · a chip per metric. */
+.season-scores {
+ display: grid; grid-template-columns: 64px 40px 1fr; align-items: center;
+ gap: 10px; padding: 7px 0; border-top: 1px solid var(--border);
+}
+.season-scores-name { font-size: 13px; font-weight: 600; color: var(--text); }
+.season-scores-chips { display: flex; flex-wrap: wrap; gap: 5px; }
+.score-chip {
+ display: inline-flex; align-items: center; justify-content: center;
+ min-width: 30px; padding: 3px 6px; border-radius: 7px;
+ font-size: 12px; font-weight: 700; color: var(--text);
+}
+.season-scores-overall .score-chip { min-width: 34px; font-weight: 800; }
+
+/* Summary reveal — pill button + caret, mirrors the season-expand pattern. */
+.summary-toggle {
+ display: inline-flex; align-items: center; gap: 6px; min-height: 44px;
+ margin: 8px 0 4px; padding: 8px 16px; border: 1px solid var(--border);
+ border-radius: 10px; background: var(--surface); color: var(--text);
+ font-size: 15px; font-weight: 600; cursor: pointer;
+}
+.summary-toggle:hover { border-color: var(--accent); }
+.summary-caret { transition: transform .15s ease; }
+.summary-toggle[aria-expanded="true"] { border-color: var(--accent); }
+.summary-toggle[aria-expanded="true"] .summary-caret { transform: rotate(180deg); }
+
+#score-summary { margin-top: 14px; }
+.score-summary-lead { font-size: 14px; color: var(--text); margin: 6px 0; }
+.score-sentences { margin: 8px 0 18px; padding-left: 20px; }
+.score-sentences li { font-size: 14px; line-height: 1.5; margin-bottom: 8px; color: var(--text); }
+
+.score-table-wrap { overflow-x: auto; margin: 8px 0 20px; }
+.score-table { border-collapse: collapse; font-size: 13px; }
+.score-table th, .score-table td {
+ padding: 6px 10px; border-bottom: 1px solid var(--border); text-align: center; white-space: nowrap;
+}
+.score-table thead th { color: var(--muted); font-weight: 600; }
+.score-table tbody th { text-align: left; font-weight: 600; color: var(--text); }
+.score-table-overall th, .score-table-overall td {
+ border-top: 2px solid var(--border); font-weight: 800;
+}
+.pq-table td { text-align: left; font-size: 12px; color: var(--muted); }
+.pq-d { font-weight: 700; }
+.pq-up { color: color-mix(in oklab, var(--hot), var(--text) 30%); }
+.pq-down { color: color-mix(in oklab, var(--cool), var(--text) 30%); }
+
+@media (max-width: 640px) {
+ .score-num { font-size: 40px; }
+ .score-hero { padding: 14px 15px; }
+ .season-scores { grid-template-columns: 56px 36px 1fr; gap: 8px; }
+}
+
/* Monthly temperature-range strip: one gradient bar per month, low→high on a shared
axis. The signature climate visual — colourful and unmistakably Thermograph. */
.range-strip { display: grid; gap: 6px; margin: 16px 0 8px; max-width: 620px; }
diff --git a/static/subscriptions.html b/static/subscriptions.html
index 754215d..2925cd8 100644
--- a/static/subscriptions.html
+++ b/static/subscriptions.html
@@ -41,6 +41,7 @@
Calendar
Day Detail
Compare
+ Score
Climate
Alerts