thermograph/frontend/static/app.js

642 lines
31 KiB
JavaScript
Raw Normal View History

2026-07-11 20:28:33 +00:00
// Weekly (map) page. Imports replace the old script-tag ordering contract.
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
import { fmtTemp, onUnitChange, toWind, windUnit, precipUnit } from "./units.js";
import { uv } from "./account.js"; // header sign-in entry + notification bell, and the API-version resolver
import { loadView, TTL, prefetchViews } from "./cache.js";
2026-07-11 20:28:33 +00:00
import { initFindButton, setFindLabel } from "./mappicker.js";
import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette,
tempChart, precipChart, dryChart, attachChartHover } from "./chart.js";
Rebuild the homepage as a distribution landing; add an SMTP seam (#178) The homepage was a bare tool: a find-bar and an empty panel reading "Find a location to begin." A visitor arriving from a search result or a shared link learned nothing about what the product does before deciding to leave. Rebuild it around the Weekly view, which is untouched: - Hero with the question as the h1, and a grade card showing a real graded example — the most unusual city we're currently tracking, either tail. The card's frame and text slots are server-rendered with reserved heights, so app.js re-pointing it at the visitor's own place shifts nothing. - "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city is force-included whenever one qualifies. - Stance line, how-it-works, explore cards, and 12 city chips linking into the ~1000-page /climate surface. - Monthly digest form, in the footer of every page. Serve / from Jinja instead of a static file with placeholder substitution, so crawlers and no-JS readers get the whole page as real HTML. home.html.j2 extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted rather than left behind the static mount, where it would keep serving indexable duplicate content. "Where is it most unusual right now" has no cheap answer at request time — percentiles live inside zlib-compressed payload blobs with no column to sort on. So homepage.py sweeps the warm cache and writes data/homepage.json, read by the template. The sweep is strictly cache-only (climate.load_cached_recent_forecast is new, the sibling of load_cached_history), so grading ~1000 cities costs zero upstream requests. It rides the notifier's timer behind an hourly guard rather than starting a second daemon, and also runs at the tail of warm_cities so a fresh deploy has a populated feed. Instrumentation: metrics gains a product-event counter keyed by (event, referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by POST /api/v2/event. The referrer is taken from the request's own header, never from the client. The beacon gets its own inbound category that record_inbound ignores, so reporting an interaction doesn't also count as traffic. The dashboard grows an events block with per-referrer attribution. Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the choice between direct-to-MX and relaying through a provider stays a Postfix config change with no code change. The backend defaults to "console", which logs and sends nothing, so dev and tests exercise the whole signup path safely. Signups land in pending_digest unconfirmed; collecting the list shouldn't wait on delivery. Also adds /privacy, linked from the footer and kept out of the sitemap. The strip's classes are named unusual-* rather than record-*: .record-card is already the SEO records page's, and reusing it leaked layout rules onto /climate/<slug>/records.
2026-07-18 07:39:47 +00:00
import { track } from "./digest.js";
import { TIER_COLORS, SCALE_TEMP, drynessColor, pctOrd, esc, todayISO, placeLabel,
tierKeySegs, GUIDE_LINK, fmtPrecip, fmtPrecipTier, fmtWind, fmtHumid } from "./shared.js";
let selected = null; // {lat, lon}
const dateInput = document.getElementById("date-input");
const todayBtn = document.getElementById("today-btn");
dateInput.value = todayISO();
dateInput.max = dateInput.value;
// The "Today" chip snaps the target date back to today; it stays lit while the
// target already is today, so it doubles as a "you're viewing now" indicator.
function syncTodayBtn() {
todayBtn.classList.toggle("active", dateInput.value === todayISO());
}
syncTodayBtn();
todayBtn.addEventListener("click", () => {
const t = todayISO();
if (dateInput.value === t) return;
dateInput.value = t;
dateInput.max = t;
syncTodayBtn();
if (selected) runGrade();
});
function selectLocation(lat, lon) {
selected = { lat, lon };
Picker map: smaller labels, darker roads; location label coords→place (#27) * Picker map: smaller city labels, darker/bolder roads (split tile layers) Split the CARTO Voyager basemap into two raster layers so label size and road weight are independent: a labels-free base upscaled via tileSize 512 + zoomOffset -1 (bold, prominent roads) plus a labels-only layer at natural size on top (small, crisp city names). The darken/saturate filter now targets the base layer only (.mp-base: brightness .92, contrast 1.12), deepening the roads while leaving label text at full contrast. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc * Location label: show coordinates immediately, then resolve to place name On selecting a location, write the raw coordinates to the location label right away, so the user sees the picked spot instantly instead of a stale/blank label. The existing post-fetch render already overwrites it with the reverse-geocoded "neighborhood, city, region" string (data.place), so the label now reads coords → place name live. - app.js / calendar.js / day.js: set coords in the selection handler before the fetch; the render/initOnce overwrite is unchanged. - compare.js: show coordinates through the pending + loading states (instead of "Locating…") so each chip's coords are replaced live by the place name once scored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 08:35:43 +00:00
// Show the raw coordinates immediately; render() replaces them with the resolved
// neighborhood + city name once the grade fetch returns.
locLabel.textContent = `${lat.toFixed(3)}, ${lon.toFixed(3)}`;
locLabel.hidden = false;
runGrade();
}
// ---- location picker ----
// The Find button opens the shared modal map picker; choosing a spot loads it.
const findBtn = document.getElementById("find-btn");
const locLabel = document.getElementById("loc-label");
2026-07-11 20:28:33 +00:00
initFindButton(findBtn, "Find a location",
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47) The page scripts each re-declared the shared presentation layer — the tier color table existed in four places (app.js, calendar.js, day.js, style.css) and the scale label tables in three (plus legend.html's own drifting copy), alongside per-page copies of the dryness ramp, formatters, ord, todayISO, esc, the weather icons/summary, month helpers and the 2-year range chunker. ~240 duplicated lines deleted (net -236 with the new module included). - frontend/shared.js (IIFE, extends window.Thermograph): tier colors read from style.css's :root custom properties at load — the CSS is now the single source of truth; the JS map exists only because inline-SVG work (chart + PNG export) needs literal values, with hex fallbacks for a missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord, todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker, and the weatherType summary (dsr-aware; the day page just omits dsr). - nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in the shared classic-script scope, and a leaked locHash collided with page destructuring (caught by the browser smoke, not by node --check). locHash is now exported and used by all 8 former hand-built hash sites. - mappicker.js: initFindButton/setFindLabel replace the Find-button block each page rebuilt. - legend.html renders its scales from the shared tables, so the guide can no longer drift from what the app shows. Verified: node --check on all JS; 108 backend tests; headless-Chromium smoke over all five pages against live data — no console/page errors, legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle, calendar grid + key + metric switch, day 7 ladders + weather icon, compare seeded rank card.
2026-07-11 20:21:48 +00:00
() => selected, (lat, lon) => selectLocation(lat, lon));
Rebuild the homepage as a distribution landing; add an SMTP seam (#178) The homepage was a bare tool: a find-bar and an empty panel reading "Find a location to begin." A visitor arriving from a search result or a shared link learned nothing about what the product does before deciding to leave. Rebuild it around the Weekly view, which is untouched: - Hero with the question as the h1, and a grade card showing a real graded example — the most unusual city we're currently tracking, either tail. The card's frame and text slots are server-rendered with reserved heights, so app.js re-pointing it at the visitor's own place shifts nothing. - "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city is force-included whenever one qualifies. - Stance line, how-it-works, explore cards, and 12 city chips linking into the ~1000-page /climate surface. - Monthly digest form, in the footer of every page. Serve / from Jinja instead of a static file with placeholder substitution, so crawlers and no-JS readers get the whole page as real HTML. home.html.j2 extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted rather than left behind the static mount, where it would keep serving indexable duplicate content. "Where is it most unusual right now" has no cheap answer at request time — percentiles live inside zlib-compressed payload blobs with no column to sort on. So homepage.py sweeps the warm cache and writes data/homepage.json, read by the template. The sweep is strictly cache-only (climate.load_cached_recent_forecast is new, the sibling of load_cached_history), so grading ~1000 cities costs zero upstream requests. It rides the notifier's timer behind an hourly guard rather than starting a second daemon, and also runs at the tail of warm_cities so a fresh deploy has a populated feed. Instrumentation: metrics gains a product-event counter keyed by (event, referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by POST /api/v2/event. The referrer is taken from the request's own header, never from the client. The beacon gets its own inbound category that record_inbound ignores, so reporting an interaction doesn't also count as traffic. The dashboard grows an events block with per-referrer attribution. Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the choice between direct-to-MX and relaying through a provider stays a Postfix config change with no code change. The backend defaults to "console", which logs and sends nothing, so dev and tests exercise the whole signup path safely. Signups land in pending_digest unconfirmed; collecting the list shouldn't wait on delivery. Also adds /privacy, linked from the footer and kept out of the sitemap. The strip's classes are named unusual-* rather than record-*: .record-card is already the SEO records page's, and reusing it leaked layout rules onto /climate/<slug>/records.
2026-07-18 07:39:47 +00:00
// ---- hero ----
// The hero's grade card is server-rendered showing the most unusual city we're
// tracking; once the visitor has a place of their own it re-points at that. The
// frame and its text slots already exist in the HTML, so swapping the numbers in
// shifts nothing on the page.
const hero = document.getElementById("hero");
Fix the hero's "Use my location" doing nothing (#179) The button was dead on plain HTTP and failed silently everywhere. Browsers only expose geolocation on secure origins, but `navigator.geolocation` still EXISTS on http:// — getCurrentPosition just fails with a permission error ("Only secure origins are allowed"). The guard was `if (!navigator.geolocation) return;`, which passes on http://, so the call went ahead, errored, and landed in an empty error callback. Nothing happened at all. `make lan-run` serves plain HTTP on 0.0.0.0:8137 so phones can reach it, so the button could never work in the normal dev-and-phone workflow, and gave no hint why. It would have worked on thermograph.org, which is HTTPS. - Gate on `window.isSecureContext`, not just the API's presence. Where the browser will refuse, hide the locate button and promote "Pick on the map" to primary rather than offering a control that cannot work. - Report failures: a declined prompt, a timeout, and an unavailable fix each get their own message in an aria-live slot. A silent failure is indistinguishable from a broken button, which is exactly how this went unnoticed. - Show a "Locating…" state while the fix is pending; it can take seconds. To exercise geolocation against the LAN dev server, serve it over TLS: `make lan-run TLS=1` (self-signed cert into certs/). Verified by driving the button in a real browser across all three cases: insecure origin (button hidden, map promoted), permission granted (hero re-points at the located place), permission denied (message shown, button restored).
2026-07-18 07:48:17 +00:00
const heroLocate = document.getElementById("hero-locate");
const heroPick = document.getElementById("hero-pick");
const heroMsg = document.getElementById("hero-locate-msg");
function locateMsg(text) {
if (!heroMsg) return;
heroMsg.textContent = text || "";
heroMsg.hidden = !text;
}
// Browsers expose geolocation ONLY on secure origins, but `navigator.geolocation`
// still exists on plain http:// — getCurrentPosition just fails with a permission
// error. So feature-testing the object is not enough: without the isSecureContext
// check the button is a dead control on the plain-HTTP LAN dev server (and on any
// phone reaching it by IP), which is exactly how it shipped broken.
// Rather than offer a button that cannot work, promote the map picker instead.
const canLocate = !!navigator.geolocation && window.isSecureContext;
if (heroLocate && !canLocate) {
heroLocate.hidden = true;
heroPick?.classList.replace("btn-ghost", "btn-primary");
}
heroLocate?.addEventListener("click", () => {
Rebuild the homepage as a distribution landing; add an SMTP seam (#178) The homepage was a bare tool: a find-bar and an empty panel reading "Find a location to begin." A visitor arriving from a search result or a shared link learned nothing about what the product does before deciding to leave. Rebuild it around the Weekly view, which is untouched: - Hero with the question as the h1, and a grade card showing a real graded example — the most unusual city we're currently tracking, either tail. The card's frame and text slots are server-rendered with reserved heights, so app.js re-pointing it at the visitor's own place shifts nothing. - "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city is force-included whenever one qualifies. - Stance line, how-it-works, explore cards, and 12 city chips linking into the ~1000-page /climate surface. - Monthly digest form, in the footer of every page. Serve / from Jinja instead of a static file with placeholder substitution, so crawlers and no-JS readers get the whole page as real HTML. home.html.j2 extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted rather than left behind the static mount, where it would keep serving indexable duplicate content. "Where is it most unusual right now" has no cheap answer at request time — percentiles live inside zlib-compressed payload blobs with no column to sort on. So homepage.py sweeps the warm cache and writes data/homepage.json, read by the template. The sweep is strictly cache-only (climate.load_cached_recent_forecast is new, the sibling of load_cached_history), so grading ~1000 cities costs zero upstream requests. It rides the notifier's timer behind an hourly guard rather than starting a second daemon, and also runs at the tail of warm_cities so a fresh deploy has a populated feed. Instrumentation: metrics gains a product-event counter keyed by (event, referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by POST /api/v2/event. The referrer is taken from the request's own header, never from the client. The beacon gets its own inbound category that record_inbound ignores, so reporting an interaction doesn't also count as traffic. The dashboard grows an events block with per-referrer attribution. Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the choice between direct-to-MX and relaying through a provider stays a Postfix config change with no code change. The backend defaults to "console", which logs and sends nothing, so dev and tests exercise the whole signup path safely. Signups land in pending_digest unconfirmed; collecting the list shouldn't wait on delivery. Also adds /privacy, linked from the footer and kept out of the sitemap. The strip's classes are named unusual-* rather than record-*: .record-card is already the SEO records page's, and reusing it leaked layout rules onto /climate/<slug>/records.
2026-07-18 07:39:47 +00:00
track("home.locate");
Fix the hero's "Use my location" doing nothing (#179) The button was dead on plain HTTP and failed silently everywhere. Browsers only expose geolocation on secure origins, but `navigator.geolocation` still EXISTS on http:// — getCurrentPosition just fails with a permission error ("Only secure origins are allowed"). The guard was `if (!navigator.geolocation) return;`, which passes on http://, so the call went ahead, errored, and landed in an empty error callback. Nothing happened at all. `make lan-run` serves plain HTTP on 0.0.0.0:8137 so phones can reach it, so the button could never work in the normal dev-and-phone workflow, and gave no hint why. It would have worked on thermograph.org, which is HTTPS. - Gate on `window.isSecureContext`, not just the API's presence. Where the browser will refuse, hide the locate button and promote "Pick on the map" to primary rather than offering a control that cannot work. - Report failures: a declined prompt, a timeout, and an unavailable fix each get their own message in an aria-live slot. A silent failure is indistinguishable from a broken button, which is exactly how this went unnoticed. - Show a "Locating…" state while the fix is pending; it can take seconds. To exercise geolocation against the LAN dev server, serve it over TLS: `make lan-run TLS=1` (self-signed cert into certs/). Verified by driving the button in a real browser across all three cases: insecure origin (button hidden, map promoted), permission granted (hero re-points at the located place), permission denied (message shown, button restored).
2026-07-18 07:48:17 +00:00
const label = heroLocate.textContent;
const restore = () => {
heroLocate.disabled = false;
heroLocate.textContent = label;
};
// Geolocation can take seconds (or hang until the timeout), so say so —
// a button that looks inert is indistinguishable from a broken one.
heroLocate.disabled = true;
heroLocate.textContent = "Locating…";
locateMsg("");
Rebuild the homepage as a distribution landing; add an SMTP seam (#178) The homepage was a bare tool: a find-bar and an empty panel reading "Find a location to begin." A visitor arriving from a search result or a shared link learned nothing about what the product does before deciding to leave. Rebuild it around the Weekly view, which is untouched: - Hero with the question as the h1, and a grade card showing a real graded example — the most unusual city we're currently tracking, either tail. The card's frame and text slots are server-rendered with reserved heights, so app.js re-pointing it at the visitor's own place shifts nothing. - "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city is force-included whenever one qualifies. - Stance line, how-it-works, explore cards, and 12 city chips linking into the ~1000-page /climate surface. - Monthly digest form, in the footer of every page. Serve / from Jinja instead of a static file with placeholder substitution, so crawlers and no-JS readers get the whole page as real HTML. home.html.j2 extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted rather than left behind the static mount, where it would keep serving indexable duplicate content. "Where is it most unusual right now" has no cheap answer at request time — percentiles live inside zlib-compressed payload blobs with no column to sort on. So homepage.py sweeps the warm cache and writes data/homepage.json, read by the template. The sweep is strictly cache-only (climate.load_cached_recent_forecast is new, the sibling of load_cached_history), so grading ~1000 cities costs zero upstream requests. It rides the notifier's timer behind an hourly guard rather than starting a second daemon, and also runs at the tail of warm_cities so a fresh deploy has a populated feed. Instrumentation: metrics gains a product-event counter keyed by (event, referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by POST /api/v2/event. The referrer is taken from the request's own header, never from the client. The beacon gets its own inbound category that record_inbound ignores, so reporting an interaction doesn't also count as traffic. The dashboard grows an events block with per-referrer attribution. Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the choice between direct-to-MX and relaying through a provider stays a Postfix config change with no code change. The backend defaults to "console", which logs and sends nothing, so dev and tests exercise the whole signup path safely. Signups land in pending_digest unconfirmed; collecting the list shouldn't wait on delivery. Also adds /privacy, linked from the footer and kept out of the sitemap. The strip's classes are named unusual-* rather than record-*: .record-card is already the SEO records page's, and reusing it leaked layout rules onto /climate/<slug>/records.
2026-07-18 07:39:47 +00:00
navigator.geolocation.getCurrentPosition(
Fix the hero's "Use my location" doing nothing (#179) The button was dead on plain HTTP and failed silently everywhere. Browsers only expose geolocation on secure origins, but `navigator.geolocation` still EXISTS on http:// — getCurrentPosition just fails with a permission error ("Only secure origins are allowed"). The guard was `if (!navigator.geolocation) return;`, which passes on http://, so the call went ahead, errored, and landed in an empty error callback. Nothing happened at all. `make lan-run` serves plain HTTP on 0.0.0.0:8137 so phones can reach it, so the button could never work in the normal dev-and-phone workflow, and gave no hint why. It would have worked on thermograph.org, which is HTTPS. - Gate on `window.isSecureContext`, not just the API's presence. Where the browser will refuse, hide the locate button and promote "Pick on the map" to primary rather than offering a control that cannot work. - Report failures: a declined prompt, a timeout, and an unavailable fix each get their own message in an aria-live slot. A silent failure is indistinguishable from a broken button, which is exactly how this went unnoticed. - Show a "Locating…" state while the fix is pending; it can take seconds. To exercise geolocation against the LAN dev server, serve it over TLS: `make lan-run TLS=1` (self-signed cert into certs/). Verified by driving the button in a real browser across all three cases: insecure origin (button hidden, map promoted), permission granted (hero re-points at the located place), permission denied (message shown, button restored).
2026-07-18 07:48:17 +00:00
(pos) => {
restore();
selectLocation(pos.coords.latitude, pos.coords.longitude);
},
(err) => {
restore();
// Always say something: a declined prompt or a timeout must not look
// like the button did nothing.
locateMsg(
err.code === err.PERMISSION_DENIED
? "Location permission was declined. Pick a spot on the map instead."
Fix the hero's "Use my location" doing nothing (#179) The button was dead on plain HTTP and failed silently everywhere. Browsers only expose geolocation on secure origins, but `navigator.geolocation` still EXISTS on http:// — getCurrentPosition just fails with a permission error ("Only secure origins are allowed"). The guard was `if (!navigator.geolocation) return;`, which passes on http://, so the call went ahead, errored, and landed in an empty error callback. Nothing happened at all. `make lan-run` serves plain HTTP on 0.0.0.0:8137 so phones can reach it, so the button could never work in the normal dev-and-phone workflow, and gave no hint why. It would have worked on thermograph.org, which is HTTPS. - Gate on `window.isSecureContext`, not just the API's presence. Where the browser will refuse, hide the locate button and promote "Pick on the map" to primary rather than offering a control that cannot work. - Report failures: a declined prompt, a timeout, and an unavailable fix each get their own message in an aria-live slot. A silent failure is indistinguishable from a broken button, which is exactly how this went unnoticed. - Show a "Locating…" state while the fix is pending; it can take seconds. To exercise geolocation against the LAN dev server, serve it over TLS: `make lan-run TLS=1` (self-signed cert into certs/). Verified by driving the button in a real browser across all three cases: insecure origin (button hidden, map promoted), permission granted (hero re-points at the located place), permission denied (message shown, button restored).
2026-07-18 07:48:17 +00:00
: err.code === err.TIMEOUT
? "That took too long. Try again, or pick a spot on the map."
: "Your location isn't available right now. Pick a spot on the map instead.",
Fix the hero's "Use my location" doing nothing (#179) The button was dead on plain HTTP and failed silently everywhere. Browsers only expose geolocation on secure origins, but `navigator.geolocation` still EXISTS on http:// — getCurrentPosition just fails with a permission error ("Only secure origins are allowed"). The guard was `if (!navigator.geolocation) return;`, which passes on http://, so the call went ahead, errored, and landed in an empty error callback. Nothing happened at all. `make lan-run` serves plain HTTP on 0.0.0.0:8137 so phones can reach it, so the button could never work in the normal dev-and-phone workflow, and gave no hint why. It would have worked on thermograph.org, which is HTTPS. - Gate on `window.isSecureContext`, not just the API's presence. Where the browser will refuse, hide the locate button and promote "Pick on the map" to primary rather than offering a control that cannot work. - Report failures: a declined prompt, a timeout, and an unavailable fix each get their own message in an aria-live slot. A silent failure is indistinguishable from a broken button, which is exactly how this went unnoticed. - Show a "Locating…" state while the fix is pending; it can take seconds. To exercise geolocation against the LAN dev server, serve it over TLS: `make lan-run TLS=1` (self-signed cert into certs/). Verified by driving the button in a real browser across all three cases: insecure origin (button hidden, map promoted), permission granted (hero re-points at the located place), permission denied (message shown, button restored).
2026-07-18 07:48:17 +00:00
);
},
Rebuild the homepage as a distribution landing; add an SMTP seam (#178) The homepage was a bare tool: a find-bar and an empty panel reading "Find a location to begin." A visitor arriving from a search result or a shared link learned nothing about what the product does before deciding to leave. Rebuild it around the Weekly view, which is untouched: - Hero with the question as the h1, and a grade card showing a real graded example — the most unusual city we're currently tracking, either tail. The card's frame and text slots are server-rendered with reserved heights, so app.js re-pointing it at the visitor's own place shifts nothing. - "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city is force-included whenever one qualifies. - Stance line, how-it-works, explore cards, and 12 city chips linking into the ~1000-page /climate surface. - Monthly digest form, in the footer of every page. Serve / from Jinja instead of a static file with placeholder substitution, so crawlers and no-JS readers get the whole page as real HTML. home.html.j2 extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted rather than left behind the static mount, where it would keep serving indexable duplicate content. "Where is it most unusual right now" has no cheap answer at request time — percentiles live inside zlib-compressed payload blobs with no column to sort on. So homepage.py sweeps the warm cache and writes data/homepage.json, read by the template. The sweep is strictly cache-only (climate.load_cached_recent_forecast is new, the sibling of load_cached_history), so grading ~1000 cities costs zero upstream requests. It rides the notifier's timer behind an hourly guard rather than starting a second daemon, and also runs at the tail of warm_cities so a fresh deploy has a populated feed. Instrumentation: metrics gains a product-event counter keyed by (event, referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by POST /api/v2/event. The referrer is taken from the request's own header, never from the client. The beacon gets its own inbound category that record_inbound ignores, so reporting an interaction doesn't also count as traffic. The dashboard grows an events block with per-referrer attribution. Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the choice between direct-to-MX and relaying through a provider stays a Postfix config change with no code change. The backend defaults to "console", which logs and sends nothing, so dev and tests exercise the whole signup path safely. Signups land in pending_digest unconfirmed; collecting the list shouldn't wait on delivery. Also adds /privacy, linked from the footer and kept out of the sitemap. The strip's classes are named unusual-* rather than record-*: .record-card is already the SEO records page's, and reusing it leaked layout rules onto /climate/<slug>/records.
2026-07-18 07:39:47 +00:00
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 600000 },
);
});
Fix the hero's "Use my location" doing nothing (#179) The button was dead on plain HTTP and failed silently everywhere. Browsers only expose geolocation on secure origins, but `navigator.geolocation` still EXISTS on http:// — getCurrentPosition just fails with a permission error ("Only secure origins are allowed"). The guard was `if (!navigator.geolocation) return;`, which passes on http://, so the call went ahead, errored, and landed in an empty error callback. Nothing happened at all. `make lan-run` serves plain HTTP on 0.0.0.0:8137 so phones can reach it, so the button could never work in the normal dev-and-phone workflow, and gave no hint why. It would have worked on thermograph.org, which is HTTPS. - Gate on `window.isSecureContext`, not just the API's presence. Where the browser will refuse, hide the locate button and promote "Pick on the map" to primary rather than offering a control that cannot work. - Report failures: a declined prompt, a timeout, and an unavailable fix each get their own message in an aria-live slot. A silent failure is indistinguishable from a broken button, which is exactly how this went unnoticed. - Show a "Locating…" state while the fix is pending; it can take seconds. To exercise geolocation against the LAN dev server, serve it over TLS: `make lan-run TLS=1` (self-signed cert into certs/). Verified by driving the button in a real browser across all three cases: insecure origin (button hidden, map promoted), permission granted (hero re-points at the located place), permission denied (message shown, button restored).
2026-07-18 07:48:17 +00:00
heroPick?.addEventListener("click", () => { locateMsg(""); findBtn.click(); });
Rebuild the homepage as a distribution landing; add an SMTP seam (#178) The homepage was a bare tool: a find-bar and an empty panel reading "Find a location to begin." A visitor arriving from a search result or a shared link learned nothing about what the product does before deciding to leave. Rebuild it around the Weekly view, which is untouched: - Hero with the question as the h1, and a grade card showing a real graded example — the most unusual city we're currently tracking, either tail. The card's frame and text slots are server-rendered with reserved heights, so app.js re-pointing it at the visitor's own place shifts nothing. - "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city is force-included whenever one qualifies. - Stance line, how-it-works, explore cards, and 12 city chips linking into the ~1000-page /climate surface. - Monthly digest form, in the footer of every page. Serve / from Jinja instead of a static file with placeholder substitution, so crawlers and no-JS readers get the whole page as real HTML. home.html.j2 extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted rather than left behind the static mount, where it would keep serving indexable duplicate content. "Where is it most unusual right now" has no cheap answer at request time — percentiles live inside zlib-compressed payload blobs with no column to sort on. So homepage.py sweeps the warm cache and writes data/homepage.json, read by the template. The sweep is strictly cache-only (climate.load_cached_recent_forecast is new, the sibling of load_cached_history), so grading ~1000 cities costs zero upstream requests. It rides the notifier's timer behind an hourly guard rather than starting a second daemon, and also runs at the tail of warm_cities so a fresh deploy has a populated feed. Instrumentation: metrics gains a product-event counter keyed by (event, referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by POST /api/v2/event. The referrer is taken from the request's own header, never from the client. The beacon gets its own inbound category that record_inbound ignores, so reporting an interaction doesn't also count as traffic. The dashboard grows an events block with per-referrer attribution. Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the choice between direct-to-MX and relaying through a provider stays a Postfix config change with no code change. The backend defaults to "console", which logs and sends nothing, so dev and tests exercise the whole signup path safely. Signups land in pending_digest unconfirmed; collecting the list shouldn't wait on delivery. Also adds /privacy, linked from the footer and kept out of the sitemap. The strip's classes are named unusual-* rather than record-*: .record-card is already the SEO records page's, and reusing it leaked layout rules onto /climate/<slug>/records.
2026-07-18 07:39:47 +00:00
/** Re-point the hero card at the place the visitor actually chose. */
function updateHero(data) {
if (!hero) return;
const card = document.getElementById("hero-grade");
const targetDay = data.recent.find((d) => d.date === data.target_date);
const g = targetDay && (targetDay.tmax || targetDay.tmin);
if (!card || !g) return;
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 ${pctOrd(g.percentile)} percentile`;
Rebuild the homepage as a distribution landing; add an SMTP seam (#178) The homepage was a bare tool: a find-bar and an empty panel reading "Find a location to begin." A visitor arriving from a search result or a shared link learned nothing about what the product does before deciding to leave. Rebuild it around the Weekly view, which is untouched: - Hero with the question as the h1, and a grade card showing a real graded example — the most unusual city we're currently tracking, either tail. The card's frame and text slots are server-rendered with reserved heights, so app.js re-pointing it at the visitor's own place shifts nothing. - "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city is force-included whenever one qualifies. - Stance line, how-it-works, explore cards, and 12 city chips linking into the ~1000-page /climate surface. - Monthly digest form, in the footer of every page. Serve / from Jinja instead of a static file with placeholder substitution, so crawlers and no-JS readers get the whole page as real HTML. home.html.j2 extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted rather than left behind the static mount, where it would keep serving indexable duplicate content. "Where is it most unusual right now" has no cheap answer at request time — percentiles live inside zlib-compressed payload blobs with no column to sort on. So homepage.py sweeps the warm cache and writes data/homepage.json, read by the template. The sweep is strictly cache-only (climate.load_cached_recent_forecast is new, the sibling of load_cached_history), so grading ~1000 cities costs zero upstream requests. It rides the notifier's timer behind an hourly guard rather than starting a second daemon, and also runs at the tail of warm_cities so a fresh deploy has a populated feed. Instrumentation: metrics gains a product-event counter keyed by (event, referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by POST /api/v2/event. The referrer is taken from the request's own header, never from the client. The beacon gets its own inbound category that record_inbound ignores, so reporting an interaction doesn't also count as traffic. The dashboard grows an events block with per-referrer attribution. Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the choice between direct-to-MX and relaying through a provider stays a Postfix config change with no code change. The backend defaults to "console", which logs and sends nothing, so dev and tests exercise the whole signup path safely. Signups land in pending_digest unconfirmed; collecting the list shouldn't wait on delivery. Also adds /privacy, linked from the footer and kept out of the sitemap. The strip's classes are named unusual-* rather than record-*: .record-card is already the SEO records page's, and reusing it leaked layout rules onto /climate/<slug>/records.
2026-07-18 07:39:47 +00:00
// 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] || "");
hero.querySelector(".grade-why")?.remove();
}
dateInput.addEventListener("change", () => { syncTodayBtn(); selected && runGrade(); });
// ---- grade request + render ----
const placeholder = document.getElementById("placeholder");
const results = document.getElementById("results");
let lastGraded = null; // most recent /api/grade response (for the PNG export)
// Clicking a graded day (any cell or its date header) opens its full breakdown.
results.addEventListener("click", (e) => {
const row = e.target.closest(".rd-grid [data-date]");
if (!row || !selected) return;
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47) The page scripts each re-declared the shared presentation layer — the tier color table existed in four places (app.js, calendar.js, day.js, style.css) and the scale label tables in three (plus legend.html's own drifting copy), alongside per-page copies of the dryness ramp, formatters, ord, todayISO, esc, the weather icons/summary, month helpers and the 2-year range chunker. ~240 duplicated lines deleted (net -236 with the new module included). - frontend/shared.js (IIFE, extends window.Thermograph): tier colors read from style.css's :root custom properties at load — the CSS is now the single source of truth; the JS map exists only because inline-SVG work (chart + PNG export) needs literal values, with hex fallbacks for a missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord, todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker, and the weatherType summary (dsr-aware; the day page just omits dsr). - nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in the shared classic-script scope, and a leaked locHash collided with page destructuring (caught by the browser smoke, not by node --check). locHash is now exported and used by all 8 former hand-built hash sites. - mappicker.js: initFindButton/setFindLabel replace the Find-button block each page rebuilt. - legend.html renders its scales from the shared tables, so the guide can no longer drift from what the app shows. Verified: node --check on all JS; 108 backend tests; headless-Chromium smoke over all five pages against live data — no console/page errors, legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle, calendar grid + key + metric switch, day 7 ladders + weather icon, compare seeded rank card.
2026-07-11 20:21:48 +00:00
location.href = `day${locHash(selected.lat, selected.lon, row.dataset.date)}`;
});
Persistent derived-data cache: SQLite store, ETag revalidation, view bundle, IndexedDB frontend (#21) * Compress API responses and revalidate static assets instead of re-downloading - Add GZipMiddleware (min 1 KB): the 2-year calendar JSON shrinks ~6-8x. - Serve pages/assets with Cache-Control: no-cache instead of no-store, so browsers revalidate via the ETag/Last-Modified that FileResponse and StaticFiles already emit. Unchanged assets now cost an empty 304 rather than a full transfer on every page navigation, while deploys still show up immediately. * Persist derived responses in SQLite so grading is computed once per cell, not per request New data/thermograph.sqlite (WAL) holds what's derived from the raw parquet records — finished grade/calendar/day/forecast payloads and reverse-geocode labels — so the expensive work (notably the 2-year calendar grade_range, ~270ms) becomes a ~5ms database read that survives restarts and is shared across views. Raw parquet stays the source of truth; the store is a pure accelerator (every reader falls back to recomputing on a miss, and deleting the db is a safe reset). Freshness is token-driven, not clock-driven: each cached payload is validated by a token encoding what it was computed from (payload schema version, the archive record's end date, the recent-fetch stamp). The existing freshness drivers are untouched — get_history still tops up the tail hourly and get_recent_forecast still refetches hourly — and tokens are derived from what they return, so cached payloads expire exactly when their inputs change. The same tokens double as weak ETags: If-None-Match answers with an empty 304 without touching the payload. - backend/store.py: derived-payload + revgeo tables, thread-local WAL conns, every helper fail-soft. - app.py: endpoints split into pure payload builders + HTTP/caching shells; the in-memory 10-minute _CAL_CACHE is retired (superseded by the persistent store). - climate.py: revgeo persisted through the store; recent_stamp() and load_cached_history() (no-network read) helpers. - backend/migrate.py + make migrate: idempotent, resumable backfill of the store from existing parquet caches (default calendar span + latest-day detail + revgeo, ≤1 throttled Nominatim call per unlabeled cell). Never fetches weather. - grid.from_id(): rebuild a cell from its cache filename (migrate tooling). * Add /api/v2/cell: one bundle carrying every view's payload GET /api/v2/cell?lat&lon returns the grade, forecast, calendar (last 24 months) and day (today) payloads in one response, each the exact payload its per-view endpoint returns — built by the same builders and cached under the same derived-store keys/tokens — paired with the etag that endpoint would emit. The frontend can warm all views with a single request, seed its per-view cache from the slices, and later revalidate each view individually with If-None-Match. The bundle's own etag combines the slices', so an unchanged bundle is an empty 304. prefetch=1 is a warm-only mode for neighbor-cell prefetching with a hard guarantee: it never spends weather-API quota. A cell with no cached archive answers 204, and only the history-derived slices (calendar + latest-day detail) are built. At most one Nominatim lookup for a never-labeled cell. * Frontend: IndexedDB response cache with stale-while-revalidate + bundle prefetch The per-URL response cache moves from localStorage/sessionStorage (~5 MB quota, which multi-year calendar payloads regularly blew through) to IndexedDB, with an in-memory map in front. Entries carry the server's ETag, so anything stale revalidates conditionally — unchanged data costs an empty 304 and a re-stamp, never a re-transfer. On network failure the stale copy is served over an error. getJSON gains an optional onUpdate callback opting into stale-while-revalidate: the three views (weekly, day, calendar) now render a cached copy immediately — spinners are delayed 150ms so warm loads never flash-blank — and repaint only if background revalidation finds changed data. New data shows up the moment it exists instead of waiting out a TTL. Cross-view prefetch collapses from one request per view to a single /api/v2/cell bundle, whose slices (exact per-view payloads + their etags) are seeded under the URLs each view actually requests; the bundle call itself is conditional via a remembered etag. Afterwards the 8 surrounding grid cells are warmed server-side with prefetch=1 (never spends weather-API quota; staggered ≥1.1s for the one possible Nominatim lookup each), so tapping nearby lands on already-graded data. Legacy tg:* storage entries are cleared once; cache entries untouched for two weeks are pruned on page load.
2026-07-11 07:31:28 +00:00
let gradeSeq = 0; // supersedes an in-flight load when the user picks a new spot/date
async function runGrade() {
if (!selected) return;
updateHash();
placeholder.hidden = true;
results.hidden = false;
// Always send the target date, even when it's "today" — the backend only
// falls back to its own UTC today (api_grade's `date` param default; see
// backend/web/app.py) when the param is omitted or empty, a day behind local
// midnight for any viewer east of UTC (reported from Kaunas, UTC+3: asking
// for 7/26 rendered 7/25). The comment this replaces claimed omitting the
// date kept this URL matching the cross-view prefetch — but that match was
// the other half of the bug: cache.js's viewFetches() seeds the grade view's
// cache entry under this exact dateless URL too, tagged fresh for the
// viewer's local day no matter which day the server actually resolved, so a
// same-tab nav that had already prefetched this cell (e.g. via the Day page)
// could serve that stale, server-resolved slice straight out of IndexedDB
// with no live request ever going out. Sending the real date makes every
// load self-consistent with the viewer's own clock instead of aliasing onto
// whatever "today" the server or an earlier prefetch resolved.
// dateInput.value can also come back empty (a cleared native date field —
// day.js guards the same input for the same reason), so fall back to the
// viewer's local today rather than ever send `date=` empty, which hits that
// same server fallback.
const date = dateInput.value || todayISO();
const q = `lat=${selected.lat}&lon=${selected.lon}&date=${date}`;
const url = uv(`grade?${q}`);
await loadView({
url, ttl: TTL.grade, seq: ++gradeSeq, current: () => gradeSeq,
spinner: () => { results.innerHTML = `<p class="spinner">Fetching decades of climate history…</p>`; },
onData: render,
onError: (err) => { results.innerHTML = `<p class="error">${err.message}</p>`; },
});
}
// Compact cell formatters for the dense recent/forecast table (units live in the
// column headers, so values stay short). Dry days label the running dry streak
// (see precipCell) rather than the rain amount.
2026-07-11 20:28:33 +00:00
const cTemp = fmtTemp; // active unit, bare degree
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
const cHum = (v) => (v == null ? "—" : `${Math.round(v)}%`); // relative %, unit-invariant
const cWind = (v) => (v == null ? "—" : `${Math.round(toWind(v))}`);
Report rain in whole millimetres; lift tier spans out of the bars (#192) Two fixes to how measurements read. Rain goes back to millimetres, rendered as whole numbers — that is how rainfall is reported, and a tenth of a millimetre is below what the source resolves. Inches keep two decimals, since a whole inch of rain is a lot to round to. The distribution strip's low-high span moves from inside each bar to just under the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min 17°" pill clipped — it had already been shrunk to 8px to cope, and on a high-rainfall city with longer values it lost both ends of the label, because the text is centred and overflow-hidden. Above the bar it has the full column width and needs no scrim to stay legible over nine tier colours. The span is now bare numbers ("17-62"), since the average directly above it carries the unit; repeating " g/m³" on both ends is what made it too wide in the first place. It uses the same precision as that average, or the two lines disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero so precip's ten columns still fit a phone. With nothing inside the bars, the height floor drops from 30px to 14px: it only has to keep a non-empty bucket visible now, so the heights encode frequency more honestly. Category names get 2px of side padding, which stops long neighbours ("Light-Mod" beside "Moderate") reading as one word. Verified at 390/412/1920 in both themes across all four calendar metrics and the compare strips, in imperial and metric: nothing clips. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
// fmtPrecip carries the precision: whole millimetres, two decimals for inches.
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
const cRain = (v) => (v == null ? "—" : v > 0 ? fmtPrecip(v, false) : "·");
// Monochrome inline icons (currentColor) so the UI chrome matches the dark theme
// instead of using colorful emoji. Feather-style line paths.
const IC = {
link: `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
download: `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`,
calendar: `<svg class="ic ic-lg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`,
};
const MONTH_NAMES = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"];
// Friendly, plain-language "what to expect" sentences for the selected date's
// season, built from the ±7-day climatology frequencies (rain_freq / sun_freq)
// the backend now returns. Rain drives a bit of cute packing advice. Any piece
// with no data (e.g. sunshine on a NASA/MET-sourced cell) is simply omitted.
function insightsHTML(data) {
const c = data.climatology || {};
const parts = data.target_date ? data.target_date.split("-") : [];
const monthName = parts.length === 3 ? MONTH_NAMES[+parts[1] - 1] : null;
const around = monthName ? `around mid-${monthName}` : "this time of year";
const lines = [];
const hi = c.tmax && c.tmax.p50 != null ? c.tmax.p50 : null;
const lo = c.tmin && c.tmin.p50 != null ? c.tmin.p50 : null;
if (hi != null && lo != null) {
lines.push(`<li><span class="ins-emoji" aria-hidden="true">🌡️</span> A typical day ${esc(around)} peaks near <b>${fmtTemp(hi, true)}</b> and dips to <b>${fmtTemp(lo, true)}</b>.</li>`);
}
if (c.sun_freq != null) {
const pct = Math.round(c.sun_freq * 100);
const emoji = c.sun_freq >= 0.6 ? "☀️" : c.sun_freq >= 0.3 ? "⛅" : "☁️";
const tone = c.sun_freq >= 0.6 ? "plenty of sunshine — " : c.sun_freq < 0.3 ? "often cloudy — " : "";
lines.push(`<li><span class="ins-emoji" aria-hidden="true">${emoji}</span> ${tone}sunny about <b>${pct}%</b> of days ${esc(around)}.</li>`);
}
if (c.rain_freq != null) {
const pct = Math.round(c.rain_freq * 100);
let emoji, advice;
if (c.rain_freq < 0.15) {
emoji = "🌤️"; advice = "you can probably leave the umbrella at home.";
} else if (c.rain_freq < 0.4) {
emoji = "🌦️"; advice = "maybe tuck a compact umbrella in your bag just in case.";
} else {
emoji = "🌧️"; advice = "don't forget your favorite umbrella or raincoat!";
}
lines.push(`<li class="ins-rain"><span class="ins-emoji" aria-hidden="true">${emoji}</span> Rain on about <b>${pct}%</b> of days ${esc(around)}${advice}</li>`);
}
if (!lines.length) return "";
return `<div class="insights">
<p class="insight-lead">What to expect ${esc(around)}</p>
<ul class="insight-list">${lines.join("")}</ul>
</div>`;
}
function render(data) {
recentData = data;
Rebuild the homepage as a distribution landing; add an SMTP seam (#178) The homepage was a bare tool: a find-bar and an empty panel reading "Find a location to begin." A visitor arriving from a search result or a shared link learned nothing about what the product does before deciding to leave. Rebuild it around the Weekly view, which is untouched: - Hero with the question as the h1, and a grade card showing a real graded example — the most unusual city we're currently tracking, either tail. The card's frame and text slots are server-rendered with reserved heights, so app.js re-pointing it at the visitor's own place shifts nothing. - "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city is force-included whenever one qualifies. - Stance line, how-it-works, explore cards, and 12 city chips linking into the ~1000-page /climate surface. - Monthly digest form, in the footer of every page. Serve / from Jinja instead of a static file with placeholder substitution, so crawlers and no-JS readers get the whole page as real HTML. home.html.j2 extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted rather than left behind the static mount, where it would keep serving indexable duplicate content. "Where is it most unusual right now" has no cheap answer at request time — percentiles live inside zlib-compressed payload blobs with no column to sort on. So homepage.py sweeps the warm cache and writes data/homepage.json, read by the template. The sweep is strictly cache-only (climate.load_cached_recent_forecast is new, the sibling of load_cached_history), so grading ~1000 cities costs zero upstream requests. It rides the notifier's timer behind an hourly guard rather than starting a second daemon, and also runs at the tail of warm_cities so a fresh deploy has a populated feed. Instrumentation: metrics gains a product-event counter keyed by (event, referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by POST /api/v2/event. The referrer is taken from the request's own header, never from the client. The beacon gets its own inbound category that record_inbound ignores, so reporting an interaction doesn't also count as traffic. The dashboard grows an events block with per-referrer attribution. Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the choice between direct-to-MX and relaying through a provider stays a Postfix config change with no code change. The backend defaults to "console", which logs and sends nothing, so dev and tests exercise the whole signup path safely. Signups land in pending_digest unconfirmed; collecting the list shouldn't wait on delivery. Also adds /privacy, linked from the footer and kept out of the sitemap. The strip's classes are named unusual-* rather than record-*: .record-card is already the SEO records page's, and reusing it leaked layout rules onto /climate/<slug>/records.
2026-07-18 07:39:47 +00:00
updateHero(data);
// The place name appears once — as the results heading (<h2>) below, which also
// carries the coordinates + climatology context. The top label only holds the
// transient coordinates written on selection, so hide it now that the named
// results have rendered (otherwise the name would show twice).
locLabel.hidden = true;
locLabel.textContent = "";
2026-07-11 20:28:33 +00:00
setFindLabel(findBtn, "Change location");
const c = data.climatology;
const cell = data.cell;
const yrs = c.year_range ? `${c.year_range[0]}${c.year_range[1]}` : "—";
const coords = `${cell.center_lat.toFixed(3)}, ${cell.center_lon.toFixed(3)}`;
// Each card compares the target day's value to the normal (median) for that
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
// metric, tinted by its grade, and links to the full single-day breakdown. The
// window now runs past the target into the forecast, so the target is looked up
// by date rather than taken as the newest row.
const targetDay = data.recent.find((d) => d.date === data.target_date) || null;
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47) The page scripts each re-declared the shared presentation layer — the tier color table existed in four places (app.js, calendar.js, day.js, style.css) and the scale label tables in three (plus legend.html's own drifting copy), alongside per-page copies of the dryness ramp, formatters, ord, todayISO, esc, the weather icons/summary, month helpers and the 2-year range chunker. ~240 duplicated lines deleted (net -236 with the new module included). - frontend/shared.js (IIFE, extends window.Thermograph): tier colors read from style.css's :root custom properties at load — the CSS is now the single source of truth; the JS map exists only because inline-SVG work (chart + PNG export) needs literal values, with hex fallbacks for a missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord, todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker, and the weatherType summary (dsr-aware; the day page just omits dsr). - nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in the shared classic-script scope, and a leaked locHash collided with page destructuring (caught by the browser smoke, not by node --check). locHash is now exported and used by all 8 former hand-built hash sites. - mappicker.js: initFindButton/setFindLabel replace the Find-button block each page rebuilt. - legend.html renders its scales from the shared tables, so the guide can no longer drift from what the app shows. Verified: node --check on all JS; 108 backend tests; headless-Chromium smoke over all five pages against live data — no console/page errors, legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle, calendar grid + key + metric switch, day 7 ladders + weather icon, compare seeded rank card.
2026-07-11 20:21:48 +00:00
const dayHref = `day${locHash(selected.lat, selected.lon, data.target_date)}`;
const cmpCard = (label, key, clim, fmt) => {
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
const g = targetDay ? targetDay[key] : null;
const normalVal = clim ? fmt(clim.p50) : "—";
const cat = g ? (TIER_COLORS[g.class] || "") : "";
const big = g ? fmt(g.value) : normalVal;
const grade = g
? `<span class="nc-grade" style="--cat:${cat}">${g.grade}</span>`
: "";
return `<a class="normal-card" href="${dayHref}">
<div class="nc-head"><h3>${label}</h3>${grade}</div>
<div class="big"${g ? ` style="--cat:${cat}"` : ""}>${big}</div>
<div class="range">normal: <b>${normalVal}</b></div>
</a>`;
};
results.innerHTML = `
<div class="loc-head">
<div class="loc-title">
<h2>${placeLabel(data)}</h2>
<ul class="loc-meta">
${data.place ? `<li><b>${coords}</b></li>` : ""}
<li>Climatology from <b>${yrs}</b></li>
<li><b>${c.n_samples.toLocaleString()}</b> days sample size (day ±7)</li>
</ul>
</div>
<div class="share">
<button id="btn-link" title="Copy a link back to this exact view">${IC.link} Copy link</button>
<button id="btn-png" title="Download the trend chart as an image">${IC.download} Chart</button>
</div>
</div>
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47) The page scripts each re-declared the shared presentation layer — the tier color table existed in four places (app.js, calendar.js, day.js, style.css) and the scale label tables in three (plus legend.html's own drifting copy), alongside per-page copies of the dryness ramp, formatters, ord, todayISO, esc, the weather icons/summary, month helpers and the 2-year range chunker. ~240 duplicated lines deleted (net -236 with the new module included). - frontend/shared.js (IIFE, extends window.Thermograph): tier colors read from style.css's :root custom properties at load — the CSS is now the single source of truth; the JS map exists only because inline-SVG work (chart + PNG export) needs literal values, with hex fallbacks for a missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord, todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker, and the weatherType summary (dsr-aware; the day page just omits dsr). - nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in the shared classic-script scope, and a leaked locHash collided with page destructuring (caught by the browser smoke, not by node --check). locHash is now exported and used by all 8 former hand-built hash sites. - mappicker.js: initFindButton/setFindLabel replace the Find-button block each page rebuilt. - legend.html renders its scales from the shared tables, so the guide can no longer drift from what the app shows. Verified: node --check on all JS; 108 backend tests; headless-Chromium smoke over all five pages against live data — no console/page errors, legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle, calendar grid + key + metric switch, day 7 ladders + weather icon, compare seeded rank card.
2026-07-11 20:21:48 +00:00
<a class="cta-cal" href="calendar${locHash(selected.lat, selected.lon)}">
<span class="cta-cal-icon" aria-hidden="true">${IC.calendar}</span>
<span>Check historical data</span>
<span class="cta-cal-arrow" aria-hidden="true"></span>
</a>
${insightsHTML(data)}
<div class="section-title">${data.target_date} vs a typical day. Tap a metric for the full breakdown</div>
<div class="normals">
${cmpCard("High", "tmax", c.tmax, fmtTemp)}
${cmpCard("Low", "tmin", c.tmin, fmtTemp)}
${cmpCard("Feels", "feels", c.feels, fmtTemp)}
${cmpCard("Humidity", "humid", c.humid, fmtHumid)}
${cmpCard("Wind", "wind", c.wind, fmtWind)}
${cmpCard("Gust", "gust", c.gust, fmtWind)}
${cmpCard("Precip", "precip", c.precip, fmtPrecip)}
</div>
<div id="series"></div>
`;
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
renderSeries(); // fills #series (chart + graded-days timeline) from the window
2026-07-11 20:28:33 +00:00
prefetchViews(selected.lat, selected.lon, ["grade", "forecast"]); // warm calendar/day
document.getElementById("btn-link").onclick = copyShareLink;
document.getElementById("btn-png").onclick = downloadChartPng;
}
// One tinted value cell: background + value colored by the day's grade tier for
// that metric (the same diverging scale as everywhere else). Carries the date so
// tapping any cell opens that day's full breakdown.
function rdCell(g, fmt, date, isFc, isToday) {
const dd = date ? ` data-date="${date}"` : "";
const cls = "rd-c" + (isFc ? " rd-fc" : isToday ? " rd-today" : "");
if (!g) return `<span class="${cls}"${dd}>—</span>`;
const col = TIER_COLORS[g.class] || "";
return `<span class="${cls}"${dd} style="--c:${col}">${fmt(g.value)}</span>`;
}
// Precip cell: rain days show the amount (tinted by intensity tier); dry days
// label the running dry streak — "3d" = 3 days since measurable rain, warmed by
// the same dryness ramp as the chart — instead of a bare dot.
function precipCell(d, isFc, isToday) {
const g = d.precip;
const dd = ` data-date="${d.date}"`;
const cls = "rd-c" + (isFc ? " rd-fc" : isToday ? " rd-today" : "");
if (!g) return `<span class="${cls}"${dd}>—</span>`;
// Only a genuinely dry day (no rain at all) labels the streak; any rain day —
// down to sub-0.01" trace that rounds to 0 — shows its depth, tinted by tier.
if (g.class === "dry" && d.dsr != null && d.dsr > 0) {
return `<span class="${cls}"${dd} style="--c:${drynessColor(d.dsr)}" title="${d.dsr} days since rain">${d.dsr}d</span>`;
}
const col = TIER_COLORS[g.class] || "";
const amt = g.class && g.class !== "dry" ? fmtPrecipTier(g.value, g.class, false) : cRain(g.value);
return `<span class="${cls}"${dd} style="--c:${col}">${amt}</span>`;
}
// Compact "graded days" table — a ROW per metric, a COLUMN per day (newest first).
// Wide runs (e.g. 2 weeks) scroll horizontally inside their own container; the
// metric-label column stays pinned. Tapping any cell/day opens that day's detail.
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
// Wind and rain cells are bare numbers to keep the grid narrow, so their row
// label carries the unit — and has to be built per render, since the unit can
// change under us (onUnitChange re-renders).
const RD_METRICS = () => [
["tmax", "Hi", cTemp], ["tmin", "Lo", cTemp], ["feels", "Feel", cTemp],
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
["humid", "Hum", cHum],
["wind", `Wind <span class="rd-unit">${windUnit()}</span>`, cWind],
["gust", `Gust <span class="rd-unit">${windUnit()}</span>`, cWind],
["precip", `Rain <span class="rd-unit">${precipUnit()}</span>`, cRain],
];
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
function daysTable(rows, targetDate) {
if (!rows.length) return "";
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
const todayDate = todayISO();
const isFc = (d) => d.date > todayDate; // future = forecast columns
const isTarget = (d) => d.date === targetDate; // the graded target day (orange marker)
const cols = `grid-template-columns: 46px repeat(${rows.length}, minmax(42px, 1fr));`;
const header = `<div class="rd-corner"></div>` + rows.map((d) => {
const dt = new Date(d.date + "T00:00:00");
const dow = dt.toLocaleDateString(undefined, { weekday: "short" });
const md = dt.toLocaleDateString(undefined, { month: "numeric", day: "numeric" });
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
const cls = "rd-colh" + (isTarget(d) ? " rd-today" : isFc(d) ? " rd-fc" : "");
return `<div class="${cls}" data-date="${d.date}" title="See the full breakdown for this day"><b>${dow}</b><span>${md}</span></div>`;
}).join("");
Follow the unit system for precipitation and wind too (#190) Defaulting climate pages to the city's temperature convention left every page half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now drives every measure — picking °C also gives mm and km/h. Absolute humidity stays g/m³ in both systems; there is no imperial unit for it anyone would recognise, so converting it would only make the page worse. units.js becomes the single source for all of it. The formatting logic existed in three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks and tooltip — which is why the units drifted apart in the first place. shared.js now re-exports from units.js so its importers are unchanged, and chart.js carries a per-series unit `kind` in place of sniffing for a "°" suffix, which could not have extended to three unit families. Server-rendered pages gain the carrier they were missing: precipitation and wind now render as <span class="precip" data-precip-in> / <span class="wind" data-wind-mph>, the same contract temperature already had, so climate.js can repaint them on toggle and the attribute stays imperial as the source of truth. Precision follows the unit: millimetres get one decimal where inches get two. 0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision the source doesn't have. Wind rounds half-up to match Math.round, as temperature already does. The weekly table's wind and rain cells are bare numbers to keep the grid narrow, so their row labels now carry the unit and rebuild per render. Static copy that names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong for °C readers since the country defaults landed — is marked up with data-unit-label and painted from the same source. The chart keeps its imperial domain; only labels convert, so point positions and the fan geometry are untouched. Push and email bodies are still imperial-only (notify.py): they are composed server-side with no client to repaint them and no per-user unit stored, which is the same limitation temperature already has there. Left for a follow-up. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
const body = RD_METRICS().map(([key, label, fmt]) =>
`<div class="rd-mlabel">${label}</div>` +
rows.map((d) => key === "precip"
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
? precipCell(d, isFc(d), isTarget(d))
: rdCell(d[key], fmt, d.date, isFc(d), isTarget(d))).join("")
).join("");
return `<div class="rd-scroll"><div class="rd-grid" style="${cols}">${header}${body}</div></div>`;
}
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
// ---- graded-days timeline ----
// The header normals stay fixed on the target day; the chart + graded-days table
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
// below show one continuous window built server-side: two weeks of history before
// the target, the target itself (orange marker), then the days after it — real
// observations when they're in the past, the forward forecast when they run into
// the future (see backend _build_grade).
let recentData = null; // /grade response — the whole window, newest-first
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
// Repaint everything in the newly-picked unit.
2026-07-11 20:28:33 +00:00
onUnitChange(() => {
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
if (recentData) render(recentData);
});
// Rebuild the chart when the window is resized so its drawing width keeps
// tracking the container (debounced — resize fires continuously while dragging).
let resizeTimer = null;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => { if (recentData) renderSeries(); }, 160);
});
function renderSeries() {
const host = document.getElementById("series");
if (!host || !recentData) return;
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
const days = recentData.recent; // newest-first: history · target · after/forecast
const target = recentData.target_date;
const hasFc = days.some((d) => d.date > todayISO()); // window runs into the forecast
host.innerHTML = `
<div class="section-title">Trend vs normal${hasFc ? " · recent → forecast" : ""}</div>
<div id="chart-wrap"></div>
${tierKeyHtml()}
<div class="section-title">Daily, graded${hasFc ? ": recent &amp; 7-day forecast" : ""}</div>
<div class="rd-orient"><span> Older</span><span>Newer </span></div>
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
${daysTable([...days].reverse(), target) || '<p class="muted">Nothing to grade.</p>'}
`;
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
buildChart(recentData);
scrollTableToTarget(host, target);
}
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
// Open the timeline centered on the target day's column (the orange marker), so
// the two weeks of history and the days after it are both in view.
function scrollTableToTarget(host, target) {
const scroll = host.querySelector(".rd-scroll");
if (!scroll) return;
requestAnimationFrame(() => {
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
const col = scroll.querySelector(`.rd-colh[data-date="${target}"]`);
if (!col) { scroll.scrollLeft = scroll.scrollWidth; return; }
scroll.scrollLeft = Math.max(0, col.offsetLeft - (scroll.clientWidth - col.offsetWidth) / 2);
});
}
// ---- trend chart (self-contained inline SVG) ----
function tierKeyHtml() {
// Highest tier first (Near Record hot → Near Record cold).
const segs = tierKeySegs([...SCALE_TEMP].reverse().map((t) =>
({ color: TIER_COLORS[t.c], label: t.label })));
return `<div class="section-title">Grade scale · low → high vs local normal</div>
<div class="tier-key">${segs}</div>${GUIDE_LINK}`;
}
let chartDays = [];
// Which chart category is shown. Persisted so a refresh keeps your selection.
let chartMetric = localStorage.getItem("thermograph:chartMetric") || "tmax";
function buildChart(data) {
const wrap = document.getElementById("chart-wrap");
const C = chartPalette();
// Drawing width tracks the on-screen box (see chart.js) so wide monitors gain
// plot room while phones keep the 720-unit floor (the SVG just scales down).
setChartWidth(wrap.clientWidth);
lastGraded = data; // the series currently drawn (for PNG export)
chartDays = [...data.recent].reverse(); // oldest -> newest
const days = chartDays, n = days.length;
if (!n) { wrap.innerHTML = ""; return; }
const xFor = (i) => (n === 1 ? PL + PW / 2 : PL + (i * PW) / (n - 1));
// shared: x date labels
const step = n > 9 ? 2 : 1;
let xlab = "";
days.forEach((d, i) => {
if (i % step && i !== n - 1) return;
const dt = new Date(d.date + "T00:00:00");
xlab += `<text x="${xFor(i).toFixed(1)}" y="${xLabY}" text-anchor="middle" font-size="10.5" fill="${C.muted}">${dt.getMonth() + 1}/${dt.getDate()}</text>`;
});
// shared: hover hit targets over the plot
let hits = "";
days.forEach((_, i) => {
const w = n === 1 ? PW : PW / (n - 1);
hits += `<rect class="hit" data-i="${i}" x="${(xFor(i) - w / 2).toFixed(1)}" y="${plotTop}" width="${w.toFixed(1)}" height="${plotBot - plotTop}" fill="transparent"/>`;
});
const built = chartMetric === "precip" ? precipChart(days, n, xFor, C)
: chartMetric === "dry" ? dryChart(days, n, xFor, C)
: tempChart(chartMetric, days, n, xFor, C);
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
// Solid orange marker line ON the target day, plus a dashed "forecast →" divider
// where the window crosses from the past into the future — drawn only when it's
// clearly separated from the target marker (i.e. the target is in the past; when
// the target is today the marker itself already sits at that boundary).
let marks = "";
const tIdx = days.findIndex((d) => d.date === data.target_date);
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
if (tIdx >= 0) {
const xt = xFor(tIdx).toFixed(1);
const td = new Date(data.target_date + "T00:00:00");
marks += `<line x1="${xt}" y1="${plotTop}" x2="${xt}" y2="${plotBot}" stroke="${C.accent}" stroke-width="1.8" opacity="0.9"/>`
+ `<text x="${xt}" y="${plotTop - 3}" text-anchor="middle" font-size="9" font-weight="700" fill="${C.accent}">${td.getMonth() + 1}/${td.getDate()}</text>`;
}
const fIdx = days.findIndex((d) => d.date > todayISO()); // first forecast (future) day
if (fIdx > tIdx + 1) {
const xd = ((xFor(fIdx - 1) + xFor(fIdx)) / 2).toFixed(1);
marks += `<line x1="${xd}" y1="${plotTop}" x2="${xd}" y2="${plotBot}" stroke="${C.accent}" stroke-width="1.1" stroke-dasharray="4 3" opacity="0.55"/>`
+ `<text x="${xd}" y="${plotTop - 3}" text-anchor="middle" font-size="8.5" font-weight="700" fill="${C.accent}" opacity="0.8">forecast →</text>`;
}
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
const title = `${placeLabel(data)} · ${data.target_date}`;
const svg = `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" font-family="system-ui, -apple-system, Segoe UI, sans-serif" role="img" aria-label="${built.aria}">
<rect x="0" y="0" width="${W}" height="${H}" fill="${C.surface}"/>
<text x="${PL - 8}" y="24" font-size="12.5" font-weight="700" fill="${built.color}">${built.subtitle}</text>
<text x="${W - PR}" y="24" text-anchor="end" font-size="11" fill="${C.muted}">${title}</text>
${built.content}
Weekly view: center the daily-graded window on the target day (#28) * Pin the °F/°C toggle to the header's top-right on all widths The unit toggle sat inside .brand after a flex-basis:auto title block, so on narrow (phone) widths the long tagline claimed the first row and bumped the toggle onto its own line mid-header. Give the title block flex:1 1 0 with min-width:0 so it contributes ~nothing to the wrap decision and shrinks instead — keeping the toggle on the logo's row, top-right, with the tagline wrapping beneath and the view tabs on their own row below. * Weekly view: center the daily-graded window on the target day Reframe the "Daily, graded" chart + table on the weekly view around the selected target day instead of ending at it: - Show two weeks of history before the target, the target itself, then up to 7 days after it. Days after the target are real observations when they are already in the past and the forward forecast when they run into the future. - Mark the target day with a solid orange line on the chart (and an accent edge on its table column); draw the dashed "forecast →" divider only where the window actually crosses into the future and it is separated from the target marker. Backend: _build_grade now grades a [target-days, target+after] window built from both the archive and the recent+forecast bundle (the bundle wins per date; the archive fills older gaps), so any past target renders its surrounding week. api/grade gains an `after` param (default 7) and the cache key includes it; the /cell prefetch bundle builds the matching slice. Frontend: the chart consumes the server-built window directly — the separate forecast fetch and gap-merge are gone. The target is looked up by date for the comparison cards (the newest row is now a forecast day), and the graded table opens centered on the target column. * Weekly view: extend the after-target window to 14 days Bump the daily-graded window's after-target span from 7 to 14 days. A target far enough in the past now shows 14 observed days after it; a recent target shows its observed days plus the 1-7 available forecast days, and the forecast naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
${marks}
${xlab}
<line id="crosshair" x1="0" y1="${plotTop}" x2="0" y2="${plotBot}" stroke="${C.muted}" stroke-width="1" stroke-dasharray="3 3" opacity="0"/>
${hits}
</svg>`;
const btn = (m, label) => `<button type="button" data-metric="${m}"${chartMetric === m ? ' class="active"' : ""}>${label}</button>`;
wrap.innerHTML = `
<div class="chart-toggle" id="chart-toggle" role="group" aria-label="Chart metric">
${btn("tmax", "High")}${btn("tmin", "Low")}${btn("feels", "Feels")}${btn("humid", "Humid")}${btn("wind", "Wind")}${btn("gust", "Gust")}${btn("precip", "Precip")}${btn("dry", "Dry")}
</div>
<div class="chart-legend">${built.legend}</div>
${svg}
<div id="chart-tip" class="chart-tip" hidden></div>`;
document.getElementById("chart-toggle").addEventListener("click", (e) => {
const b = e.target.closest("button[data-metric]");
if (!b || b.dataset.metric === chartMetric) return;
chartMetric = b.dataset.metric;
try { localStorage.setItem("thermograph:chartMetric", chartMetric); } catch (e) {}
buildChart(data);
});
attachChartHover(wrap, chartDays);
}
// ---- share ----
function copyShareLink() {
if (!selected) return;
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47) The page scripts each re-declared the shared presentation layer — the tier color table existed in four places (app.js, calendar.js, day.js, style.css) and the scale label tables in three (plus legend.html's own drifting copy), alongside per-page copies of the dryness ramp, formatters, ord, todayISO, esc, the weather icons/summary, month helpers and the 2-year range chunker. ~240 duplicated lines deleted (net -236 with the new module included). - frontend/shared.js (IIFE, extends window.Thermograph): tier colors read from style.css's :root custom properties at load — the CSS is now the single source of truth; the JS map exists only because inline-SVG work (chart + PNG export) needs literal values, with hex fallbacks for a missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord, todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker, and the weatherType summary (dsr-aware; the day page just omits dsr). - nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in the shared classic-script scope, and a leaked locHash collided with page destructuring (caught by the browser smoke, not by node --check). locHash is now exported and used by all 8 former hand-built hash sites. - mappicker.js: initFindButton/setFindLabel replace the Find-button block each page rebuilt. - legend.html renders its scales from the shared tables, so the guide can no longer drift from what the app shows. Verified: node --check on all JS; 108 backend tests; headless-Chromium smoke over all five pages against live data — no console/page errors, legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle, calendar grid + key + metric switch, day 7 ladders + weather icon, compare seeded rank card.
2026-07-11 20:21:48 +00:00
const url = `${location.origin}${location.pathname}${locHash(selected.lat, selected.lon, dateInput.value)}`;
navigator.clipboard.writeText(url).then(
() => flashBtn("btn-link", "✓ Copied!"),
() => { prompt("Copy this link:", url); }
);
}
// Build a table of graded days as SVG markup, stacked below the chart.
function exportTableSvg(C) {
const rows = lastGraded.recent; // newest first (matches the on-screen table)
const rowH = 27, headH = 42, padB = 16;
const top = H + 10;
const cDate = PL, cPcp = PL + 150, cHigh = PL + 322, cLow = PL + 494;
const totalH = top + headH + rows.length * rowH + padB;
const cell = (x, g, isPcp) => {
if (!g) return `<text x="${x}" y="0" font-size="12" fill="${C.muted}">—</text>`;
const color = TIER_COLORS[g.class] || C.ink;
const v = isPcp ? `${g.value.toFixed(2)}"` : fmtTemp(g.value);
return `<text x="${x}" y="0"><tspan font-size="13" font-weight="700" fill="${C.ink}">${v}</tspan>` +
`<tspan dx="7" font-size="11" font-weight="600" fill="${color}">${esc(g.grade)}</tspan></text>`;
};
const th = (x, t) =>
`<text x="${x}" y="${top + 30}" font-size="10.5" font-weight="700" letter-spacing="0.05em" fill="${C.muted}">${t}</text>`;
let body = "";
rows.forEach((d, i) => {
const y = top + headH + i * rowH;
const dt = new Date(d.date + "T00:00:00");
const dstr = dt.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
body += `<g transform="translate(0 ${y})">` +
(i ? `<line x1="${PL}" y1="-7" x2="${W - PR}" y2="-7" stroke="${C.grid}" stroke-width="1"/>` : "") +
`<text x="${cDate}" y="0" font-size="12.5" fill="${C.ink}">${esc(dstr)}</text>` +
cell(cPcp, d.precip, true) + cell(cHigh, d.tmax, false) + cell(cLow, d.tmin, false) +
`</g>`;
});
const markup = `
<text x="${PL}" y="${top + 8}" font-size="13" font-weight="700" fill="${C.ink}">Recent days, graded: ${esc(placeLabel(lastGraded))}</text>
${th(cDate, "DATE")}${th(cPcp, "PRECIP")}${th(cHigh, "HIGH")}${th(cLow, "LOW")}
<line x1="${PL}" y1="${top + headH - 9}" x2="${W - PR}" y2="${top + headH - 9}" stroke="${C.grid}" stroke-width="1.5"/>
${body}`;
return { markup, totalH };
}
function downloadChartPng() {
const chart = document.querySelector("#chart-wrap svg");
if (!chart || !lastGraded) return;
const C = chartPalette();
// Chart body minus interactive-only layers.
const clone = chart.cloneNode(true);
const cross = clone.querySelector("#crosshair"); if (cross) cross.remove();
clone.querySelectorAll("rect.hit").forEach((r) => r.remove());
const chartInner = clone.innerHTML;
const { markup, totalH } = exportTableSvg(C);
const xml = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${totalH}" width="${W * 2}" height="${totalH * 2}" font-family="system-ui, -apple-system, Segoe UI, sans-serif">` +
`<rect x="0" y="0" width="${W}" height="${totalH}" fill="${C.surface}"/>` +
chartInner + markup + `</svg>`;
const url = URL.createObjectURL(new Blob([xml], { type: "image/svg+xml;charset=utf-8" }));
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = W * 2; canvas.height = totalH * 2;
canvas.getContext("2d").drawImage(img, 0, 0);
URL.revokeObjectURL(url);
canvas.toBlob((blob) => {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `thermograph_${dateInput.value}.png`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
flashBtn("btn-png", "✓ Saved");
}, "image/png");
};
img.src = url;
}
function flashBtn(id, text) {
const b = document.getElementById(id);
if (!b) return;
const orig = b.innerHTML; // preserve the inline icon markup
b.textContent = text;
setTimeout(() => (b.innerHTML = orig), 1600);
}
function updateHash() {
if (!selected) return;
Extract shared.js: one home for tier colors, scales, formatters, helpers (#47) The page scripts each re-declared the shared presentation layer — the tier color table existed in four places (app.js, calendar.js, day.js, style.css) and the scale label tables in three (plus legend.html's own drifting copy), alongside per-page copies of the dryness ramp, formatters, ord, todayISO, esc, the weather icons/summary, month helpers and the 2-year range chunker. ~240 duplicated lines deleted (net -236 with the new module included). - frontend/shared.js (IIFE, extends window.Thermograph): tier colors read from style.css's :root custom properties at load — the CSS is now the single source of truth; the JS map exists only because inline-SVG work (chart + PNG export) needs literal values, with hex fallbacks for a missing stylesheet. Plus SCALE_TEMP/SCALE_RAIN, drynessColor, fmt*, ord, todayISO, esc, placeLabel, month/chunk date helpers, clickOpensPicker, and the weatherType summary (dsr-aware; the day page just omits dsr). - nav.js: wrapped in an IIFE — its ~15 top-level functions were globals in the shared classic-script scope, and a leaked locHash collided with page destructuring (caught by the browser smoke, not by node --check). locHash is now exported and used by all 8 former hand-built hash sites. - mappicker.js: initFindButton/setFindLabel replace the Find-button block each page rebuilt. - legend.html renders its scales from the shared tables, so the guide can no longer drift from what the app shows. Verified: node --check on all JS; 108 backend tests; headless-Chromium smoke over all five pages against live data — no console/page errors, legend rows 9/9, weekly 7 cards + colored chart/key/table + metric toggle, calendar grid + key + metric switch, day 7 ladders + weather icon, compare seeded rank card.
2026-07-11 20:21:48 +00:00
history.replaceState(null, "", locHash(selected.lat, selected.lon, dateInput.value));
2026-07-11 20:28:33 +00:00
saveLastLocation(selected.lat, selected.lon, dateInput.value);
}
// Start where you left off: an explicit link (hash) wins, otherwise the last
Worldwide coverage: grade any point on Earth (#32) Remove the US+Canada bounding box so every endpoint accepts any lat/lon. The grading pipeline was already global-ready (ERA5 archive, timezone=auto, day-of-year climatology), so opening it up is mostly deleting the guard — plus the edge cases that only exist once the whole globe is in play: - grid.py: snap() wraps longitude into [-180, 180) and clamps latitude, and cell centers are normalized so the polar row and the cells straddling the antimeridian always report valid coordinates to the weather/geocoding APIs. snap() and from_id() now share one _cell() builder, making id round-trips exact by construction (verified with a 300k-point global sweep). - nav.js: neighbor-cell prefetch skips rows past the poles and wraps longitudes across the dateline instead of sending out-of-range queries. - Nominatim reverse geocoding requests accept-language=en so place labels render in one script worldwide (matching the forward geocoder). - mappicker: search suggestions are no longer filtered to US/CA, the placeholder and default map view are worldwide. - calendar: season filter labels flip for southern-hemisphere locations (Dec-Feb shows as Summer); the underlying month groups are unchanged, so saved filter selections keep meaning the same months. Verified end-to-end on a scratch server: Tokyo and Sydney grade with real labels, a Fiji cell on the antimeridian's east edge builds and serves warm hits from the derived store, and prefetch=1 on a cold cell still answers 204 without spending weather-API quota.
2026-07-11 15:02:28 +00:00
// place you looked at (remembered across views), otherwise the default world map.
function restoreFromHash() {
2026-07-11 20:28:33 +00:00
const loc = loadLastLocation();
if (!loc) return;
if (loc.date) dateInput.value = loc.date;
syncTodayBtn();
selectLocation(loc.lat, loc.lon);
}
restoreFromHash();