fix(frontend): Weekly view must always send an explicit date to /grade
All checks were successful
Build + push images (Forgejo registry) / build-push (backend) (push) Successful in 6s
secrets-guard / encrypted (push) Successful in 6s
shell-lint / shellcheck (push) Successful in 9s
secrets-guard / encrypted (pull_request) Successful in 17s
shell-lint / shellcheck (pull_request) Successful in 16s
PR build (required check) / changes (pull_request) Successful in 20s
PR build (required check) / build-backend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
Build + push images (Forgejo registry) / build-push (frontend) (push) Successful in 59s
PR build (required check) / build-frontend (pull_request) Successful in 35s
PR build (required check) / gate (pull_request) Successful in 2s
All checks were successful
Build + push images (Forgejo registry) / build-push (backend) (push) Successful in 6s
secrets-guard / encrypted (push) Successful in 6s
shell-lint / shellcheck (push) Successful in 9s
secrets-guard / encrypted (pull_request) Successful in 17s
shell-lint / shellcheck (pull_request) Successful in 16s
PR build (required check) / changes (pull_request) Successful in 20s
PR build (required check) / build-backend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
Build + push images (Forgejo registry) / build-push (frontend) (push) Successful in 59s
PR build (required check) / build-frontend (pull_request) Successful in 35s
PR build (required check) / gate (pull_request) Successful in 2s
This commit is contained in:
parent
5f20fba9f5
commit
5b7e30dad9
3 changed files with 91 additions and 10 deletions
|
|
@ -152,10 +152,27 @@ async function runGrade() {
|
|||
updateHash();
|
||||
placeholder.hidden = true;
|
||||
results.hidden = false;
|
||||
// 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}`);
|
||||
// Always send the target date, even when it's "today" — the backend only
|
||||
// falls back to its own UTC today (api_grade's `date` param default; see
|
||||
// backend/web/app.py) when the param is omitted or empty, a day behind local
|
||||
// midnight for any viewer east of UTC (reported from Kaunas, UTC+3: asking
|
||||
// for 7/26 rendered 7/25). The comment this replaces claimed omitting the
|
||||
// date kept this URL matching the cross-view prefetch — but that match was
|
||||
// the other half of the bug: cache.js's viewFetches() seeds the grade view's
|
||||
// cache entry under this exact dateless URL too, tagged fresh for the
|
||||
// viewer's local day no matter which day the server actually resolved, so a
|
||||
// same-tab nav that had already prefetched this cell (e.g. via the Day page)
|
||||
// could serve that stale, server-resolved slice straight out of IndexedDB
|
||||
// with no live request ever going out. Sending the real date makes every
|
||||
// load self-consistent with the viewer's own clock instead of aliasing onto
|
||||
// whatever "today" the server or an earlier prefetch resolved.
|
||||
// dateInput.value can also come back empty (a cleared native date field —
|
||||
// day.js guards the same input for the same reason), so fall back to the
|
||||
// viewer's local today rather than ever send `date=` empty, which hits that
|
||||
// same server fallback.
|
||||
const date = dateInput.value || todayISO();
|
||||
const q = `lat=${selected.lat}&lon=${selected.lon}&date=${date}`;
|
||||
const url = uv(`grade?${q}`);
|
||||
await loadView({
|
||||
url, ttl: TTL.grade, seq: ++gradeSeq, current: () => gradeSeq,
|
||||
spinner: () => { results.innerHTML = `<p class="spinner">Fetching decades of climate history…</p>`; },
|
||||
|
|
|
|||
|
|
@ -207,7 +207,7 @@ function seedCache(url, data, etag) {
|
|||
// lives here next to the bundle fetch — not knowledge of any one page.
|
||||
function viewFetches(q, today) {
|
||||
return {
|
||||
grade: [uv(`grade?${q}`), TTL.grade],
|
||||
grade: [uv(`grade?${q}&date=${today}`), TTL.grade],
|
||||
forecast: [uv(`forecast?${q}`), TTL.forecast],
|
||||
calendar: [uv(`calendar?${q}&months=24`), TTL.calendar],
|
||||
day: [uv(`day?${q}&date=${today}`), TTL.day],
|
||||
|
|
@ -232,6 +232,12 @@ export function prefetchViews(lat, lon, ownViews) {
|
|||
if (_prefetched === tag) return;
|
||||
_prefetched = tag;
|
||||
const q = `lat=${lat}&lon=${lon}`;
|
||||
// The client's local "today" — sent explicitly so the server never has to guess
|
||||
// it from its own (UTC) clock. Getting this wrong is exactly what poisoned the
|
||||
// Weekly view's cache for UTC+ visitors between their local midnight and UTC
|
||||
// midnight: the bundle used to be built against the server's date while the
|
||||
// per-view request stated the client's, so the two silently disagreed.
|
||||
const today = localDay();
|
||||
const own = new Set(ownViews);
|
||||
// Yield first so the landing view finishes rendering before we fetch the rest.
|
||||
setTimeout(async () => {
|
||||
|
|
@ -239,17 +245,20 @@ export function prefetchViews(lat, lon, ownViews) {
|
|||
const ek = "tg-cell-etag:" + q;
|
||||
let prev = null;
|
||||
try { prev = sessionStorage.getItem(ek); } catch (e) {}
|
||||
const res = await fetch(uv(`cell?${q}&neighbors=1`),
|
||||
const res = await fetch(uv(`cell?${q}&date=${today}&neighbors=1`),
|
||||
{ credentials: "include", ...(prev ? { headers: { "If-None-Match": prev } } : {}) });
|
||||
if (res.ok && res.status !== 304) {
|
||||
const bundle = await res.json();
|
||||
try { sessionStorage.setItem(ek, res.headers.get("ETag") || ""); } catch (e) {}
|
||||
const fetches = viewFetches(q, localDay());
|
||||
// Seed under bundle.today (the date the server actually resolved — normally
|
||||
// just an echo of the date= we sent) rather than our own `today`, so the
|
||||
// seeded key stays byte-identical to what a per-view request for that same
|
||||
// resolved date will use even in the rare case the two disagree (e.g. a
|
||||
// request that straddles local midnight).
|
||||
const fetches = viewFetches(q, bundle.today);
|
||||
for (const [name, slice] of Object.entries(bundle.slices || {})) {
|
||||
if (own.has(name) || !slice || !slice.data) continue;
|
||||
const url = name === "day"
|
||||
? uv(`day?${q}&date=${bundle.today}`) // the day slice targets today
|
||||
: (fetches[name] || [])[0];
|
||||
const url = (fetches[name] || [])[0];
|
||||
if (url) seedCache(url, slice.data, slice.etag);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
55
frontend/tests/unit/test_app_js_grade_date.py
Normal file
55
frontend/tests/unit/test_app_js_grade_date.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""Regression guard for the grade-URL "today" date bug: runGrade() in app.js used
|
||||
to omit `date` from the /api/v2/grade request whenever the selected date equaled
|
||||
the viewer's local today, so the backend fell back to ITS OWN UTC today
|
||||
(api_grade's `date` param default in backend/web/app.py) -- a day behind local
|
||||
midnight for any viewer east of UTC. Reported from Kaunas (UTC+3): asking for
|
||||
the Weekly tab on 7/26 rendered 7/25.
|
||||
|
||||
There is no JS/DOM test harness in this repo to drive app.js and assert on the
|
||||
real fetch() URL it builds -- static/package.json declares zero dependencies, so
|
||||
there is no jest/playwright/node runner wired into CI, and nothing else covers
|
||||
static/*.js runtime behavior. So this can't be a behavioral test. What it CAN
|
||||
do, hermetically, through the same served-asset route test_pages.py already
|
||||
exercises (`GET {B}/app.js`), is treat the real shipped source as data and
|
||||
assert the structural property the fix guarantees: runGrade() builds exactly one
|
||||
grade URL and always includes an explicit `date=`, rather than branching on
|
||||
whether the selected date is "today" and omitting it in that case.
|
||||
|
||||
This fails against the pre-fix source and passes against the fixed source --
|
||||
a source guard, not a substitute for a real behavioral test.
|
||||
"""
|
||||
import re
|
||||
|
||||
B = "/thermograph" # matches THERMOGRAPH_BASE set in tests/conftest.py
|
||||
|
||||
|
||||
def _run_grade_body(client) -> str:
|
||||
r = client.get(f"{B}/app.js")
|
||||
assert r.status_code == 200
|
||||
src = r.text
|
||||
m = re.search(r"async function runGrade\(\) \{\n(.*?)\n\}\n", src, re.DOTALL)
|
||||
assert m, "runGrade() not found in app.js -- update this test if it moved/was renamed"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def test_run_grade_always_sends_explicit_date(client):
|
||||
"""The historical bug: `dateInput.value === todayISO() ? grade?q : grade?q&date=...`
|
||||
omitted `date` for "today", so the backend's own UTC-today fallback rendered the
|
||||
previous day for any UTC+ viewer between their local midnight and UTC midnight."""
|
||||
body = _run_grade_body(client)
|
||||
assert not re.search(r"dateInput\.value\s*===\s*todayISO\(\)", body), \
|
||||
"runGrade() still branches on today to decide whether to send date="
|
||||
assert body.count("grade?") == 1, \
|
||||
"runGrade() should build exactly one grade URL, not two divergent branches"
|
||||
assert re.search(r"date=\$\{[^}]+\}", body), \
|
||||
"runGrade() must always build the URL with an explicit date= param"
|
||||
|
||||
|
||||
def test_run_grade_never_sends_an_empty_date(client):
|
||||
"""A cleared native date field reports dateInput.value === "" (day.js guards the
|
||||
same input for the same reason). An empty date= hits the identical server-UTC
|
||||
fallback this fix exists to avoid, so runGrade() must fall back to the viewer's
|
||||
own local today rather than ever send the param empty."""
|
||||
body = _run_grade_body(client)
|
||||
assert re.search(r"dateInput\.value\s*\|\|\s*todayISO\(\)", body), \
|
||||
"runGrade() should fall back to todayISO() when dateInput.value is empty"
|
||||
Loading…
Reference in a new issue