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

12 commits

Author SHA1 Message Date
Emi Griffith
669e52a0d9 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
# Conflicts:
#	frontend/static/day.js
2026-07-24 16:10:39 -07:00
Emi Griffith
d2c46458ce Merge remote-tracking branch 'forgejo/qa/date-local-live' into qa/frontend-qa-batch 2026-07-24 16:09:44 -07:00
Emi Griffith
45f4d18a4e Merge remote-tracking branch 'forgejo/qa/https-origin-live' into qa/frontend-qa-batch 2026-07-24 16:09:44 -07:00
Emi Griffith
71d1ee95db Merge remote-tracking branch 'forgejo/qa/vapid-rotation' into qa/frontend-qa-batch 2026-07-24 16:09:44 -07:00
Emi Griffith
9e93d1bfdf Merge remote-tracking branch 'forgejo/qa/recentday-gate' into qa/frontend-qa-batch 2026-07-24 16:09:44 -07:00
Emi Griffith
831f10d657 Merge remote-tracking branch 'forgejo/qa/date-422-live' into qa/frontend-qa-batch 2026-07-24 16:09:44 -07:00
Emi Griffith
fedd18a74b Make trace-rain days read consistently across every surface
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.
2026-07-24 16:07:14 -07:00
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
Emi Griffith
72e141afd3 Emit https origin for the public host in canonical/og/sitemap URLs
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.
2026-07-24 16:04:42 -07:00
Emi Griffith
078aaab759 Drop the in-progress local day from the Open-Meteo recent/forecast fallback
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.
2026-07-24 16:01:08 -07:00
Emi Griffith
35cf1036d4 push: re-subscribe on VAPID key rotation and prune dead 401/403 rows
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.
2026-07-24 15:48:25 -07:00
Emi Griffith
ca589545a6 Return 422 for malformed date query param in grade/day endpoints
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.
2026-07-24 15:43:35 -07:00