thermograph/frontend/static/day.js
Emi Griffith f0747bae3a Compute "today" and date-picker bounds in local time, not UTC
todayISO() and day.js's stepDay() formatted dates via toISOString(), which
is UTC. For UTC+ viewers past local midnight this rolled the date a day
early: "today", the date-input max, the next-day disabled guard, and the
Weekly "Today" button all referred to the wrong day, and prev/next
navigation stepped off-by-one.

Route both through the existing local isoOfDate() so every view (Weekly,
Day Detail, Calendar) agrees on the viewer's local day.
2026-07-24 16:06:52 -07:00

215 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Thermograph single-day detail subpage — for one day + location, shows the value
// at every percentile tier boundary in that day-of-year's ±7-day climate window,
// and where the observed values land. Talks to /api/v2/day + /api/v2/geocode.
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 { initFindButton, setFindLabel } from "./mappicker.js";
import { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, pctOrd, fmtPrecip, fmtWind, fmtHumid,
todayISO, isoOfDate, weatherType, placeLabel } from "./shared.js";
// Color a tier the same way the calendar cell would be colored for it.
const tierColor = (c) => (c === "dry" ? DRY_COLOR : TIER_COLORS[c] || PRECIP_COLORS[c] || "");
let selected = null; // {lat, lon}
let curDate = null; // "YYYY-MM-DD" currently shown
let latest = null; // newest date available in the record
const placeholder = document.getElementById("day-placeholder");
const dayHead = document.getElementById("day-head");
const dayBody = document.getElementById("day-body");
const dateInput = document.getElementById("date-input");
// ---- 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");
initFindButton(findBtn, "Find a location", () => selected,
(lat, lon) => {
selected = { lat, lon };
// Show the raw coordinates immediately; render() replaces them with the
// resolved neighborhood + city name once the day fetch returns.
locLabel.textContent = `${lat.toFixed(3)}, ${lon.toFixed(3)}`;
locLabel.hidden = false;
fetchDay();
});
// ---- date navigation ----
dateInput.addEventListener("change", () => { if (dateInput.value) { curDate = dateInput.value; fetchDay(); } });
document.getElementById("prev-day").addEventListener("click", () => stepDay(-1));
document.getElementById("next-day").addEventListener("click", () => stepDay(1));
function stepDay(delta) {
if (!curDate) return;
const d = new Date(curDate + "T00:00:00");
d.setDate(d.getDate() + delta);
// Format in LOCAL time — curDate was parsed as local midnight, so toISOString()
// (UTC) would shift the result a day for UTC± viewers and desync the today guard.
const iso = isoOfDate(d);
if (iso > todayISO()) return; // don't step past today
curDate = iso;
fetchDay();
}
// ---- fetch + render ----
let daySeq = 0; // supersedes an in-flight load when the user picks a new spot/date
async function fetchDay() {
if (!selected) return;
const dateQ = curDate ? `&date=${curDate}` : "";
history.replaceState(null, "", locHash(selected.lat, selected.lon, curDate));
placeholder.hidden = true;
dayHead.hidden = false;
const seq = ++daySeq;
// 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 !== daySeq) return;
dayHead.innerHTML = `<p class="spinner">Building the percentile breakdown…</p>`;
dayBody.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(`day?lat=${selected.lat}&lon=${selected.lon}${dateQ}`), TTL.day,
false, (upd) => { if (seq === daySeq) finishDay(upd); });
} catch (err) {
clearTimeout(spin);
if (seq !== daySeq) return;
dayHead.innerHTML = `<p class="error">${err.message}</p>`;
dayBody.innerHTML = "";
return;
}
clearTimeout(spin);
if (seq !== daySeq) return;
finishDay(data);
}
function finishDay(data) {
latest = data.latest;
curDate = data.detail.date;
dateInput.value = curDate;
// Allow navigating up to today (recent days beyond the archive come from the
// forecast bundle), not just the latest fully-archived day.
dateInput.max = todayISO();
document.getElementById("next-day").disabled = curDate >= todayISO();
saveLastLocation(selected.lat, selected.lon, curDate);
render(data);
prefetchViews(selected.lat, selected.lon, ["day"]); // warm weekly/calendar/forecast
}
let lastData = null; // most recent /day response, for repainting on a unit switch
onUnitChange(() => { if (lastData) render(lastData); });
function render(data) {
lastData = data;
const d = data.detail;
const place = placeLabel(data);
locLabel.textContent = place;
locLabel.hidden = false;
setFindLabel(findBtn, "Change location");
const dt = new Date(d.date + "T00:00:00");
const nice = dt.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" });
const yrs = d.year_range ? `${d.year_range[0]}${d.year_range[1]}` : "—";
// Weather summary from the observed high + precip (omitted for days with no obs yet).
const th = d.metrics.tmax.obs ? d.metrics.tmax.obs.value : null;
const pr = d.metrics.precip.obs ? d.metrics.precip.obs.value : null;
const wt = th != null || pr != null ? weatherType(th, pr) : null;
dayHead.innerHTML = `
<h2>${nice}</h2>
${wt ? `<p class="day-weather">${wt.icon} ${wt.text}</p>` : ""}
<p class="meta">${place}
· ${d.n_samples.toLocaleString()} days around this date (${yrs})</p>`;
dayBody.innerHTML = [
ladderCard("Daily high", d.metrics.tmax, fmtTemp, "temp"),
ladderCard("Daily low", d.metrics.tmin, fmtTemp, "temp"),
ladderCard("Feels like", d.metrics.feels, fmtTemp, "temp"),
ladderCard("Humidity", d.metrics.humid, fmtHumid, "temp"),
ladderCard("Wind speed", d.metrics.wind, fmtWind, "temp"),
ladderCard("Wind gust", d.metrics.gust, fmtWind, "temp"),
ladderCard("Precipitation", d.metrics.precip, fmtPrecip, "precip"),
].join("");
}
// Collapse a space-separated unit shared by both bounds ("17.3 g/m³19.9 g/m³"
// → "17.319.9 g/m³") so ranges fit the narrow value column on phones. Attached
// units (76°, 0.25") pass through untouched.
function fmtRange(fmt, lo, hi) {
const a = fmt(lo), b = fmt(hi);
const i = a.lastIndexOf(" ");
if (i > 0 && b.endsWith(a.slice(i))) return `${a.slice(0, i)}${b}`;
return `${a}${b}`;
}
// The unit-less value for the ◀ marker — the unit is already on the range beside
// it and in the card's summary, and dropping it keeps the marker on one line.
const bareVal = (fmt, v) => {
const s = fmt(v), i = s.lastIndexOf(" ");
return i > 0 ? s.slice(0, i) : s;
};
// One metric card: an observed summary line + the tier ladder (highest tier on
// top). The tier the observed value falls into is highlighted with its value.
function ladderCard(title, m, fmt, kind) {
if (!m || !m.ladder) return "";
const obs = m.obs;
const activeClass = obs ? obs.class : null;
let summary;
if (obs) {
const pct = obs.percentile == null ? "" : ` · ${pctOrd(obs.percentile)} pct`;
summary = `<span class="day-obs" style="--cat:${tierColor(obs.class)}">${fmt(obs.value)}</span>
<span class="day-obs-meta">${obs.grade}${pct}</span>`;
} else {
summary = `<span class="day-obs-meta">No observation for this day yet</span>`;
}
// Left: a per-metric summary. Right: the bold, right-aligned Historical Range.
const noteLeft = kind === "temp"
? `median ${fmt(m.ladder.median)}`
: `${m.ladder.rain_days.toLocaleString()} rain days · dry ${m.ladder.dry_pct}%`;
const note = `<span class="ladder-note-left">${noteLeft}</span>` +
`<span class="ladder-range">Historical Range ${fmtRange(fmt, m.ladder.min, m.ladder.max)}</span>`;
const rows = m.ladder.tiers.map((t) => {
const active = t.c === activeClass ? " active" : "";
// Dry has no rain percentile — leave that column blank and show the threshold
// as the value. Other tiers show their percentile range and value range.
let val, pct = t.range;
if (t.c === "dry") { val = t.range; pct = ""; }
else if (t.hi == null) val = `> ${fmt(t.lo)}`; // top tier: strictly beyond p99
else if (t.lo == null) val = `< ${fmt(t.hi)}`; // bottom tier: strictly below p1
else val = fmtRange(fmt, t.lo, t.hi);
const marker = t.c === activeClass && obs
? `<span class="ladder-mark">◀ ${bareVal(fmt, obs.value)}</span>` : "";
return `<div class="ladder-row${active}">
<span class="ladder-sw" style="background:${tierColor(t.c)}"></span>
<span class="ladder-label" style="--cat:${tierColor(t.c)}">${t.label}</span>
<span class="ladder-pct">${pct}</span>
<span class="ladder-val">${val}</span>
<span class="ladder-mk">${marker}</span>
</div>`;
}).join("");
return `<section class="ladder-card">
<div class="ladder-head">
<h3>${title}</h3>
<div class="ladder-summary">${summary}</div>
</div>
<p class="ladder-note">${note}</p>
<div class="ladder">${rows}</div>
</section>`;
}
// ---- restore (hash from a shared link, else the last place you looked at) ----
(function restore() {
const loc = loadLastLocation();
if (loc) {
selected = { lat: loc.lat, lon: loc.lon };
curDate = loc.date || todayISO(); // default to today when no date is given
fetchDay();
}
})();