Compare/calendar mobile polish: overflow fix, sheet load button, 6-yr default, country in names (#80)

- Fix the mobile width blow-out: the distribution grid item (.cmp-dist-row) had no
  min-width:0, so each row expanded to the 9–10-column connected bar chart's
  min-content and widened the whole page (zoom-out, clipped filter sheet). Add
  grid-template-columns: minmax(0,1fr) + min-width:0 so the strip scrolls inside its
  own .ct-strip, and tighten .ct-col to 34px on phones.
- Add a Load/Refresh button inside the compare filter sheet's date-range block, wired
  to the same refresh()/isDirty(); editing the range on mobile no longer needs the
  sheet closed. The existing button stays in the controls (loads added places).
- Default date range on both pages is now January six years back → the present. On
  the calendar this is an explicit chunked range (the months=24 path is server-capped
  at ~2yr).
- Names now include country: reverse_geocode appends it to the place label; revgeo
  cache key gets a v2 prefix and PAYLOAD_VER bumps to p2 so labels/payloads
  repopulate.
- Location names read as a hierarchy: compact chips show just the lead segment (full
  name in the title/aria), and each ranked card shows the lead segment bold over a
  muted "city · region · country" line.

Frontend + a small backend name/cache change; no schema migration.
This commit is contained in:
Emi Griffith 2026-07-11 23:32:10 -07:00 committed by GitHub
parent d38bf0192c
commit f3ba056738
5 changed files with 65 additions and 20 deletions

View file

@ -71,9 +71,12 @@ function weatherPass(rec) {
const wt = weatherType(rec); const wt = weatherType(rec);
return (!wt.feel || checkedFeels.has(wt.feel)) && checkedSkies.has(wt.sky); return (!wt.feel || checkedFeels.has(wt.feel)) && checkedSkies.has(wt.sky);
} }
// Custom date range (ISO strings) or null = default last-24-months. Any span is // Custom date range (ISO strings). Defaults to January six years back → today; the
// allowed; the frontend splits it into ~2-year server requests (see fetchCalendar). // frontend splits any span into ~2-year server requests (see fetchCalendar), so a
let rangeStart = null, rangeEnd = null; // 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 full requested span (drives the filters + pickers); data.range tracks only
// the portion graded so far, which grows as chunks arrive. // the portion graded so far, which grows as chunks arrive.
let reqStart = null, reqEnd = null; let reqStart = null, reqEnd = null;
@ -690,7 +693,7 @@ function attachHover(byDate) {
const savedFrom = saved && saved.start ? saved.start : null; const savedFrom = saved && saved.start ? saved.start : null;
rangeStart = (savedFrom && toFirst > savedFrom) rangeStart = (savedFrom && toFirst > savedFrom)
? savedFrom // To after From → keep From ? savedFrom // To after From → keep From
: isoOfDate(new Date(y - 2, m, 1)); // To ≤ From (or none) → same month, 2yr back : isoOfDate(new Date(y - 6, m, 1)); // To ≤ From (or none) → same month, 6yr back
saveCalRange(rangeStart, rangeEnd); saveCalRange(rangeStart, rangeEnd);
setCalSync(sel); setCalSync(sel);
} else if (saved) { } else if (saved) {

View file

@ -105,7 +105,8 @@
</div> </div>
<label>From <input id="cmp-start" type="month" /></label> <label>From <input id="cmp-start" type="month" /></label>
<label>To <input id="cmp-end" type="month" /></label> <label>To <input id="cmp-end" type="month" /></label>
<span class="hint">Editing the range arms Refresh — tap it to reload.</span> <span class="hint">Editing the range arms Load — tap it to reload.</span>
<button type="button" id="cmp-refresh-sheet" class="cmp-refresh-btn" hidden>Load / Refresh ↻</button>
</div> </div>
</div> </div>

View file

@ -22,7 +22,8 @@ import { MONTHS, pad, monthStart, monthEnd, buildChunks,
clickOpensPicker, metricBuckets, distStrip, clickOpensPicker, metricBuckets, distStrip,
seasonFilterDropdown, seasonSummaryText, syncSeasonChecks, seasonFilterDropdown, seasonSummaryText, syncSeasonChecks,
applySeasonMonthChange, initSeasonExpand, applySeasonMonthChange, initSeasonExpand,
allMonths, monthsToMask, maskToMonths } from "./shared.js"; allMonths, monthsToMask, maskToMonths,
namePrimary, nameParts } from "./shared.js";
import { fmtTemp, fmtDelta, onUnitChange } from "./units.js"; import { fmtTemp, fmtDelta, onUnitChange } from "./units.js";
import { initFilterSheet } from "./filtersheet.js"; import { initFilterSheet } from "./filtersheet.js";
@ -107,8 +108,8 @@ const BASIS_LABEL = { tmax: "daytime high", mean: "daily mean", tmin: "overnight
const isYM = (s) => typeof s === "string" && /^\d{4}-\d{2}$/.test(s); const isYM = (s) => typeof s === "string" && /^\d{4}-\d{2}$/.test(s);
function defaultRange() { function defaultRange() {
const now = new Date(); const now = new Date();
const s = new Date(now.getFullYear(), now.getMonth() - 11, 1); // last 12 whole months // January six years back → the present month (a broad seasonal sample by default).
return { start: `${s.getFullYear()}-${pad(s.getMonth() + 1)}`, end: `${now.getFullYear()}-${pad(now.getMonth() + 1)}` }; return { start: `${now.getFullYear() - 6}-01`, end: `${now.getFullYear()}-${pad(now.getMonth() + 1)}` };
} }
function loadRange() { function loadRange() {
try { const o = JSON.parse(lsGet(CMP.range)); if (o && isYM(o.start) && isYM(o.end)) return o; } catch (e) {} try { const o = JSON.parse(lsGet(CMP.range)); if (o && isYM(o.start) && isYM(o.end)) return o; } catch (e) {}
@ -166,6 +167,10 @@ function readHashState() {
// ---- elements ---- // ---- elements ----
const addBtn = document.getElementById("cmp-add"); const addBtn = document.getElementById("cmp-add");
const refreshBtn = document.getElementById("cmp-refresh"); const refreshBtn = document.getElementById("cmp-refresh");
// A second Load/Refresh lives inside the params sheet's date-range block, so editing
// the range on mobile (where the panel is a bottom sheet) can reload without closing.
const refreshBtnSheet = document.getElementById("cmp-refresh-sheet");
const refreshBtns = [refreshBtn, refreshBtnSheet];
const locList = document.getElementById("cmp-loc-list"); const locList = document.getElementById("cmp-loc-list");
const params = document.getElementById("cmp-params"); const params = document.getElementById("cmp-params");
const placeholder = document.getElementById("cmp-placeholder"); const placeholder = document.getElementById("cmp-placeholder");
@ -373,7 +378,9 @@ function renderLocList() {
else if (l.error) { label = "Couldn't load"; cls += " error"; } else if (l.error) { label = "Couldn't load"; cls += " error"; }
else if (!l.series) { label = l.name || coords; cls += " pending"; lead = `<span class="cmp-chip-dot" aria-hidden="true"></span>`; } else if (!l.series) { label = l.name || coords; cls += " pending"; lead = `<span class="cmp-chip-dot" aria-hidden="true"></span>`; }
else { label = l.name; } else { label = l.name; }
return `<li class="${cls}">${lead}<span class="cmp-chip-name">${label}</span>` + // The chip shows just the lead segment (compact, several per row); the full name
// stays in the title + aria-label and on the ranked card below.
return `<li class="${cls}">${lead}<span class="cmp-chip-name" title="${label}">${namePrimary(label)}</span>` +
`<button type="button" class="cmp-chip-x" data-i="${i}" aria-label="Remove ${label}">&times;</button></li>`; `<button type="button" class="cmp-chip-x" data-i="${i}" aria-label="Remove ${label}">&times;</button></li>`;
}).join(""); }).join("");
} }
@ -393,6 +400,13 @@ const BUCKETS = [
// site's own colors), and directly beneath it a key that names every bucket and its // site's own colors), and directly beneath it a key that names every bucket and its
// share — so even a 1% sliver on the bar reads clearly. The whole bar is 100% of the // share — so even a 1% sliver on the bar reads clearly. The whole bar is 100% of the
// days, so more blue = runs cold, more red = runs hot. // days, so more blue = runs cold, more red = runs hot.
// The place name as a hierarchy: the lead segment bold, the rest (city · region ·
// country) on a muted line beneath — the one spot that shows the full name.
function nameHierarchy(name) {
const { primary, rest } = nameParts(name);
return `<span class="cmp-name"><b>${primary}</b>${rest ? `<span class="cmp-name-sub">${rest}</span>` : ""}</span>`;
}
function rankCard(loc, s, rank) { function rankCard(loc, s, rank) {
const win = rank === 1; const win = rank === 1;
const parts = BUCKETS.map(([cls, label, key]) => ({ cls, label, w: s[key] })).filter((p) => p.w > 0); const parts = BUCKETS.map(([cls, label, key]) => ({ cls, label, w: s[key] })).filter((p) => p.w > 0);
@ -417,7 +431,7 @@ function rankCard(loc, s, rank) {
return `<div class="cmp-card${win ? " cmp-win" : ""}">` + return `<div class="cmp-card${win ? " cmp-win" : ""}">` +
`<div class="cmp-card-top">` + `<div class="cmp-card-top">` +
`<span class="cmp-rank">${rank}</span>` + `<span class="cmp-rank">${rank}</span>` +
`<div class="cmp-card-name"><span class="cmp-name">${loc.name}</span><span class="cmp-daycount">${sub}</span></div>` + `<div class="cmp-card-name">${nameHierarchy(loc.name)}<span class="cmp-daycount">${sub}</span></div>` +
`<div class="cmp-big"><b>${Math.round(s.score)}</b><span>match / 100</span></div>` + `<div class="cmp-big"><b>${Math.round(s.score)}</b><span>match / 100</span></div>` +
`</div>` + `</div>` +
bar + key + bands + bar + key + bands +
@ -448,7 +462,7 @@ function renderBaseline(scored) {
const rows = scored.map(({ loc, s }) => { const rows = scored.map(({ loc, s }) => {
const cls = posClass(s.avgTemp), a = posF(s.avgTemp); const cls = posClass(s.avgTemp), a = posF(s.avgTemp);
return `<div class="cmp-bl-row">` + return `<div class="cmp-bl-row">` +
`<span class="cmp-bl-name">${loc.name}</span>` + `<span class="cmp-bl-name" title="${loc.name}">${namePrimary(loc.name)}</span>` +
`<div class="cmp-bl-track" role="img" aria-label="${loc.name}: average ${fmtTemp(s.avgTemp)}, typical ${fmtTemp(s.p10)} to ${fmtTemp(s.p90)}">` + `<div class="cmp-bl-track" role="img" aria-label="${loc.name}: average ${fmtTemp(s.avgTemp)}, typical ${fmtTemp(s.p10)} to ${fmtTemp(s.p90)}">` +
`<span class="cmp-bl-tol" style="left:${tolL}%;right:${100 - tolR}%"></span>` + `<span class="cmp-bl-tol" style="left:${tolL}%;right:${100 - tolR}%"></span>` +
`<span class="cmp-bl-band" style="left:${bandL}%;right:${100 - bandR}%"></span>` + `<span class="cmp-bl-band" style="left:${bandL}%;right:${100 - bandR}%"></span>` +
@ -491,9 +505,10 @@ function renderResults() {
// Headline: the winner + the parameters it was judged under. // Headline: the winner + the parameters it was judged under.
const win = scored[0]; const win = scored[0];
const span = `${monthLabel(range.start)} ${monthLabel(range.end)}`; const span = `${monthLabel(range.start)} ${monthLabel(range.end)}`;
const winName = namePrimary(win.loc.name);
const lead = scored.length > 1 const lead = scored.length > 1
? `<b>${win.loc.name}</b> is the best match for ${fmtTemp(lo)}${fmtTemp(hi)} — score ${Math.round(win.s.score)}/100` ? `<b>${winName}</b> is the best match for ${fmtTemp(lo)}${fmtTemp(hi)} — score ${Math.round(win.s.score)}/100`
: `<b>${win.loc.name}</b>: match score ${Math.round(win.s.score)}/100 for ${fmtTemp(lo)}${fmtTemp(hi)}`; : `<b>${winName}</b>: match score ${Math.round(win.s.score)}/100 for ${fmtTemp(lo)}${fmtTemp(hi)}`;
head.hidden = false; head.hidden = false;
head.innerHTML = `<h2>${lead}</h2>` + head.innerHTML = `<h2>${lead}</h2>` +
`<p class="meta">By ${BASIS_LABEL[basis]} · comfort ${fmtTemp(lo)}${fmtTemp(hi)}, tolerance ${fmtTemp(tlo)}${fmtTemp(thi)} · ${span}` + `<p class="meta">By ${BASIS_LABEL[basis]} · comfort ${fmtTemp(lo)}${fmtTemp(hi)}, tolerance ${fmtTemp(tlo)}${fmtTemp(thi)} · ${span}` +
@ -516,10 +531,10 @@ function renderDist() {
distBody.innerHTML = withData.map(({ l, vis }) => { distBody.innerHTML = withData.map(({ l, vis }) => {
const buckets = metricBuckets(vis, distMetric); const buckets = metricBuckets(vis, distMetric);
const total = buckets.reduce((n, b) => n + b.n, 0); const total = buckets.reduce((n, b) => n + b.n, 0);
const name = l.name || `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`; const name = namePrimary(l.name) || `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`;
const strip = total ? distStrip(buckets, distCount) : `<p class="muted">No data for this metric.</p>`; const strip = total ? distStrip(buckets, distCount) : `<p class="muted">No data for this metric.</p>`;
return `<div class="cmp-dist-row">` + return `<div class="cmp-dist-row">` +
`<div class="cmp-dist-name">${name}<span class="cmp-dist-days">${total.toLocaleString()} days</span></div>` + `<div class="cmp-dist-name" title="${l.name || ""}">${name}<span class="cmp-dist-days">${total.toLocaleString()} days</span></div>` +
strip + `</div>`; strip + `</div>`;
}).join(""); }).join("");
distSection.hidden = false; distSection.hidden = false;
@ -530,7 +545,7 @@ function renderAll() {
params.hidden = !has; params.hidden = !has;
placeholder.hidden = has; placeholder.hidden = has;
addBtn.querySelector("span").textContent = has ? "Add another location" : "Add location"; addBtn.querySelector("span").textContent = has ? "Add another location" : "Add location";
refreshBtn.hidden = !isDirty(); for (const b of refreshBtns) b.hidden = !isDirty();
renderLocList(); renderLocList();
renderResults(); renderResults();
renderDist(); renderDist();
@ -541,7 +556,7 @@ initFindButton(addBtn, "Add location",
() => (locations.length ? locations[locations.length - 1] : loadLastLocation()), () => (locations.length ? locations[locations.length - 1] : loadLastLocation()),
(lat, lon) => addLocation(lat, lon)); (lat, lon) => addLocation(lat, lon));
refreshBtn.addEventListener("click", refresh); for (const b of refreshBtns) b.addEventListener("click", refresh);
locList.addEventListener("click", (e) => { locList.addEventListener("click", (e) => {
const x = e.target.closest(".cmp-chip-x"); const x = e.target.closest(".cmp-chip-x");

View file

@ -192,6 +192,17 @@ export const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;")
export const placeLabel = (data) => export const placeLabel = (data) =>
data.place || `${data.cell.center_lat.toFixed(3)}, ${data.cell.center_lon.toFixed(3)}`; data.place || `${data.cell.center_lat.toFixed(3)}, ${data.cell.center_lon.toFixed(3)}`;
// 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
// " · " (e.g. "Seattle · Washington · United States"). Tolerant of 14 parts.
export const namePrimary = (name) =>
String(name || "").split(",")[0].trim() || String(name || "");
export function nameParts(name) {
const segs = String(name || "").split(",").map((s) => s.trim()).filter(Boolean);
return { primary: segs[0] || String(name || ""), rest: segs.slice(1).join(" · ") };
}
// ---- dates & range chunking ---- // ---- dates & range chunking ----
export const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; export const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
export const pad = (n) => String(n).padStart(2, "0"); export const pad = (n) => String(n).padStart(2, "0");

View file

@ -854,7 +854,10 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
} }
.cmp-refresh-btn[hidden] { display: none; } .cmp-refresh-btn[hidden] { display: none; }
.cmp-refresh-btn:hover { filter: brightness(1.05); } .cmp-refresh-btn:hover { filter: brightness(1.05); }
.cmp-chip-name { font-weight: 600; } .cmp-chip-name {
font-weight: 600; max-width: 44vw;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.cmp-chip-x { .cmp-chip-x {
border: none; background: transparent; color: var(--muted); cursor: pointer; border: none; background: transparent; color: var(--muted); cursor: pointer;
font-size: 20px; line-height: 1; width: 30px; height: 30px; border-radius: 999px; font-size: 20px; line-height: 1; width: 30px; height: 30px; border-radius: 999px;
@ -889,7 +892,11 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
background: var(--surface-2); color: var(--muted); background: var(--surface-2); color: var(--muted);
} }
.cmp-card-name { flex: 1 1 auto; min-width: 0; } .cmp-card-name { flex: 1 1 auto; min-width: 0; }
.cmp-name { display: block; font-size: 16px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; } /* Name hierarchy: the lead segment bold, the rest (city · region · country) muted
beneath both truncate rather than wrap so the card head stays one tidy block. */
.cmp-name { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.cmp-name b { font-size: 16px; font-weight: 700; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cmp-name-sub { font-size: 12.5px; color: var(--muted); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cmp-daycount { font-size: 12px; color: var(--muted); } .cmp-daycount { font-size: 12px; color: var(--muted); }
.cmp-big { flex: 0 0 auto; text-align: right; } .cmp-big { flex: 0 0 auto; text-align: right; }
.cmp-big b { display: block; font-size: 22px; color: var(--normal); line-height: 1.1; } .cmp-big b { display: block; font-size: 22px; color: var(--normal); line-height: 1.1; }
@ -1037,8 +1044,12 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
.cmp-dist-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; } .cmp-dist-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; }
.cmp-dist-controls .cal-filter-label { margin: 0; } .cmp-dist-controls .cal-filter-label { margin: 0; }
.cmp-dist .metric-toggle { margin: 0; } .cmp-dist .metric-toggle { margin: 0; }
.cmp-dist-body { display: grid; gap: 12px; margin-top: 14px; } /* minmax(0,1fr) + the row's min-width:0 let the track shrink below the strip's
min-content, so the connected bar chart scrolls inside its own .ct-strip
(overflow-x:auto) instead of widening the whole page on a phone. */
.cmp-dist-body { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; margin-top: 14px; }
.cmp-dist-row { .cmp-dist-row {
min-width: 0;
border: 1px solid var(--border); border-radius: 12px; background: var(--surface); padding: 12px 16px 6px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); padding: 12px 16px 6px;
} }
.cmp-dist-name { display: flex; align-items: baseline; gap: 8px; font-size: 14px; font-weight: 700; } .cmp-dist-name { display: flex; align-items: baseline; gap: 8px; font-size: 14px; font-weight: 700; }
@ -1126,6 +1137,10 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
.guide-metrics { grid-template-columns: 1fr; gap: 2px 0; } .guide-metrics { grid-template-columns: 1fr; gap: 2px 0; }
.guide-metrics dt { margin-top: 10px; } .guide-metrics dt { margin-top: 10px; }
/* Tighter distribution columns so a 9-tier temp strip fits a phone without
scrolling (precip's 10th column still scrolls inside its own .ct-strip). */
.ct-col { min-width: 34px; }
.cmp-params { grid-template-columns: 1fr; } .cmp-params { grid-template-columns: 1fr; }
.cmp-basis-block .metric-toggle button { flex: 1 0 22%; padding: 10px 4px; } .cmp-basis-block .metric-toggle button { flex: 1 0 22%; padding: 10px 4px; }