Port f5fc288 Consolidate shared frontend render/fetch helpers from monorepo (drift reconciliation)

Ports monorepo PR #43: cache.js gains a shared loadView() (spinner-delay +
supersession + SWR wiring) used by app.js/score.js instead of each
duplicating that dance around getJSON, and shared.js gains tierKeySegs()/
GUIDE_LINK used by app.js/calendar.js instead of each hand-building the
tier-key markup. Resolved against this branch's uv()/API_VERSION work: every
fetch URL touched here stays routed through uv(...) (grade/score already
were) rather than reverting to a raw api/v2/... literal.
This commit is contained in:
Emi Griffith 2026-07-22 12:08:57 -07:00
parent 290187db6b
commit 0af523bc34
5 changed files with 68 additions and 78 deletions

View file

@ -2,13 +2,13 @@
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
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 { getJSON, TTL, prefetchViews } from "./cache.js";
import { loadView, TTL, prefetchViews } from "./cache.js";
import { initFindButton, setFindLabel } from "./mappicker.js";
import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette,
tempChart, precipChart, dryChart, attachChartHover } from "./chart.js";
import { track } from "./digest.js";
import { TIER_COLORS, SCALE_TEMP, drynessColor, pctOrd, esc, todayISO, placeLabel,
fmtPrecip, fmtWind, fmtHumid } from "./shared.js";
tierKeySegs, GUIDE_LINK, fmtPrecip, fmtWind, fmtHumid } from "./shared.js";
let selected = null; // {lat, lon}
@ -152,34 +152,16 @@ async function runGrade() {
updateHash();
placeholder.hidden = true;
results.hidden = false;
const seq = ++gradeSeq;
// Delay the spinner: a cache-served render lands in a few ms, so blanking the
// panel immediately would just flash. Only a genuinely slow (network) load
// ever shows it.
const spin = setTimeout(() => {
if (seq === gradeSeq) results.innerHTML = `<p class="spinner">Fetching decades of climate history…</p>`;
}, 150);
// Omit the date when it's today, so the URL (and cache key) matches the prefetch
// fired from the other views — a same-tab navigation then reuses the response.
const q = `lat=${selected.lat}&lon=${selected.lon}`;
const url = dateInput.value === todayISO() ? uv(`grade?${q}`) : uv(`grade?${q}&date=${dateInput.value}`);
let data;
try {
// Stale-while-revalidate: a stale cached copy renders immediately and the
// update callback repaints if the background revalidation finds new data.
data = await getJSON(url, TTL.grade, false, (upd) => {
if (seq === gradeSeq) render(upd);
});
} catch (err) {
clearTimeout(spin);
if (seq !== gradeSeq) return;
results.innerHTML = `<p class="error">${err.message}</p>`;
return;
}
clearTimeout(spin);
if (seq !== gradeSeq) return;
render(data);
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
@ -390,14 +372,10 @@ function scrollTableToTarget(host, target) {
function tierKeyHtml() {
// Highest tier first (Near Record hot → Near Record cold).
const segs = [...SCALE_TEMP].reverse().map((t) =>
`<div class="tk-seg">
<span class="tk-swatch" style="background:${TIER_COLORS[t.c]}"></span>
<span class="tk-label">${t.label}</span>
</div>`).join("");
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>
<p class="muted tk-note"><a href="legend">Full guide to metrics &amp; grades </a></p>`;
<div class="tier-key">${segs}</div>${GUIDE_LINK}`;
}
let chartDays = [];

View file

@ -140,6 +140,27 @@ export async function getJSON(url, ttlMs = 600000, persist = false, onUpdate = n
catch (e) { if (rec) return rec.d; throw e; } // offline/unreachable → stale beats an error
}
// A guarded stale-while-revalidate load for a single primary view (grade, score…).
// Centralizes the fiddly, easy-to-get-wrong bits shared by those loaders: a
// spinner delayed 150ms so a warm render never flashes it, supersession so a
// newer selection wins (`seq` is this call's captured token, `current()` the live
// one), an SWR repaint via getJSON's update callback, and clearing the spinner on
// both the success and error paths. `spinner`/`onData`/`onError` are caller UI
// callbacks; `onData` handles both the SWR update and the final result.
export async function loadView({ url, ttl, seq, current, spinner, onData, onError }) {
const spin = setTimeout(() => { if (seq === current()) spinner(); }, 150);
let data;
try {
data = await getJSON(url, ttl, false, (upd) => { if (seq === current()) onData(upd); });
} catch (err) {
clearTimeout(spin);
if (seq === current()) onError(err);
return;
}
clearTimeout(spin);
if (seq === current()) onData(data);
}
// Load an ordered list of URLs through the cache with a bounded-parallel pool.
// Results (or {error}) are reported in place via onResult(i, result, isUpdate) —
// including stale-while-revalidate updates — so callers can render the

View file

@ -7,11 +7,11 @@ import { uv } from "./account.js"; // header sign-in entry + notification bell
import { TTL, chunkedFetch, prefetchViews } from "./cache.js";
import { initFindButton, setFindLabel } from "./mappicker.js";
import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, pctOrd,
fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd,
placeLabel, fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd,
CHUNK_MONTHS, buildChunks, clickOpensPicker, metricBuckets, distStrip,
seasonFilterDropdown, seasonSummaryText, syncSeasonChecks,
applySeasonMonthChange, initSeasonExpand, allMonths, monthsToMask, maskToMonths,
weatherType as wxType, DRY_CAP } from "./shared.js";
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
@ -407,7 +407,7 @@ async function fetchCalendar() {
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 = d.place || `${d.cell.center_lat.toFixed(3)}, ${d.cell.center_lon.toFixed(3)}`;
locLabel.textContent = placeLabel(d);
locLabel.hidden = false;
setFindLabel(findBtn, "Change location");
renderFilters();
@ -460,8 +460,7 @@ async function fetchCalendar() {
// 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 cell = data.cell;
const place = data.place || `${cell.center_lat.toFixed(3)}, ${cell.center_lon.toFixed(3)}`;
const place = placeLabel(data);
const years = ((new Date(data.range.end) - new Date(data.range.start)) / 3.15576e10);
let note = "";
if (remaining > 0 && loading) {
@ -575,25 +574,19 @@ function renderTotals(visible) {
totEl.hidden = false;
}
const GUIDE_LINK = `<p class="muted tk-note"><a href="legend">Full guide to metrics &amp; grades →</a></p>`;
function renderKey() {
if (metric === "dsr") {
const steps = [["Rain", 0], ["1 day", 1], ["4", 4], ["7", 7], ["10", 10], ["14+", 14]];
const segs = steps.map(([lab, d]) =>
`<div class="tk-seg"><span class="tk-swatch" style="background:${drynessColor(d)}"></span>
<span class="tk-label">${lab}</span></div>`).join("");
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 = [...SCALE_RAIN].reverse().map((t) =>
`<div class="tk-seg"><span class="tk-swatch" style="background:${PRECIP_COLORS[t.c]}"></span>
<span class="tk-label">${t.label}</span></div>`).join("");
const dry = [["≤1 day", 1], ["7", 7], ["14+", 14]].map(([l, d]) =>
`<div class="tk-seg"><span class="tk-swatch" style="background:${drynessColor(d)}"></span>
<span class="tk-label">${l}</span></div>`).join("");
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>
@ -602,9 +595,8 @@ function renderKey() {
}
const label = (TEMP_METRIC_INFO[metric] || {}).label || "value";
// Highest tier first (Near Record hot → Near Record cold).
const segs = [...SCALE_TEMP].reverse().map((t) =>
`<div class="tk-seg"><span class="tk-swatch" style="background:${TIER_COLORS[t.c]}"></span>
<span class="tk-label">${t.label}</span></div>`).join("");
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}`;
}

View file

@ -4,7 +4,7 @@
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
import { fmtTemp, onUnitChange } from "./units.js";
import { uv } from "./account.js"; // header sign-in entry + notification bell, and the API-version resolver
import { getJSON, TTL, prefetchViews } from "./cache.js";
import { loadView, TTL, prefetchViews } from "./cache.js";
import { initFindButton, setFindLabel } from "./mappicker.js";
import { TIER_COLORS, esc, placeLabel } from "./shared.js";
@ -55,31 +55,19 @@ async function fetchScore() {
history.replaceState(null, "", locHash(selected.lat, selected.lon));
placeholder.hidden = true;
scoreHead.hidden = false;
const seq = ++seqCounter;
// Delay the spinner: a cache-served render lands in a few ms, so blanking the
// page immediately would just flash. Only a slow (network) load shows it.
const spin = setTimeout(() => {
if (seq !== seqCounter) return;
scoreHead.innerHTML = `<p class="spinner">Scoring 45 years of climate…</p>`;
scoreBody.innerHTML = "";
}, 150);
let data;
try {
// Stale-while-revalidate: a stale cached copy renders immediately; the update
// callback repaints if the background revalidation finds new data.
data = await getJSON(
uv(`score?lat=${selected.lat}&lon=${selected.lon}`), TTL.score,
false, (upd) => { if (seq === seqCounter) finishScore(upd); });
} catch (err) {
clearTimeout(spin);
if (seq !== seqCounter) return;
scoreHead.innerHTML = `<p class="error">${esc(err.message)}</p>`;
scoreBody.innerHTML = "";
return;
}
clearTimeout(spin);
if (seq !== seqCounter) return;
finishScore(data);
await loadView({
url: uv(`score?lat=${selected.lat}&lon=${selected.lon}`),
ttl: TTL.score, seq: ++seqCounter, current: () => seqCounter,
spinner: () => {
scoreHead.innerHTML = `<p class="spinner">Scoring 45 years of climate…</p>`;
scoreBody.innerHTML = "";
},
onData: finishScore,
onError: (err) => {
scoreHead.innerHTML = `<p class="error">${esc(err.message)}</p>`;
scoreBody.innerHTML = "";
},
});
}
function finishScore(data) {

View file

@ -264,6 +264,17 @@ export const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;")
export const placeLabel = (data) =>
data.place || `${data.cell.center_lat.toFixed(3)}, ${data.cell.center_lon.toFixed(3)}`;
// The grade/scale key strip: one ".tk-seg" (colored swatch + label) per item.
// Callers resolve each item's color (TIER_COLORS / PRECIP_COLORS / drynessColor)
// and pass { color, label } pairs, so this stays free of any color-map coupling.
export const tierKeySegs = (items) => items.map((it) =>
`<div class="tk-seg"><span class="tk-swatch" style="background:${it.color}"></span>` +
`<span class="tk-label">${it.label}</span></div>`).join("");
// The "full guide" footer link shared under every grade-scale key.
export const GUIDE_LINK =
`<p class="muted tk-note"><a href="legend">Full guide to metrics &amp; grades →</a></p>`;
// A place label is "neighbourhood, city, region, country" (comma-joined by the
// backend). For display we lead with the first segment and mute the rest:
// namePrimary → the lead segment; nameParts → { primary, rest } with rest joined by