Frontend QA batch: date/TZ, https origin, date-422, VAPID rotation, trace-precip, partial-day gate #70

Merged
admin_emi merged 12 commits from qa/frontend-qa-batch into dev 2026-07-24 23:13:38 +00:00
Owner

Six fixes from a frontend QA sweep (one of them a live user report from Ruru), integrated into one branch off dev. 122 backend tests pass on the combined branch; all frontend JS parses. Each was developed and tested independently, then merged (one trivial import-line conflict in day.js, resolved as a union).

The six

  1. Local-time date handling (P0 — Ruru's live bug). todayISO()/stepDay() used UTC, so UTC+ users past local midnight couldn't reach their local "today" and prev/next was off-by-one. Now routed through local isoOfDate(). frontend/static/{shared,day}.js. Fixes the Weekly tab, Day Detail, and the calendar picker.
  2. https origin (P0). Canonical / og:url / og:image / robots / all sitemap <loc> emitted http:// on an HTTPS site (Caddy terminates TLS). Now derive the scheme from THERMOGRAPH_BASE_URL when the request host matches the public host; localhost/LAN stays http. Fixed in the frontend SSR emitter (frontend/content.py, the real source) and the backend JSON-LD (backend/api/content_routes.py). Infra follow-up: add HSTS in Caddy.
  3. date param 422 (P1). api_grade/api_day 500'd on a malformed date; now return 422. backend/web/app.py.
  4. VAPID rotation (P1). Push subscribers went dark on key rotation and dead rows were never pruned. enable() re-subscribes on key mismatch; send() prunes 401/403; VAPID contact default fixed to .org. frontend/static/push-client.js, backend/notifications/push.py. Must land before any VAPID key rotation (e.g. secrets-sops).
  5. Trace-precip consistency (P1). Per product decision (precip == 0 → Dry, > 0 → Trace+): dry-streaks reset on any rain >0, a rounds-to-zero trace day renders "trace" (not 0.0"), weatherType/chart/table key dry-vs-rain on the grade class, and the rain chart fan is remapped to the 8 tiers. Grading itself unchanged. backend/data/grading.py, frontend/static/{shared,calendar,day,app,chart}.js.
  6. Partial-day grade gate (P1). The Open-Meteo recent/forecast fallback graded the in-progress local day (Ruru saw a spurious "16°C / 1st-percentile" for a day whose true high was ~20.7°C). Now drops the cell's in-progress local day, mirroring the MET path's coverage gate. backend/data/climate.py. (Ops follow-up separately: NASA primary failing fleet-wide, sync-worker lag — that's why ~92% of cells are on this fallback.)

Targets dev; from there it promotes to beta.

https://claude.ai/code/session_01DKw14tBn1DPXhw3qMpfeMu

Six fixes from a frontend QA sweep (one of them a live user report from Ruru), integrated into one branch off `dev`. **122 backend tests pass on the combined branch; all frontend JS parses.** Each was developed and tested independently, then merged (one trivial import-line conflict in `day.js`, resolved as a union). ### The six 1. **Local-time date handling (P0 — Ruru's live bug).** `todayISO()`/`stepDay()` used UTC, so UTC+ users past local midnight couldn't reach their local "today" and prev/next was off-by-one. Now routed through local `isoOfDate()`. `frontend/static/{shared,day}.js`. Fixes the Weekly tab, Day Detail, and the calendar picker. 2. **https origin (P0).** Canonical / `og:url` / `og:image` / `robots` / all sitemap `<loc>` emitted `http://` on an HTTPS site (Caddy terminates TLS). Now derive the scheme from `THERMOGRAPH_BASE_URL` when the request host matches the public host; localhost/LAN stays http. Fixed in the **frontend SSR** emitter (`frontend/content.py`, the real source) and the backend JSON-LD (`backend/api/content_routes.py`). *Infra follow-up: add HSTS in Caddy.* 3. **date param 422 (P1).** `api_grade`/`api_day` 500'd on a malformed `date`; now return 422. `backend/web/app.py`. 4. **VAPID rotation (P1).** Push subscribers went dark on key rotation and dead rows were never pruned. `enable()` re-subscribes on key mismatch; `send()` prunes 401/403; VAPID contact default fixed to `.org`. `frontend/static/push-client.js`, `backend/notifications/push.py`. **Must land before any VAPID key rotation (e.g. secrets-sops).** 5. **Trace-precip consistency (P1).** Per product decision (`precip == 0 → Dry`, `> 0 → Trace+`): dry-streaks reset on any rain `>0`, a rounds-to-zero trace day renders "trace" (not `0.0"`), `weatherType`/chart/table key dry-vs-rain on the grade class, and the rain chart fan is remapped to the 8 tiers. Grading itself unchanged. `backend/data/grading.py`, `frontend/static/{shared,calendar,day,app,chart}.js`. 6. **Partial-day grade gate (P1).** The Open-Meteo recent/forecast fallback graded the in-progress local day (Ruru saw a spurious "16°C / 1st-percentile" for a day whose true high was ~20.7°C). Now drops the cell's in-progress local day, mirroring the MET path's coverage gate. `backend/data/climate.py`. *(Ops follow-up separately: NASA primary failing fleet-wide, sync-worker lag — that's why ~92% of cells are on this fallback.)* Targets `dev`; from there it promotes to beta. https://claude.ai/code/session_01DKw14tBn1DPXhw3qMpfeMu
admin_emi added 12 commits 2026-07-24 23:11:49 +00:00
api_grade and api_day parsed the `date` query param with an unguarded
datetime.date.fromisoformat, so a malformed or non-calendar value
(notadate, 2026-13-40, 2026-02-30) raised ValueError and surfaced as a
500. Route the parse through a _parse_target_date helper that maps the
ValueError to HTTPException(422); absent/empty and valid dates behave
exactly as before. Add a route-level test asserting 422 on bad dates.
After a VAPID keypair rotation, subscribers minted under the old key
silently stopped receiving notifications while the UI still reported
"on", and the dead rows were never cleaned up.

Frontend (enable): a browser holding an existing PushSubscription never
re-subscribed, so it kept using the old applicationServerKey. Now the
current server VAPID key is always fetched and compared against the
subscription's baked-in key; on a mismatch the stale subscription is
unsubscribed and re-created with the new key. The matching-key path is
unchanged.

Backend (send): a rotated key makes the push service reject delivery
with 401/403, which returned "error" and left the row in place forever.
Treat 401/403 as permanently dead alongside 404/410 so the caller prunes
the row. Genuinely transient failures (rate limits, 5xx) still return
"error" and keep the row.

Also correct the default VAPID contact to mailto:admin@thermograph.org
(the .app domain was a typo); the env override is unchanged.

Extends tests/notifications/test_push.py with the send() status mapping
and the contact default.
Open-Meteo's daily high/low for the current day aggregates only the hours
elapsed so far, so grading it reads a still-unfolding day as complete and
produces spurious extremes — a cool morning served as a 1st-percentile
record-low high. The MET Norway primary already guards this with its
diurnal-coverage gate; the Open-Meteo fallback had no equivalent, so a
fleet-wide failover onto it exposed the bug.

Use the utc_offset_seconds Open-Meteo reports for a timezone=auto request to
identify the cell's local today and exclude it from the bundle. Past days are
complete and future days are whole-day forecasts, so only today is dropped;
the day lands in the record once it is over. When no offset is reported the
guard is skipped rather than guessing a date.
The SSR frontend's _origin() (canonical, og:url, og:image, robots, sitemap
<loc>) and the backend content API's _origin() (the jsonld url folded into
each payload) both trusted x-forwarded-proto / request.url.scheme. Behind
Caddy — which terminates TLS and reverse-proxies plain HTTP — those read
"http", so on the HTTPS-only public site every absolute URL emitted http://,
which 308-redirects to https://: canonicals self-conflict and the sitemap
lists redirecting URLs.

Take the scheme from the configured public origin (THERMOGRAPH_BASE_URL, set
per-host in the deploy env) when the request arrives on that host, so prod and
beta emit https. localhost and LAN dev never match the public host and keep the
observed scheme, so plain-HTTP development is unchanged; the frontend keeps its
X-Forwarded-Host precedence for the proxy-fallback path.

Tests assert canonical/og:url/og:image, robots Sitemap and every sitemap <loc>
are https for the public host (and via X-Forwarded-Host), that a LAN host stays
http, and that the backend payload's jsonld url is likewise https.
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.
Since the dry/rain grading split moved to precip > 0, a sub-0.01" reanalysis
"trace" day is graded as a rain tier but its depth rounds to 0.00" / 0 mm, and
several surfaces still treated it as dry — a day would read "0.0" · Light rain,
N days since rain" at once. Align every consumer of a graded precip day with the
> 0 split so a trace day reads as the (very light) rain it was graded to be.

- Dry streak: dry_streaks and longest_dry_streak reset on any rain (> 0), not
  the 0.01" rain-frequency line, so a trace day breaks the streak and its
  "days since rain" no longer keeps climbing. (The rain_freq climatology stat
  keeps the 0.01" measurable-rain convention.)
- Display: new fmtPrecipTier() prints "trace" for a rain-tier day whose depth
  rounds to zero, rather than a bone-dry "0.00". Used on the calendar tooltip,
  the day-page observation + ladder marker, and the recent/forecast table.
- weatherType() decides wet/dry from the grade class when it has it (a rain tier
  means it rained even at trace depth), falling back to depth > 0; callers on the
  calendar and day page pass the class.
- Recent-table and chart precip dots key their dry-vs-rain rendering on the grade
  class instead of value > 0, so a trace day tints as rain, not dry.

Rain chart fan: the precipitation fan still used the pre-split colour map,
painting the whole 90-99 rain-day-percentile region one shade and 75-90 a tier
too dark. _band_stats emits a p95 mark (additive; unused by the temperature fan)
and RAIN_FAN remaps to the eight tiers -- 95-99 Severe, 90-95 Very Heavy, 60-90
Heavy -- with a p95 fallback in pget so an older cached payload still renders.
Merge remote-tracking branch 'forgejo/qa/dry-trace' into qa/frontend-qa-batch
All checks were successful
PR build (required check) / changes (pull_request) Successful in 7s
secrets-guard / encrypted (pull_request) Successful in 5s
shell-lint / shellcheck (pull_request) Successful in 8s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 58s
PR build (required check) / build-backend (pull_request) Successful in 1m16s
PR build (required check) / gate (pull_request) Successful in 2s
669e52a0d9
# Conflicts:
#	frontend/static/day.js
admin_emi merged commit 6df4968679 into dev 2026-07-24 23:13:38 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Jinemi/thermograph#70
No description provided.