Compare: put full state in the URL and gate reloads behind Refresh (#12)

The whole comparison — locations, comfort target, band, judged temperature
and date range — is now encoded in the URL hash (c/t/b/s/e/loc) and read
back on load, so a link reproduces exactly what's on screen. Comfort, band
and judged-temperature edits still re-rank instantly and update the hash
without a fetch.

Editing the date range or the location set no longer refetches on its own:
it marks the comparison dirty and reveals a single "Refresh comparison"
button next to Add location; the load runs only when it's tapped. Pending
locations show a dashed chip until then. The initial view (from a shared
link, the saved set, or the last-viewed spot) still loads on arrival.
This commit is contained in:
Emi Griffith 2026-07-10 20:17:17 -07:00 committed by GitHub
parent 13bc4cf0ac
commit 5faa6b4281
3 changed files with 128 additions and 46 deletions

View file

@ -30,7 +30,10 @@
<div class="cmp-locs">
<span class="cal-filter-label">Locations</span>
<ul id="cmp-loc-list" class="cmp-loc-list"></ul>
<div class="cmp-loc-actions">
<button type="button" id="cmp-add" class="find-btn"></button>
<button type="button" id="cmp-refresh" class="cmp-refresh-btn" hidden>Refresh comparison ↻</button>
</div>
</div>
</section>
@ -66,7 +69,7 @@
</div>
<label>From <input id="cmp-start" type="month" /></label>
<label>To <input id="cmp-end" type="month" /></label>
<button type="button" id="cmp-refresh" class="range-refresh" hidden>Reload range ↻</button>
<span class="hint">Editing the range arms Refresh — tap it to reload.</span>
</div>
</div>

View file

@ -6,9 +6,11 @@
// the comfort target. A day "hits comfort" when it lands within a tolerance band;
// otherwise it's colder or warmer, and we track by how much.
//
// The location set and the date range are the only things that require a data
// load. Comfort temperature, band width and the judged temperature are all pure
// re-computations over data already in hand, so moving those re-ranks instantly.
// The whole comparison — locations, comfort target, band, judged temperature and
// date range — lives in the URL hash, so a link reproduces exactly what you see.
// Comfort / band / judged-temperature re-rank instantly (all values are already in
// hand). Editing the date range or the location set doesn't refetch on its own:
// it arms the Refresh button, and the load runs when the user taps it.
const CMP = {
comfort: "thermograph:cmpComfort",
@ -22,12 +24,14 @@ const lsGet = (k) => { try { return localStorage.getItem(k); } catch (e) { retur
const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch (e) {} };
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const BASES = ["tmax", "tmin", "mean", "feels"];
let comfort = clamp(+lsGet(CMP.comfort) || 68, 30, 100);
let tol = clamp(lsGet(CMP.tol) == null ? 5 : +lsGet(CMP.tol), 0, 15);
let basis = ["tmax", "tmin", "mean", "feels"].includes(lsGet(CMP.basis)) ? lsGet(CMP.basis) : "tmax";
let basis = BASES.includes(lsGet(CMP.basis)) ? lsGet(CMP.basis) : "tmax";
// Locations being compared: {lat, lon, name, series|null, loading, error}.
// `series` is a compact per-day array of {hi, lo, feels} (°F, any may be null).
// A location with no series and not loading is "pending" — it needs a Refresh tap.
let locations = [];
// Bumped on every (re)load so a superseded in-flight fetch drops its result.
let loadToken = 0;
@ -36,16 +40,17 @@ const BASIS_LABEL = { tmax: "daytime high", mean: "daily mean", tmin: "overnight
// ---- date range (month granularity) ----
const pad = (n) => String(n).padStart(2, "0");
const isYM = (s) => typeof s === "string" && /^\d{4}-\d{2}$/.test(s);
function defaultRange() {
const now = new Date();
const s = new Date(now.getFullYear(), now.getMonth() - 11, 1); // last 12 whole months
return { start: `${s.getFullYear()}-${pad(s.getMonth() + 1)}`, end: `${now.getFullYear()}-${pad(now.getMonth() + 1)}` };
}
function loadRange() {
try { const o = JSON.parse(lsGet(CMP.range)); if (o && o.start && 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) {}
return defaultRange();
}
let range = loadRange(); // {start, end} as "YYYY-MM"
let range = loadRange(); // the APPLIED range (what loaded data reflects), "YYYY-MM"
const monthStart = (ym) => `${ym}-01`;
const isoOfDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
@ -74,8 +79,39 @@ function buildChunks(startIso, endIso) {
return out;
}
// ---- shareable URL state ----
// The hash carries the full comparison: c=comfort, t=band, b=basis, s/e=range,
// loc=lat,lon;lat,lon. Written on every state change; read once on load (a link
// wins over localStorage). A plain lat/lon hash from cross-view nav is ignored
// here and handled by the seed path instead.
function writeHashState() {
const p = new URLSearchParams();
p.set("c", comfort); p.set("t", tol); p.set("b", basis);
p.set("s", range.start); p.set("e", range.end);
if (locations.length) p.set("loc", locations.map((l) => `${l.lat.toFixed(4)},${l.lon.toFixed(4)}`).join(";"));
history.replaceState(null, "", "#" + p.toString());
}
function readHashState() {
const p = new URLSearchParams(location.hash.slice(1));
if (!["c", "t", "b", "s", "e", "loc"].some((k) => p.has(k))) return null; // not a compare link
const st = {};
if (p.has("c")) st.comfort = clamp(+p.get("c") || 68, 30, 100);
if (p.has("t") && p.get("t") !== "") st.tol = clamp(+p.get("t"), 0, 15);
if (BASES.includes(p.get("b"))) st.basis = p.get("b");
if (isYM(p.get("s"))) st.start = p.get("s");
if (isYM(p.get("e"))) st.end = p.get("e");
if (p.has("loc")) {
st.locs = p.get("loc").split(";").map((pair) => {
const [a, b] = pair.split(",").map(Number);
return { lat: a, lon: b };
}).filter((l) => !isNaN(l.lat) && !isNaN(l.lon));
}
return st;
}
// ---- elements ----
const addBtn = document.getElementById("cmp-add");
const refreshBtn = document.getElementById("cmp-refresh");
const locList = document.getElementById("cmp-loc-list");
const params = document.getElementById("cmp-params");
const placeholder = document.getElementById("cmp-placeholder");
@ -88,7 +124,6 @@ const tolVal = document.getElementById("cmp-tol-val");
const basisToggle = document.getElementById("cmp-basis");
const startInput = document.getElementById("cmp-start");
const endInput = document.getElementById("cmp-end");
const refreshBtn = document.getElementById("cmp-refresh");
addBtn.innerHTML = `${window.LocationPicker.PIN_ICON} <span>Add location</span>`;
@ -124,34 +159,58 @@ async function loadLocation(loc, token) {
renderAll();
try {
const res = await fetchSeries(loc, token);
if (!res || token !== loadToken) return;
loc.name = res.name; loc.series = res.series; loc.loading = false;
if (!res || token !== loadToken) return; // superseded (a newer load owns this loc)
loc.name = res.name; loc.series = res.series;
} catch (e) {
loc.loading = false; loc.error = e.message || "couldn't load";
if (token === loadToken) loc.error = e.message || "couldn't load";
}
if (token === loadToken) loc.loading = false;
persistLocations();
renderAll();
}
// Load every location that still needs data under the current range.
function loadPending(token) {
for (const loc of locations) if (!loc.series && !loc.loading) loadLocation(loc, token);
}
function addLocation(lat, lon) {
// Ignore a spot already on the list (same grid neighborhood).
if (locations.some((l) => Math.abs(l.lat - lat) < 0.05 && Math.abs(l.lon - lon) < 0.05)) return;
const loc = { lat, lon, name: null, series: null, loading: true, error: null };
locations.push(loc);
locations.push({ lat, lon, name: null, series: null, loading: false, error: null });
window.Thermograph.saveLastLocation(lat, lon);
loadLocation(loc, loadToken);
persistLocations();
writeHashState();
renderAll(); // pending → the Refresh button appears; no fetch until it's tapped
}
function removeLocation(i) {
locations.splice(i, 1);
persistLocations();
writeHashState();
renderAll();
}
// Refetch every location's series (used when the date range changes).
function reloadAll() {
// ---- pending / refresh state ----
const dateChanged = () => startInput.value !== range.start || endInput.value !== range.end;
const isPending = (l) => !l.series && !l.loading && !l.error;
const isDirty = () => locations.length > 0 && (dateChanged() || locations.some(isPending));
// The Refresh tap: adopt any edited dates (which invalidates every series) and load
// whatever now needs data.
function refresh() {
let s = startInput.value || range.start, e = endInput.value || range.end;
if (!isYM(s) || !isYM(e)) return;
if (s > e) { const t = s; s = e; e = t; }
const rangeChanged = s !== range.start || e !== range.end;
range = { start: s, end: e };
startInput.value = s; endInput.value = e;
lsSet(CMP.range, JSON.stringify(range));
const token = ++loadToken;
for (const loc of locations) { loc.series = null; loadLocation(loc, token); }
if (rangeChanged) for (const loc of locations) loc.series = null;
writeHashState();
loadPending(token);
renderAll();
}
// ---- stats ----
@ -190,8 +249,11 @@ const deg = (v) => `${Math.round(v)}°`;
function renderLocList() {
locList.innerHTML = locations.map((l, i) => {
const label = l.loading ? "Locating…" : l.error ? "Couldn't load" : (l.name || `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`);
const cls = "cmp-chip" + (l.loading ? " loading" : "") + (l.error ? " error" : "");
let label, cls = "cmp-chip";
if (l.loading) { label = "Locating…"; cls += " loading"; }
else if (l.error) { label = "Couldn't load"; cls += " error"; }
else if (!l.series) { label = l.name || `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`; cls += " pending"; }
else { label = l.name; }
return `<li class="${cls}"><span class="cmp-chip-name">${label}</span>` +
`<button type="button" class="cmp-chip-x" data-i="${i}" aria-label="Remove ${label}">&times;</button></li>`;
}).join("");
@ -251,6 +313,7 @@ function renderAll() {
params.hidden = !has;
placeholder.hidden = has;
addBtn.querySelector("span").textContent = has ? "Add another location" : "Add location";
refreshBtn.hidden = !isDirty();
renderLocList();
renderResults();
}
@ -261,22 +324,26 @@ addBtn.addEventListener("click", () => {
window.LocationPicker.open(cur, (lat, lon) => addLocation(lat, lon));
});
refreshBtn.addEventListener("click", refresh);
locList.addEventListener("click", (e) => {
const x = e.target.closest(".cmp-chip-x");
if (x) removeLocation(+x.dataset.i);
});
// Comfort / band / basis: instant re-rank, no refetch.
// Comfort / band / basis: instant re-rank over data already in hand — no refetch.
comfortInput.addEventListener("input", () => {
comfort = +comfortInput.value;
comfortVal.textContent = `${comfort}°`;
lsSet(CMP.comfort, String(comfort));
writeHashState();
renderResults();
});
tolInput.addEventListener("input", () => {
tol = +tolInput.value;
tolVal.textContent = `±${tol}°`;
lsSet(CMP.tol, String(tol));
writeHashState();
renderResults();
});
basisToggle.addEventListener("click", (e) => {
@ -285,42 +352,43 @@ basisToggle.addEventListener("click", (e) => {
basis = btn.dataset.basis;
lsSet(CMP.basis, basis);
basisToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b === btn));
writeHashState();
renderResults();
});
// Date range: editing reveals the reload button; confirming refetches everything.
// Editing the dates doesn't load — it just arms Refresh (re-eval via renderAll).
startInput.max = endInput.max = `${new Date().getFullYear()}-12`;
function markDirty() { refreshBtn.hidden = locations.length === 0; }
startInput.addEventListener("change", markDirty);
endInput.addEventListener("change", markDirty);
refreshBtn.addEventListener("click", () => {
let s = startInput.value || range.start, e = endInput.value || range.end;
if (s > e) { const t = s; s = e; e = t; }
range = { start: s, end: e };
startInput.value = s; endInput.value = e;
lsSet(CMP.range, JSON.stringify(range));
refreshBtn.hidden = true;
reloadAll();
});
startInput.addEventListener("change", renderAll);
endInput.addEventListener("change", renderAll);
// ---- init ----
(function restore() {
const hash = readHashState();
if (hash) {
if (hash.comfort != null) comfort = hash.comfort;
if (hash.tol != null) tol = hash.tol;
if (hash.basis) basis = hash.basis;
if (hash.start && hash.end) range = { start: hash.start, end: hash.end };
}
comfortInput.value = comfort; comfortVal.textContent = `${comfort}°`;
tolInput.value = tol; tolVal.textContent = `±${tol}°`;
basisToggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b.dataset.basis === basis));
startInput.value = range.start; endInput.value = range.end;
let saved = null;
try { saved = JSON.parse(lsGet(CMP.locs)); } catch (e) {}
if (Array.isArray(saved) && saved.length) {
locations = saved
// A shared link's locations win; else the last-used set; else seed from the spot
// the other views were on so the page isn't empty. All three auto-load on arrival
// (the tap-to-refresh rule is for later interactive edits, not the initial view).
let list = hash && hash.locs;
if (!list) { try { const saved = JSON.parse(lsGet(CMP.locs)); if (Array.isArray(saved)) list = saved; } catch (e) {} }
if (!list || !list.length) { const last = window.Thermograph.loadLastLocation(); list = last ? [{ lat: last.lat, lon: last.lon }] : []; }
locations = list
.filter((l) => l && typeof l.lat === "number" && typeof l.lon === "number")
.map((l) => ({ lat: l.lat, lon: l.lon, name: l.name || null, series: null, loading: true, error: null }));
for (const loc of locations) loadLocation(loc, loadToken);
} else {
// Seed from the spot the other views last looked at, so Compare isn't empty.
const last = window.Thermograph.loadLastLocation();
if (last) addLocation(last.lat, last.lon);
}
.map((l) => ({ lat: l.lat, lon: l.lon, name: l.name || null, series: null, loading: false, error: null }));
writeHashState();
const token = ++loadToken;
loadPending(token);
renderAll();
})();

View file

@ -802,7 +802,18 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
border-radius: 999px; padding: 5px 5px 5px 14px; font-size: 14px;
}
.cmp-chip.loading { color: var(--muted); }
.cmp-chip.pending { color: var(--muted); border-style: dashed; }
.cmp-chip.error { border-color: var(--very-hot); color: var(--very-hot); }
/* Add-location + Refresh sit together; Refresh only shows while changes are pending. */
.cmp-loc-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.cmp-refresh-btn {
padding: 11px 18px; border-radius: 10px; cursor: pointer;
font-weight: 700; font-size: 15px; min-height: 44px;
background: var(--accent); color: #1a1206; border: 1px solid var(--accent);
}
.cmp-refresh-btn[hidden] { display: none; }
.cmp-refresh-btn:hover { filter: brightness(1.05); }
.cmp-chip-name { font-weight: 600; }
.cmp-chip-x {
border: none; background: transparent; color: var(--muted); cursor: pointer;