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.
215 lines
9.2 KiB
JavaScript
215 lines
9.2 KiB
JavaScript
// 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.3–19.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();
|
||
}
|
||
})();
|