thermograph/frontend/static/calendar.js

774 lines
39 KiB
JavaScript
Raw Normal View History

// Thermograph calendar subpage — two years of daily grades as a month-grid heatmap.
2026-07-11 20:28:33 +00:00
// Talks to the v2 API (/api/v2/calendar, /api/v2/geocode).
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
// The tooltip reads fmtTemp live, so a unit switch shows on the next hover.
import { fmtTemp, onUnitChange } from "./units.js";
import { uv } from "./account.js"; // header sign-in entry + notification bell, and the API-version resolver
import { TTL, chunkedFetch, prefetchViews } from "./cache.js";
2026-07-11 20:28:33 +00:00
import { initFindButton, setFindLabel } from "./mappicker.js";
import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, pctOrd,
Make trace-rain days read consistently across every surface Since the dry/rain grading split moved to precip > 0, a sub-0.01" reanalysis "trace" day is graded as a rain tier but its depth rounds to 0.00" / 0 mm, and several surfaces still treated it as dry — a day would read "0.0" · Light rain, N days since rain" at once. Align every consumer of a graded precip day with the > 0 split so a trace day reads as the (very light) rain it was graded to be. - Dry streak: dry_streaks and longest_dry_streak reset on any rain (> 0), not the 0.01" rain-frequency line, so a trace day breaks the streak and its "days since rain" no longer keeps climbing. (The rain_freq climatology stat keeps the 0.01" measurable-rain convention.) - Display: new fmtPrecipTier() prints "trace" for a rain-tier day whose depth rounds to zero, rather than a bone-dry "0.00". Used on the calendar tooltip, the day-page observation + ladder marker, and the recent/forecast table. - weatherType() decides wet/dry from the grade class when it has it (a rain tier means it rained even at trace depth), falling back to depth > 0; callers on the calendar and day page pass the class. - Recent-table and chart precip dots key their dry-vs-rain rendering on the grade class instead of value > 0, so a trace day tints as rain, not dry. Rain chart fan: the precipitation fan still used the pre-split colour map, painting the whole 90-99 rain-day-percentile region one shade and 75-90 a tier too dark. _band_stats emits a p95 mark (additive; unused by the temperature fan) and RAIN_FAN remaps to the eight tiers -- 95-99 Severe, 90-95 Very Heavy, 60-90 Heavy -- with a p95 fallback in pget so an older cached payload still renders.
2026-07-24 23:07:14 +00:00
placeLabel, fmtPrecip, fmtPrecipTier, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd,
CHUNK_MONTHS, buildChunks, clickOpensPicker, metricBuckets, distStrip,
seasonFilterDropdown, seasonSummaryText, syncSeasonChecks,
applySeasonMonthChange, initSeasonExpand, allMonths, monthsToMask, maskToMonths,
tierKeySegs, GUIDE_LINK, weatherType as wxType, DRY_CAP } from "./shared.js";
import { initFilterSheet } from "./filtersheet.js";
// The diverging-temperature-scale metrics (colored exactly like High/Low), with a
// display name for the key + tooltip and the formatter for their values.
const TEMP_METRIC_INFO = {
tmax: { name: "High", label: "daily high", fmt: fmtTemp },
tmin: { name: "Low", label: "daily low", fmt: fmtTemp },
feels: { name: "Feels", label: "feels like (apparent temperature)", fmt: fmtTemp },
// The unit lives in the strip's own top-right label now, so the metric name
// doesn't repeat it (every other metric's title is unitless too).
humid: { name: "Humid", label: "absolute humidity", fmt: fmtHumid },
wind: { name: "Wind", label: "wind speed", fmt: fmtWind },
gust: { name: "Gust", label: "wind gust", fmt: fmtWind },
};
const IC_POINTER = `<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="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/></svg>`;
// The filterable day-type vocabulary (the weather filter's two checkbox groups).
// Keys match what weatherType() reports for each day.
const FEEL_WORDS = [
["scorching", "Scorching"], ["hot", "Hot"], ["warm", "Warm"], ["mild", "Mild"],
["cool", "Cool"], ["cold", "Cold"], ["frigid", "Frigid"],
];
const SKY_WORDS = [
["clear", "Clear"], ["dry", "Dry spell"], ["drizzle", "Light rain"],
["rain", "Rain"], ["downpour", "Downpour"], ["snow", "Snow"],
];
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
// The shared weather summary, adapted to the calendar's compact day records
// (dsr included, so a long-dry no-rain day reads "dry" rather than "clear").
const weatherType = (rec) =>
Make trace-rain days read consistently across every surface Since the dry/rain grading split moved to precip > 0, a sub-0.01" reanalysis "trace" day is graded as a rain tier but its depth rounds to 0.00" / 0 mm, and several surfaces still treated it as dry — a day would read "0.0" · Light rain, N days since rain" at once. Align every consumer of a graded precip day with the > 0 split so a trace day reads as the (very light) rain it was graded to be. - Dry streak: dry_streaks and longest_dry_streak reset on any rain (> 0), not the 0.01" rain-frequency line, so a trace day breaks the streak and its "days since rain" no longer keeps climbing. (The rain_freq climatology stat keeps the 0.01" measurable-rain convention.) - Display: new fmtPrecipTier() prints "trace" for a rain-tier day whose depth rounds to zero, rather than a bone-dry "0.00". Used on the calendar tooltip, the day-page observation + ladder marker, and the recent/forecast table. - weatherType() decides wet/dry from the grade class when it has it (a rain tier means it rained even at trace depth), falling back to depth > 0; callers on the calendar and day page pass the class. - Recent-table and chart precip dots key their dry-vs-rain rendering on the grade class instead of value > 0, so a trace day tints as rain, not dry. Rain chart fan: the precipitation fan still used the pre-split colour map, painting the whole 90-99 rain-day-percentile region one shade and 75-90 a tier too dark. _band_stats emits a p95 mark (additive; unused by the temperature fan) and RAIN_FAN remaps to the eight tiers -- 95-99 Severe, 90-95 Very Heavy, 60-90 Heavy -- with a p95 fallback in pget so an older cached payload still renders.
2026-07-24 23:07:14 +00:00
wxType(rec.tmax && rec.tmax.v, rec.precip && rec.precip.v, rec.dsr, rec.precip && rec.precip.c);
let selected = null; // {lat, lon}
let data = null; // last /api/v2/calendar response
// Coloring metric. Persisted so a refresh keeps your selection.
let metric = localStorage.getItem("thermograph:calMetric") || "tmax"; // tmax|tmin|feels|wind|gust|precip|dsr
// Time-of-year filter: only months that are checked (AND whose year is checked) are
// shown. Seasons are the primary, color-coded selector and months a nested suboption,
// but the single source of truth is this Set of month indices (0=Jan … 11=Dec); a
// season rolls up its three months. Southern-hemisphere labels flip (handled by the
// shared helpers via `southern`). Persisted so a refresh keeps your selection.
function loadCalMonths() {
try { const m = maskToMonths(localStorage.getItem("thermograph:calMonths")); if (m && m.size) return m; } catch (e) {}
return allMonths();
}
let checkedMonths = loadCalMonths();
const southern = () => !!(selected && selected.lat < 0);
const persistCalMonths = () => { try { localStorage.setItem("thermograph:calMonths", monthsToMask(checkedMonths)); } catch (e) {} };
let checkedYears = null; // reset to all available on each fetch
// Weather filter (day feel × sky). Unlike seasons/years it filters DAYS, not
// months: a day must match a checked feel AND a checked sky, or it's dimmed on
// the grid (and left out of the category totals) — a "find days like this" tool.
let checkedFeels = new Set(FEEL_WORDS.map((w) => w[0]));
let checkedSkies = new Set(SKY_WORDS.map((w) => w[0]));
const weatherFilterActive = () =>
checkedFeels.size < FEEL_WORDS.length || checkedSkies.size < SKY_WORDS.length;
function weatherPass(rec) {
const wt = weatherType(rec);
return (!wt.feel || checkedFeels.has(wt.feel)) && checkedSkies.has(wt.sky);
}
// Custom date range (ISO strings). Defaults to January six years back → today; the
// frontend splits any span into ~2-year server requests (see fetchCalendar), so a
// 6-year default loads as a few chunks rather than the old server-capped last-24.
const defaultCalStart = () => isoOfDate(new Date(new Date().getFullYear() - 6, 0, 1));
const defaultCalEnd = () => isoOfDate(new Date());
let rangeStart = defaultCalStart(), rangeEnd = defaultCalEnd();
// The full requested span (drives the filters + pickers); data.range tracks only
// the portion graded so far, which grows as chunks arrive.
let reqStart = null, reqEnd = null;
// Bumped on every new fetch so an in-flight chunked load abandons itself when a
// newer selection/range supersedes it.
let fetchToken = 0;
// The last range the user explicitly picked, persisted so a page refresh (or a
// return visit) keeps the same dates instead of snapping back to the last 2 years.
const CAL_RANGE_KEY = "thermograph:calRange";
function saveCalRange(s, e) {
try {
if (s && e) localStorage.setItem(CAL_RANGE_KEY, JSON.stringify({ start: s, end: e }));
else localStorage.removeItem(CAL_RANGE_KEY);
} catch (err) {}
}
function loadCalRange() {
try {
const o = JSON.parse(localStorage.getItem(CAL_RANGE_KEY));
if (o && o.start && o.end) return o;
} catch (err) {}
return null;
}
// The shared "selected day" (thermograph:loc.date) that the calendar last aligned
// its To-month to. Lets restore tell a plain refresh (day unchanged since we set
// it) apart from a real change made on the Weekly/Day view — needed because a
// future To-month clamps the shared day to today, so day-month ≠ To-month.
const CAL_SYNC_KEY = "thermograph:calDateSync";
function getCalSync() { try { return localStorage.getItem(CAL_SYNC_KEY); } catch (e) { return null; } }
function setCalSync(d) { try { d ? localStorage.setItem(CAL_SYNC_KEY, d) : localStorage.removeItem(CAL_SYNC_KEY); } catch (e) {} }
// Day-open interaction state (see attachHover). Module-scoped so the "tap outside
// to dismiss" listener can be registered once, not re-added on every re-render.
let armedCell = null, lastPointer = "mouse";
const placeholder = document.getElementById("cal-placeholder");
const calHead = document.getElementById("cal-head");
const calEl = document.getElementById("calendar");
const keyEl = document.getElementById("cal-key");
// A click-activated (touch) tooltip stays up until something that isn't a day is
// tapped: tapping another day moves it (that cell's own handler re-shows it),
// while tapping empty space, a header, the key, or anywhere off the grid dismisses
// it and cancels a pending "second tap to open". Registered once (not per re-render).
document.addEventListener("pointerdown", (e) => {
if (!e.target.closest(".cal-cell.filled")) {
document.getElementById("cal-tip").hidden = true;
armedCell = null;
}
});
// Mouse only: hovering off the grid hides the preview. On touch the tooltip is
// click-activated and must persist until a non-day tap (the handler above).
// Registered once — #calendar persists across re-renders (only its cells are
// replaced), so registering inside attachHover would stack a copy per render.
calEl.addEventListener("pointerleave", (e) => {
if (e.pointerType === "mouse") document.getElementById("cal-tip").hidden = true;
});
// ---- 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));
// ---- metric toggle ----
// An inline selector (above the grid) plus a synced copy in the filter sheet; the sheet
// copy mirrors the inline 8 buttons (cloned). Either drives the shared `metric`.
document.getElementById("metric-toggle-sheet").innerHTML =
document.getElementById("metric-toggle").innerHTML;
const syncColorButtons = () =>
document.querySelectorAll("#metric-toggle button, #metric-toggle-sheet button")
.forEach((b) => b.classList.toggle("active", b.dataset.metric === metric));
function setColorMetric(m) {
metric = m;
try { localStorage.setItem("thermograph:calMetric", metric); } catch (err) {}
syncColorButtons();
renderCalendar();
renderKey();
}
syncColorButtons(); // reflect the restored metric on both (the HTML defaults to High)
for (const id of ["metric-toggle", "metric-toggle-sheet"]) {
document.getElementById(id).addEventListener("click", (e) => {
const btn = e.target.closest("button[data-metric]");
if (btn) setColorMetric(btn.dataset.metric);
});
}
// ---- category totals: share vs. count toggle ----
// The strip normally prints each category's share; ticking "Show count" swaps every
// figure for the raw day count. State is persisted and applied by renderTotals; the
// checkbox lives inside the (re-rendered) totals block, so the listener is delegated
// on the stable #cal-totals container and re-renders from the last shown days.
let showCount = false;
try { showCount = localStorage.getItem("thermograph:calShowCount") === "1"; } catch (e) {}
let lastVisible = null;
document.getElementById("cal-totals").addEventListener("change", (e) => {
if (!e.target.closest("#ct-count-cb")) return;
showCount = e.target.checked;
try { localStorage.setItem("thermograph:calShowCount", showCount ? "1" : "0"); } catch (err) {}
if (lastVisible) renderTotals(lastVisible);
});
// The totals chart now prints each category's average + range, so it follows the
// °C/°F toggle (the grid itself stays tier-colored and reads temps live in tooltips).
onUnitChange(() => { if (lastVisible) renderTotals(lastVisible); });
// ---- season / year filters ----
const filtersEl = document.getElementById("cal-filters");
const seasonYearEl = document.getElementById("cal-seasonyear");
function calYears() {
// Years span the full requested range, not just the portion graded so far, so
// the Years dropdown is complete from the first chunk on.
const sy = new Date((reqStart || data.range.start) + "T00:00:00").getFullYear();
const ey = new Date((reqEnd || data.range.end) + "T00:00:00").getFullYear();
const out = []; for (let y = sy; y <= ey; y++) out.push(y);
return out;
}
let filterYears = []; // the year set behind the Years dropdown (for its summary)
// A collapsed summary of a multi-select: "All" when everything's on, "None" when
// nothing is, otherwise the picked labels joined. (Seasons/months use the shared
// seasonSummaryText.)
function yearSummaryText() {
const n = checkedYears ? checkedYears.size : 0;
if (!filterYears.length || n === filterYears.length) return "All years";
if (n === 0) return "None";
return filterYears.filter((y) => checkedYears.has(y)).join(", ");
}
function weatherSummaryText() {
const allF = checkedFeels.size === FEEL_WORDS.length;
const allS = checkedSkies.size === SKY_WORDS.length;
if (allF && allS) return "All days";
if (checkedFeels.size === 0 || checkedSkies.size === 0) return "None";
const f = allF ? "Any feel" : FEEL_WORDS.filter((w) => checkedFeels.has(w[0])).map((w) => w[1]).join(", ");
const s = allS ? "any sky" : SKY_WORDS.filter((w) => checkedSkies.has(w[0])).map((w) => w[1]).join(", ");
return `${f} · ${s}`;
}
function updateFilterSummaries() {
const s = seasonYearEl.querySelector('[data-dd="season"] .cal-dd-sum');
const y = seasonYearEl.querySelector('[data-dd="year"] .cal-dd-sum');
const w = seasonYearEl.querySelector('[data-dd="weather"] .cal-dd-sum');
if (s) s.textContent = seasonSummaryText(checkedMonths, { southern: southern() });
if (y) y.textContent = yearSummaryText();
if (w) w.textContent = weatherSummaryText();
}
// One filter as a compact dropdown: a summary button that expands to the checkbox
// list. Uses <details> so it needs no extra open/close wiring.
const filterDropdown = (dd, label, attr, items, summary) => `
<details class="cal-dd" data-dd="${dd}">
<summary>
<span class="cal-filter-label">${label}</span>
<span class="cal-dd-sum">${summary}</span>
<span class="cal-dd-caret" aria-hidden="true"></span>
</summary>
<div class="cal-dd-panel">
${items.map(([val, lab, on]) =>
`<label class="cal-dd-opt"><input type="checkbox" data-${attr}="${val}"${on ? " checked" : ""}>${lab}</label>`).join("")}
</div>
</details>`;
// The weather filter: one dropdown, two checkbox groups (day feel + sky), so a
// pick like "Mild or Warm · Clear" finds exactly the days you'd love. Sits after
// (below) the season/year dropdowns and spans the full filter row.
const weatherDropdown = () => `
<details class="cal-dd cal-weather" data-dd="weather">
<summary>
<span class="cal-filter-label">Weather</span>
<span class="cal-dd-sum">${weatherSummaryText()}</span>
<span class="cal-dd-caret" aria-hidden="true"></span>
</summary>
<div class="cal-dd-panel">
<div class="cal-dd-group">Day feel, from the daily high</div>
${FEEL_WORDS.map(([val, lab]) =>
`<label class="cal-dd-opt"><input type="checkbox" data-wfeel="${val}"${checkedFeels.has(val) ? " checked" : ""}>${lab}</label>`).join("")}
<div class="cal-dd-group">Sky</div>
${SKY_WORDS.map(([val, lab]) =>
`<label class="cal-dd-opt"><input type="checkbox" data-wsky="${val}"${checkedSkies.has(val) ? " checked" : ""}>${lab}</label>`).join("")}
</div>
</details>`;
function renderFilters() {
const years = calYears();
checkedYears = new Set(years); // reset to all available whenever the range changes
filterYears = years;
filtersEl.hidden = false;
// The month/year range picker stays as static HTML (keeps its listeners); only the
// season/year/weather dropdowns are rebuilt here, since the year set depends on
// the span (the weather sets survive the rebuild — they're module state).
seasonYearEl.innerHTML =
seasonFilterDropdown(checkedMonths, { southern: southern() }) +
filterDropdown("year", "Years", "year",
years.map((y) => [y, y, checkedYears.has(y)]), yearSummaryText()) +
weatherDropdown();
syncSeasonChecks(seasonYearEl, checkedMonths); // set the season indeterminate state
}
filtersEl.addEventListener("change", (e) => {
const cb = e.target.closest("input[type=checkbox]");
if (!cb) return;
if (applySeasonMonthChange(cb, checkedMonths)) { // a season or month toggle
syncSeasonChecks(seasonYearEl, checkedMonths);
persistCalMonths();
if (calSheet) calSheet.setActive(checkedMonths.size < 12);
} else if (cb.dataset.year) {
const y = +cb.dataset.year;
cb.checked ? checkedYears.add(y) : checkedYears.delete(y);
} else if (cb.dataset.wfeel) {
cb.checked ? checkedFeels.add(cb.dataset.wfeel) : checkedFeels.delete(cb.dataset.wfeel);
} else if (cb.dataset.wsky) {
cb.checked ? checkedSkies.add(cb.dataset.wsky) : checkedSkies.delete(cb.dataset.wsky);
}
updateFilterSummaries();
renderCalendar();
});
// Wire the per-season "Months" expand toggles (delegated on the stable container),
// and the mobile floating pill that slides the whole filter panel up as a sheet.
initSeasonExpand(seasonYearEl);
const calSheet = initFilterSheet({ panel: filtersEl, label: "Filters" });
calSheet.setActive(checkedMonths.size < 12);
// Close any open filter dropdown when tapping outside it (native <details> stays
// open otherwise). Doesn't touch the day tooltip's own outside-tap handler.
document.addEventListener("pointerdown", (e) => {
seasonYearEl.querySelectorAll("details.cal-dd[open]").forEach((d) => {
if (!d.contains(e.target)) d.open = false;
});
}, true);
// ---- date-range picker (month + year only) ----
// The inputs are <input type="month"> ("YYYY-MM"); a picked month is expanded to a
// full-month ISO span so every displayed month is shown in full (no partial weeks).
const startInput = document.getElementById("range-start");
const endInput = document.getElementById("range-end");
const refreshBtn = document.getElementById("range-refresh");
// Selectable bounds: 1970 through the current year. Fixed (not derived from the
// loaded span) so the To field always reaches the present; the actual data starts
// ~1980 and ends a few days ago, and out-of-range picks are clamped server-side.
startInput.min = endInput.min = "1970-01";
startInput.max = endInput.max = `${new Date().getFullYear()}-12`;
// Editing a date doesn't fetch — it reveals the Refresh button so the user
// confirms before a (possibly multi-request) load kicks off.
function markRangeDirty() { refreshBtn.hidden = false; }
startInput.addEventListener("change", markRangeDirty);
endInput.addEventListener("change", markRangeDirty);
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
clickOpensPicker(startInput, endInput); // whole-field tap opens the month picker
// Confirm: read the pickers into the active range and (re)load.
function applyRange() {
let s = startInput.value ? monthStart(startInput.value) : null;
let e = endInput.value ? monthEnd(endInput.value) : null;
if (s && e && s > e) { const t = s; s = e; e = t; } // keep start ≤ end
rangeStart = s; rangeEnd = e;
saveCalRange(s, e); // keep these dates across refreshes
syncDayToOtherViews(); // push the To-month to Weekly/Day
refreshBtn.hidden = true;
if (selected) fetchCalendar();
}
refreshBtn.addEventListener("click", applyRange);
// A To-Date change updates the day the Weekly/Day views open on: the 1st of the
// To month, or today if that month is still in the future (no data ahead of today).
function syncDayToOtherViews() {
if (!selected || !rangeEnd) return;
const toFirst = rangeEnd.slice(0, 7) + "-01";
const todayIso = isoOfDate(new Date());
const day = toFirst > todayIso ? todayIso : toFirst;
2026-07-11 20:28:33 +00:00
saveLastLocation(selected.lat, selected.lon, day);
setCalSync(day);
}
// ---- fetch + render ----
function selectLocation(lat, lon) {
selected = { lat, lon };
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(lat, lon));
2026-07-11 20:28:33 +00:00
saveLastLocation(lat, lon); // keep date for the other views
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; they're replaced with the resolved
// neighborhood + city name once the first calendar chunk lands.
locLabel.textContent = `${lat.toFixed(3)}, ${lon.toFixed(3)}`;
locLabel.hidden = false;
fetchCalendar();
}
// Up to this many chunk requests are in flight at once. The server grades every
// chunk against one shared, already-cached history record, so a few parallel
// requests cut wall-clock time on long spans without extra upstream fetches.
const CHUNK_CONCURRENCY = 4;
async function fetchCalendar() {
if (!selected) return;
const token = ++fetchToken; // supersede any in-flight chunked load
placeholder.hidden = true;
refreshBtn.hidden = true;
calHead.hidden = false;
data = null;
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
// Delay the spinner: cache-served chunks render in a few ms, so clearing the
// grid immediately would just flash-blank a warm load. A genuinely slow
// (network) load still gets the spinner and a cleared grid.
const spin = setTimeout(() => {
if (token !== fetchToken || data) return;
calHead.innerHTML = `<p class="spinner">Grading daily weather across the range…</p>`;
calEl.innerHTML = "";
keyEl.innerHTML = "";
}, 150);
const q = `lat=${selected.lat}&lon=${selected.lon}`;
// A custom span is split into ≤2-year requests; the default (no range) is one
// last-24-months request that also discovers the latest available date.
const chunks = (rangeStart && rangeEnd) ? buildChunks(rangeStart, rangeEnd) : [null];
const dayMap = new Map();
const results = new Array(chunks.length); // per-chunk response (or {error}), by index
let donePrefix = 0; // chunks [0, donePrefix) are all loaded
// Set the location, pickers, and filters once — off whichever chunk lands first
// (cell/place are identical across chunks; the requested span drives the rest).
const initOnce = (d) => {
if (data) return;
data = d;
reqStart = rangeStart || d.range.start;
reqEnd = rangeEnd || d.range.end;
startInput.value = reqStart.slice(0, 7); // "YYYY-MM" for <input type=month>
endInput.value = reqEnd.slice(0, 7);
document.getElementById("metric-block").hidden = false;
locLabel.textContent = placeLabel(d);
locLabel.hidden = false;
2026-07-11 20:28:33 +00:00
setFindLabel(findBtn, "Change location");
renderFilters();
};
// Advance the visible span over the contiguous run of loaded chunks from the
// oldest, so the grid fills top-down with no gaps even when chunks finish out of
// order. A failed chunk halts the visible prefix at its position.
const advance = () => {
while (donePrefix < chunks.length && results[donePrefix] && !results[donePrefix].error) donePrefix++;
if (donePrefix === 0 || !data) return;
data.days = [...dayMap.values()];
data.range = { start: results[0].range.start, end: results[donePrefix - 1].range.end };
renderHead(chunks.length - donePrefix, true);
renderCalendar();
renderKey();
};
const urls = chunks.map((ch) => ch
? uv(`calendar?${q}&start=${ch.start}&end=${ch.end}`)
: uv(`calendar?${q}&months=24`));
// Bounded-parallel streaming load (see cache.js). Stale-while-revalidate
// updates re-merge that chunk's days and repaint — the cell/place metadata is
// identical across versions, and initOnce self-guards after the first chunk.
await chunkedFetch(urls, {
ttl: TTL.calendar,
concurrency: CHUNK_CONCURRENCY,
isCurrent: () => token === fetchToken,
onResult: (i, d) => {
results[i] = d;
if (!d.error) {
for (const x of d.days) dayMap.set(x.date, x);
initOnce(d);
}
advance();
},
});
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
clearTimeout(spin);
if (token !== fetchToken) return; // superseded while loading
if (!data) { // every chunk failed
const firstErr = results.find((r) => r && r.error);
calHead.innerHTML = `<p class="error">${firstErr ? firstErr.error.message : "Failed to load."}</p>`;
return;
}
renderHead(chunks.length - donePrefix, false); // final head (flags any gap)
2026-07-11 20:28:33 +00:00
prefetchViews(selected.lat, selected.lon, ["calendar"]); // warm weekly/day/forecast
}
// Header summary. While `loading`, `remaining` > 0 shows a "loading N more blocks"
// note; once loading has finished, a leftover `remaining` flags chunks that failed.
function renderHead(remaining, loading) {
const place = placeLabel(data);
const years = ((new Date(data.range.end) - new Date(data.range.start)) / 3.15576e10);
let note = "";
if (remaining > 0 && loading) {
note = ` · <span class="cal-loading">loading ${remaining} more block${remaining === 1 ? "" : "s"}…</span>`;
} else if (remaining > 0) {
note = ` · <span class="error">${remaining} block${remaining === 1 ? "" : "s"} failed to load</span>`;
}
calHead.innerHTML = `
<h2>${place}</h2>
<p class="meta">${data.range.start} ${data.range.end} · ${data.days.length.toLocaleString()} days graded
(~${years.toFixed(1)} yr)${note}</p>
<p class="cal-hint">${IC_POINTER} Tap or click any day for its weather and grades</p>`;
}
function renderCalendar() {
if (!data) return;
const byDate = new Map(data.days.map((d) => [d.date, d]));
const start = new Date(data.range.start + "T00:00:00");
const end = new Date(data.range.end + "T00:00:00");
let y = start.getFullYear(), m = start.getMonth();
const endY = end.getFullYear(), endM = end.getMonth();
let html = "";
const wActive = weatherFilterActive();
const visible = []; // every graded day in a shown month: {rec, pass}
while (y < endY || (y === endY && m <= endM)) {
// Only render months that are fully inside the graded range — never a partial
// boundary month showing a stray day or two. The range is month-based, so a
// clipped edge month (e.g. data starting mid-June) is dropped entirely.
const mFirst = new Date(y, m, 1), mLast = new Date(y, m + 1, 0);
if (mFirst < start || mLast > end) {
m++; if (m > 11) { m = 0; y++; }
continue;
}
// Time-of-year / year filters: skip months whose month or year is unchecked.
if (!checkedMonths.has(m) || (checkedYears && !checkedYears.has(y))) {
m++; if (m > 11) { m = 0; y++; }
continue;
}
const lead = new Date(y, m, 1).getDay(); // 0 = Sunday
const dim = new Date(y, m + 1, 0).getDate();
let cells = "";
for (let i = 0; i < lead; i++) cells += `<div class="cal-cell empty"></div>`;
for (let day = 1; day <= dim; day++) {
const iso = `${y}-${String(m + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const rec = byDate.get(iso);
let bg = "", pass = true;
if (rec) {
if (metric === "dsr") {
bg = drynessColor(rec.dsr);
} else if (metric === "precip") {
const g = rec.precip;
// Dry days take their dry-streak color; rainy days take the blue ramp.
bg = !g ? "" : g.c === "dry" ? drynessColor(rec.dsr) : PRECIP_COLORS[g.c];
} else {
const g = rec[metric];
bg = g ? TIER_COLORS[g.c] : "";
}
pass = !wActive || weatherPass(rec);
visible.push({ rec, pass });
}
// Days with no graded record (outside the range) render transparent, not as a
// grey tile — so every displayed month reads as clean color with no dead squares.
const num = `<span class="cal-daynum">${day}</span>`;
cells += rec
? `<div class="cal-cell filled${pass ? "" : " dim"}" data-date="${iso}"${bg ? ` style="background:${bg}"` : ""}>${num}</div>`
: `<div class="cal-cell empty">${num}</div>`;
}
html += `<div class="cal-month"><h4>${MONTHS[m]} ${y}</h4>
<div class="cal-dows"><span>S</span><span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span></div>
<div class="cal-grid">${cells}</div></div>`;
m++; if (m > 11) { m = 0; y++; }
}
calEl.innerHTML = html || `<p class="muted">No months match the selected months/years.</p>`;
renderCalInsights();
renderTotals(visible);
attachHover(byDate);
}
const FULL_MONTHS = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"];
// Plain-language climatology insights for the currently selected month(s), built
// from `data.months_climate` (per-month rain/sun frequencies + typical highs over
// the full history). Includes a cross-month "Nx more likely to rain" comparison
// against the driest month of the year — the numbers travelers actually plan on.
function renderCalInsights() {
const el = document.getElementById("cal-insights");
const mc = data && data.months_climate;
const sel = mc ? [...checkedMonths].sort((a, b) => a - b) : []; // 0-based indices
const entries = sel.map((mi) => mc[String(mi + 1)]).filter(Boolean);
if (!entries.length) { el.hidden = true; el.innerHTML = ""; return; }
const avg = (a) => (a.length ? a.reduce((s, x) => s + x, 0) / a.length : null);
const tmax = avg(entries.map((e) => e.tmax_med).filter((v) => v != null));
const rainFreq = avg(entries.map((e) => e.rain_freq).filter((v) => v != null));
const sunFreq = avg(entries.map((e) => e.sun_freq).filter((v) => v != null));
const rainyDays = entries.map((e) => e.rainy_days).filter((v) => v != null)
.reduce((s, x) => s + x, 0);
const label = seasonSummaryText(checkedMonths, { southern: southern() });
const lines = [];
if (tmax != null) {
lines.push(`<li><span class="ins-emoji" aria-hidden="true">🌡️</span> Typical highs near <b>${fmtTemp(tmax, true)}</b>.</li>`);
}
if (sunFreq != null) {
const pct = Math.round(sunFreq * 100);
const emoji = sunFreq >= 0.6 ? "☀️" : sunFreq >= 0.3 ? "⛅" : "☁️";
lines.push(`<li><span class="ins-emoji" aria-hidden="true">${emoji}</span> Sunny about <b>${pct}%</b> of days.</li>`);
}
if (rainFreq != null) {
let emoji, advice;
if (rainFreq < 0.15) {
emoji = "🌤️"; advice = "mostly dry — you can probably leave the umbrella at home.";
} else if (rainFreq < 0.4) {
emoji = "🌦️"; advice = "pack a compact umbrella just in case.";
} else {
emoji = "🌧️"; advice = "bring your favorite umbrella or raincoat!";
}
lines.push(`<li class="ins-rain"><span class="ins-emoji" aria-hidden="true">${emoji}</span> About <b>${Math.round(rainyDays)}</b> rainy days across ${label}${advice}</li>`);
// Cross-month comparison: selected months vs the driest single month of the
// year (skip when the whole year is selected, or the multiple is trivial).
if (checkedMonths.size < 12) {
let driestK = null, driestFreq = Infinity;
for (const [k, e] of Object.entries(mc)) {
if (e.rain_freq != null && e.rain_freq < driestFreq) { driestFreq = e.rain_freq; driestK = k; }
}
if (driestK && driestFreq > 0) {
const mult = rainFreq / driestFreq;
if (mult >= 1.5) {
const dName = FULL_MONTHS[+driestK - 1];
lines.push(`<li><span class="ins-emoji" aria-hidden="true">📊</span> You're about <b>${mult.toFixed(mult < 10 ? 1 : 0)}×</b> more likely to see rain than in ${dName}.</li>`);
}
}
}
}
if (!lines.length) { el.hidden = true; el.innerHTML = ""; return; }
el.innerHTML = `<p class="insight-lead">Historical weather for ${label}</p>
<ul class="insight-list">${lines.join("")}</ul>`;
el.hidden = false;
}
// ---- category totals ----
// "What share of the shown days lands in each category?" for the current metric,
// rendered above the grid as a stacked share bar + per-category percentages.
// When the weather filter is narrowing, only matching days are totaled (and the
// title says how many matched) — so it doubles as a "days you'd love" counter.
function renderTotals(visible) {
const totEl = document.getElementById("cal-totals");
lastVisible = visible; // remembered so the "Show count" toggle can re-render in place
if (!visible.length) { totEl.hidden = true; totEl.innerHTML = ""; return; }
const wActive = weatherFilterActive();
const days = wActive ? visible.filter((v) => v.pass).map((v) => v.rec) : visible.map((v) => v.rec);
const title = metric === "dsr" ? "dry streak"
: metric === "precip" ? "rain intensity"
: (TEMP_METRIC_INFO[metric] || {}).label || "value";
// Category distribution (low→high) for the metric; the bucketing and the strip
// visual — the wet/dry scale-group split and the per-tier label hook — are shared
// with the compare page. Category names live in the color key below the grid too.
const buckets = metricBuckets(days, metric);
const total = buckets.reduce((n, b) => n + b.n, 0);
const head = wActive
? `${days.length.toLocaleString()} of ${visible.length.toLocaleString()} shown days match the weather filter, by ${title}:`
: `${visible.length.toLocaleString()} days shown, by ${title}:`;
// The strip prints each category's share by default, or the raw day count when the
// "Show count" checkbox (state: showCount) is ticked.
const countToggle =
`<label class="ct-count-toggle"><input type="checkbox" id="ct-count-cb"` +
`${showCount ? " checked" : ""} /> Show count</label>`;
const header = `<div class="ct-head"><div class="section-title">${head}</div>${countToggle}</div>`;
totEl.innerHTML = total
? `${header}\n ${distStrip(buckets, showCount)}`
: `${header}<p class="muted">No matching days.</p>`;
totEl.hidden = false;
}
function renderKey() {
if (metric === "dsr") {
const steps = [["Rain", 0], ["1 day", 1], ["4", 4], ["7", 7], ["10", 10], ["14+", 14]];
const segs = tierKeySegs(steps.map(([label, d]) => ({ color: drynessColor(d), label })));
keyEl.innerHTML = `<div class="section-title">Days since last rain: dry streak (caps at ${DRY_CAP} days)</div>
<div class="tier-key">${segs}</div>${GUIDE_LINK}`;
return;
}
if (metric === "precip") {
const rain = tierKeySegs([...SCALE_RAIN].reverse().map((t) =>
({ color: PRECIP_COLORS[t.c], label: t.label })));
const dry = tierKeySegs([["≤1 day", 1], ["7", 7], ["14+", 14]].map(([label, d]) =>
({ color: drynessColor(d), label })));
keyEl.innerHTML = `<div class="section-title">Rain intensity · light → heavy</div>
<div class="tier-key">${rain}</div>
<div class="section-title" style="margin-top:10px">Dry days · days since rain</div>
<div class="tier-key">${dry}</div>${GUIDE_LINK}`;
return;
}
const label = (TEMP_METRIC_INFO[metric] || {}).label || "value";
// 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 })));
keyEl.innerHTML = `<div class="section-title">Coloring by ${label} · low → high vs local normal</div>
<div class="tier-key">${segs}</div>${GUIDE_LINK}`;
}
// ---- tooltip ----
function attachHover(byDate) {
const tip = document.getElementById("cal-tip");
// The row for the metric currently being viewed is bolded and moved to the top,
// so it's easy to match a cell's color to the stat it represents. Its category
// (grade) text is tinted with the very color that represents that day's value —
// bold + saturated for the active metric, lifted/dimmed for the rest (see CSS).
// Each metric is one row of the mini grid: name · value(+pct) · category.
// `activeKey` is the row to bold: the metric currently being viewed.
let activeKey = metric;
const line = (key, name, g, fmt, color) => {
const rc = key === activeKey ? " tt-r-active" : "";
if (!g) return `<span class="tt-name${rc}">${name}</span><span class="tt-val${rc}">—</span><span class="tt-cat${rc}"></span>`;
const pct = g.pct == null ? "" : `<span class="tt-pct"> · ${pctOrd(g.pct)} pct</span>`;
const catCls = key === activeKey ? "tt-cat tt-cat-active" : "tt-cat";
const style = color ? ` style="--cat:${color}"` : "";
return `<span class="tt-name${rc}">${name}</span><span class="tt-val${rc}">${fmt(g.v)}${pct}</span><span class="${catCls}${rc}"${style}>${g.g}</span>`;
};
const show = (cell, e) => {
const rec = byDate.get(cell.dataset.date);
if (!rec) return;
activeKey = metric;
const dt = new Date(rec.date + "T00:00:00");
// Color each metric the same way the calendar cell would be colored for it.
const tempColor = (g) => (g ? TIER_COLORS[g.c] : "");
const precipColor = !rec.precip ? "" : rec.precip.c === "dry" ? drynessColor(rec.dsr) : PRECIP_COLORS[rec.precip.c];
const dsrStr = rec.dsr == null ? null
: rec.dsr === 0 ? "Rain today"
: `${rec.dsr} day${rec.dsr === 1 ? "" : "s"} since rain`;
const rows = [
["tmax", line("tmax", "High", rec.tmax, fmtTemp, tempColor(rec.tmax))],
["tmin", line("tmin", "Low", rec.tmin, fmtTemp, tempColor(rec.tmin))],
["feels", line("feels", "Feels", rec.feels, fmtTemp, tempColor(rec.feels))],
["humid", line("humid", "Humid", rec.humid, fmtHumid, tempColor(rec.humid))],
["wind", line("wind", "Wind", rec.wind, fmtWind, tempColor(rec.wind))],
["gust", line("gust", "Gust", rec.gust, fmtWind, tempColor(rec.gust))],
Make trace-rain days read consistently across every surface Since the dry/rain grading split moved to precip > 0, a sub-0.01" reanalysis "trace" day is graded as a rain tier but its depth rounds to 0.00" / 0 mm, and several surfaces still treated it as dry — a day would read "0.0" · Light rain, N days since rain" at once. Align every consumer of a graded precip day with the > 0 split so a trace day reads as the (very light) rain it was graded to be. - Dry streak: dry_streaks and longest_dry_streak reset on any rain (> 0), not the 0.01" rain-frequency line, so a trace day breaks the streak and its "days since rain" no longer keeps climbing. (The rain_freq climatology stat keeps the 0.01" measurable-rain convention.) - Display: new fmtPrecipTier() prints "trace" for a rain-tier day whose depth rounds to zero, rather than a bone-dry "0.00". Used on the calendar tooltip, the day-page observation + ladder marker, and the recent/forecast table. - weatherType() decides wet/dry from the grade class when it has it (a rain tier means it rained even at trace depth), falling back to depth > 0; callers on the calendar and day page pass the class. - Recent-table and chart precip dots key their dry-vs-rain rendering on the grade class instead of value > 0, so a trace day tints as rain, not dry. Rain chart fan: the precipitation fan still used the pre-split colour map, painting the whole 90-99 rain-day-percentile region one shade and 75-90 a tier too dark. _band_stats emits a p95 mark (additive; unused by the temperature fan) and RAIN_FAN remaps to the eight tiers -- 95-99 Severe, 90-95 Very Heavy, 60-90 Heavy -- with a p95 fallback in pget so an older cached payload still renders.
2026-07-24 23:07:14 +00:00
["precip", line("precip", "Precip", rec.precip, (v) => fmtPrecipTier(v, rec.precip && rec.precip.c), precipColor)],
];
if (dsrStr) {
const rc = metric === "dsr" ? " tt-r-active" : "";
const catCls = metric === "dsr" ? "tt-cat tt-cat-active" : "tt-cat";
// No separate percentile — let the streak text span the value + category columns.
rows.push(["dsr", `<span class="tt-name${rc}">Dry streak</span><span class="${catCls} tt-span2${rc}" style="--cat:${drynessColor(rec.dsr)}">${dsrStr}</span>`]);
}
// active metric first, the rest keep their order
rows.sort((a, b) => (b[0] === activeKey) - (a[0] === activeKey));
const wt = weatherType(rec);
tip.innerHTML = `<div class="tt-date">${dt.toLocaleDateString(undefined, { weekday: "short", year: "numeric", month: "short", day: "numeric" })}</div>
<div class="tt-type">${wt.icon} ${wt.text}</div>
<div class="tt-grid">${rows.map((r) => r[1]).join("")}</div>`;
tip.hidden = false;
const pad = 14, w = tip.offsetWidth || 170, h = tip.offsetHeight || 70;
let x = e.clientX + pad, ty = e.clientY + pad;
if (x + w > window.innerWidth) x = e.clientX - w - pad;
if (ty + h > window.innerHeight) ty = e.clientY - h - pad;
// Keep it on-screen even when flipped past an edge (narrow phone screens).
x = Math.max(pad, Math.min(x, window.innerWidth - w - pad));
ty = Math.max(pad, Math.min(ty, window.innerHeight - h - pad));
tip.style.left = x + "px";
tip.style.top = ty + "px";
};
// Clicking a day opens its full percentile breakdown. On a mouse, one click
// navigates (the tooltip is just a hover preview). On touch there's no hover, so
// the first tap only raises the tooltip and a second tap on the same day confirms.
const openDay = (cell) => {
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, cell.dataset.date)}`;
};
calEl.querySelectorAll(".cal-cell.filled").forEach((c) => {
c.addEventListener("pointerenter", (e) => { if (e.pointerType === "mouse") show(c, e); });
c.addEventListener("pointerdown", (e) => { lastPointer = e.pointerType; show(c, e); });
c.addEventListener("click", () => {
if (lastPointer === "mouse") return openDay(c);
if (armedCell === c) openDay(c); // second tap on the previewed day → open
else armedCell = c; // first tap only showed the tooltip
});
});
}
// ---- restore (hash from a shared link, else the last place you looked at) ----
(function restore() {
2026-07-11 20:28:33 +00:00
const loc = loadLastLocation();
if (!loc) return;
const saved = loadCalRange();
const sel = loc.date; // the shared selected day (from the hash or last-visited store)
if (sel && sel !== getCalSync()) {
// The selected day changed on another view → align the To-month to it. Keep the
// saved From unless the new To is at/before it, in which case pull From 2 years
// back so the window stays valid (start ≤ end). Month/year granularity only.
const d = new Date(sel + "T00:00:00");
const y = d.getFullYear(), m = d.getMonth();
rangeEnd = isoOfDate(new Date(y, m + 1, 0)); // last day of the To month
const toFirst = `${y}-${String(m + 1).padStart(2, "0")}-01`;
const savedFrom = saved && saved.start ? saved.start : null;
rangeStart = (savedFrom && toFirst > savedFrom)
? savedFrom // To after From → keep From
: isoOfDate(new Date(y - 6, m, 1)); // To ≤ From (or none) → same month, 6yr back
saveCalRange(rangeStart, rangeEnd);
setCalSync(sel);
} else if (saved) {
rangeStart = saved.start; rangeEnd = saved.end; // refresh / in-sync → keep range
}
selectLocation(loc.lat, loc.lon);
})();