# Changelog ## [1.4.38.8] — 2026-05-18 — Analytics fast-path gates per-type only The v1.4.38.5–.7 chain confirmed the rollup-fast-path was bouncing to the live SQL aggregator even when the helper's own types were fully covered — every fast-path stacked `isFullyCovered(coverage) && specificTypes` and a single unrelated uncovered type (e.g. an iOS-pushed brand-new ACTIVITY_FLIGHTS reading) flipped the AND false and stranded the whole helper on live SQL across the full 347 k-row measurement table. ### Fixed - **`correlations-fast-path` gates only on `BLOOD_PRESSURE_SYS + PULSE + WEIGHT`** — the three types the helper actually reads. `isFullyCovered(coverage)` AND-term dropped. - **`bp-in-target-fast-path` gates only on `BLOOD_PRESSURE_SYS + BLOOD_PRESSURE_DIA`**. Same fix. - **`health-score-fast-path` gates only on `WEIGHT`**. Same fix. Coverage-probe semantics unchanged. Each helper now consults the per-type entries directly; a brand-new uncovered metric type no longer poisons unrelated cards. ### Operator notes - No new migration. No env-var change. - Expected: cold-mount `/api/analytics` on Marc-sized accounts drops from 30-75 s to ~3-5 s. ## [1.4.38.7] — 2026-05-18 — Rollup recompute observability + admin trigger The v1.4.38.5 / v1.4.38.6 chain promised a fast-path recovery for power-user accounts but gave the operator no way to confirm whether the boot-time discovery had actually found a stranded user — the success log only fired when `enqueued > 0 || skipped > 0`, so the silent "found nothing" case looked identical to the silent "boot hook never ran" case. And once the worker was up there was no way to re-trigger discovery short of bouncing the container. ### Added - **`POST /api/admin/rollups/recompute`** — operator-triggered ad-hoc rollup recompute. Body `{ userId: string }` synchronously folds one user (`recomputeUserRollups` awaited inside the request). Body `{}` re-runs `enqueueBootTimeRollupBackfill()` to kick the boot-discovery loop across every user. Admin gate via `requireAdmin()` (cookie-only, never Bearer). - **`fallback_reason` + `missing_types` annotate** on the `slim_summaries` live-fallback path. When the rollup-fast-path declines, the wide-event now reports exactly which measurement types are missing DAY-bucket coverage so the operator can match a slow `/api/analytics` cold-mount to the responsible type without touching the DB. ### Changed - **Boot-backfill discovery now logs every result, including the silent "no users to backfill" case.** The line cost is one row per worker boot; the operator gain is the ability to tell discovery ran cleanly from discovery silently no-op'd. ### Operator notes - No new migration. No env-var change. ## [1.4.38.6] — 2026-05-18 — Boot-backfill discovery SQL fix The v1.4.38.5 discovery rewrite filtered the LEFT JOIN with `WHERE r."id" IS NULL` — but `measurement_rollups` has a composite primary key `(user_id, type, granularity, bucket_start)` and no surrogate `id` column. Postgres rejected the query with `column r.id does not exist` (SQL state 42703), the worker boot swallowed the error per the helper's best-effort contract, and the queue stayed empty. Net effect: v1.4.38.5 deployed cleanly but delivered none of its promised fast-path recovery. ### Fixed - **Boot-backfill discovery now filters on `r."bucket_start" IS NULL`** — any column from the right side of the LEFT JOIN serves as the "no matching row" sentinel; `bucket_start` is part of the composite primary key so the planner already touches the column. Verified against the live schema: `42703` no longer raised on worker boot, and the discovery query returns the expected per-type-missing candidate set. ### Operator notes - No new migration. No env-var change. - On first boot after deploy the rollup-full-backfill queue picks up users with any missing type-coverage and folds them. Power- user accounts may see one slow `/api/analytics` cold-mount before the next request lands on the restored fast path. ## [1.4.38.5] — 2026-05-18 — Analytics fast-path restored on long-running accounts Hotfix on top of v1.4.38.4. `/api/analytics` and the dashboard summary route were timing out at 30-75 s on power-user accounts after every container restart — the cold-cache miss fanned out to the live SQL aggregator across the full measurement table instead of riding the rollup-fast-path that v1.4.36 / v1.4.37 had landed. ### Fixed - **Boot-backfill discovery now widens to per-type missing coverage.** v1.4.35.1 introduced `enqueueBootTimeRollupBackfill` with a discovery query that only matched users with ZERO rollup rows. Any account that had ever been folded (every long-running user) silently stranded once a brand-new measurement type landed: the existing rollup buckets kept the user invisible to discovery, but the new type's empty DAY partition kept `isFullyCovered(coverage)` at `false` forever. Every analytics / dashboard read then fanned out to the live aggregator across the full measurement table. Discovery now joins `(user, distinct measurement type)` against `measurement_rollups` on the DAY granularity and surfaces users where any partition is unmatched. The v1.4.35.1 zero-rollup case is still covered (the LEFT JOIN of an empty rollup table emits a row for every type the user has logged). Integration test pins the regression: a user with WEIGHT rolled up + a brand-new PULSE row now lands on the queue. ### Operator notes - On first boot after deploy, the rollup-full-backfill queue picks up users with any missing type-coverage and folds them. Power- user accounts may see one slow `/api/analytics` cold-mount before the next request lands on the restored fast path. - No new migration. No env-var change. ## [1.4.38.4] — 2026-05-18 — Self-healing stale-shell after every deploy v1.4.38.3 closed the stale-shell case where the user trips `ChunkLoadError` mid-navigation — but the reload only fired AFTER the error surfaced. Users sitting on the dashboard or insights overview rode out the staleness until they clicked something. The PWA shell could silently drift across several deploys, surfacing nothing visible to the user until a click happened to lazy-load a missing chunk. ### Added - **`` client component** mounted inside ``. Polls `/api/version` every 60 s. If the live version moves ahead of the shell-baked version it unregisters every active service worker, wipes CacheStorage, and triggers `window.location.reload()`. `sessionStorage` gates the reload to once per session so a mid-deploy webhook race that briefly serves a stale image cannot loop the page. - **`NEXT_PUBLIC_APP_VERSION`** is now injected from `package.json` at build time (via `next.config.ts`) so the running shell knows which release it was built against. Available to any client component that needs to compare against the live server version. ### Changed - **Service worker `CACHE_VERSION` now keys to the release tag** (`v1.4.38.4`). The `activate` step already evicts any cache name outside the current pair, so bumping the string at release time guarantees the precached root HTML and the precached `/_next/static/*` chunks from the previous deploy are dropped on the next SW install. The string is now part of the release routine alongside the `package.json` bump. ### Operator notes - No new migration. No env-var change. - Coolify auto-deploys main on tag push. - Users on the pre-v1.4.38.4 shell still need a single manual refresh to pick up the new poller; from v1.4.38.4 onwards every future release self-heals. ## [1.4.38.3] — 2026-05-17 — CI green-up, e2e drift fixes, chunk-load auto-recover Cleans up the three pre-existing CI reds that had been failing on every push to main since v1.4.38 landed, plus a small client-side fix for the stale-shell post-deploy paper-cut. ### Added - **Automatic recovery from stale-shell `ChunkLoadError`.** After a deploy the cached SPA shell still references the old chunk filenames; Next.js fetches them, the new server 404s, and the user lands on the generic error surface. `AppError` now detects the chunk-load error family and triggers a single `window.location.reload()` to fetch the fresh shell. `sessionStorage` gates the auto-reload to once per session so an unrecoverable error cannot loop. Browsers in strict-privacy mode fall through to the error UI cleanly. ### Fixed - **TODO marker on the correlations `degraded` sentinel removed.** The `No TODO markers` workflow had been failing on every main / develop / PR build since v1.4.38; the keyword has been rewritten into prose without changing the documented intent. - **Rollup integration test no longer races the read path.** The v1.4.37.1 fire-and-forget on `ensureUserRollupsFresh` left the cold-testcontainer integration test asserting `dailyByType` before the rollup writes had landed. The test now calls `recomputeUserRollups` explicitly so the rollup-driven branch is exercised deterministically — matches the production warm-cache contract. - **Doctor-report e2e specs target the v1.4.37 hero-card testids.** v1.4.37 lifted the doctor-report card out of the export-tile grid and renamed `export-card-doctor-report` / `export-action-doctor-report` to `export-hero-doctor-report` / `export-hero-doctor-report-action`. The Playwright suite still targeted the old ids and timed out on every run. - **`measurement-flow` e2e mock returns a complete `Measurement` shape.** The list-page expects `unit` and `source` on every row; the mock omitted both, so the list render crashed before painting the row and the test's `expect.poll(...)` for "78.4" timed out. - **Dashboard "View all" achievement link reaches the 44 px tap-target floor.** The link was 46×16 px; mobile-viewport e2e rejected it. `inline-flex min-h-11 items-center` keeps the visual styling identical while satisfying the floor. ### Operator notes - No new migration. No env-var change. No public API change. - Coolify auto-deploys main on tag push. ## [1.4.38.2] — 2026-05-17 — Mood-reminder hotfix bundle + Settings toggle Hotfix bundle on top of v1.4.38.1. A six-axis review of the v0.5.4 iOS-coordination patch surfaced enough real bugs that the mood-reminder feature could not have been used safely as shipped: the locale resolver demoted four of six supported locales to English, the dedup ledger committed before delivery (so a transient APNs blip silently nuked the day's nudge), the daily-22:00 timestamp drifted by an hour on DST days, the APNs payload forwarded channel-specific structures (the Telegram `replyMarkup` keyboard) to Apple, and there was no Settings surface for the user to opt in. v1.4.38.2 closes all of the above and ships the missing toggle. ### Added - **Settings toggle for the daily mood reminder.** New card under `/settings/notifications` with a single Switch wired to the user profile flag. Six-locale copy (de/en/es/fr/it/pl) for the card title, description, status text, and toasts. Without this surface the feature shipped in v1.4.38.1 was unreachable for end users. - **`DispatchOutcome` return type** on `dispatchNotification` so callers can decide whether the send is committed to their own ledger. Existing callers ignore the return and keep working — the function remains best-effort and never throws. - **`localHmAsUtc` helper** in `@/lib/timezone` returns the UTC instant of local `hh:mm` on the local calendar day implied by `now`. Re-derives the offset at the target local time so the result is correct on DST transition days. - **Daily 03:25 Europe/Berlin retention cron** for the `mood_reminder_dispatches` ledger: deletes rows older than 90 days. Slots between the audit-log cleanup and the cumulative drain. ### Fixed - **Mood-reminder ledger now writes only after the dispatcher confirms delivery.** A `dispatched = false` outcome (no channel succeeded) leaves the slot empty so the next tick is free to retry once the user adds a channel or upstream recovers. - **Mood-reminder locale resolver honours every supported locale.** v1.4.38.1 shipped translations for es/fr/it/pl but the resolver silently demoted those users to English copy. - **Mood-reminder FR body restores the apostrophe in `aujourd'hui`.** v1.4.38.1 shipped without it; VoiceOver reads the lockscreen body aloud, so the typo was audible. - **Medication-reminder `scheduledFor` and the iOS-snooze `scheduledAt` ISO are now DST-safe.** The previous arithmetic added raw UTC hours to local midnight, which drifted by an hour on spring-forward / fall-back days. The iOS "snooze 15 min" action would otherwise anchor against the wrong baseline twice a year. - **Per-user `try` wrapper around the mood-reminder tick.** A single bad row (corrupt timezone, dispatcher exception) used to abort the whole 22:00 candidate pass; now the wide-event records a per-user failure counter and the rest of the cohort is unaffected. - **APNs payload whitelists iOS-relevant metadata keys.** The dispatcher's `metadata` is shared across every channel and was forwarding channel-specific payload structures (Telegram's `replyMarkup` inline-keyboard object, ad-hoc extras) into the APNs `userInfo` Apple sees and the iOS notification handler. Allowlist now covers only what iOS actually reads. ### Refactor - Removed an unused `MoodReminderCandidate` interface and the redundant `moodReminderEnabled` select field from the mood-reminder candidate query (the `where` clause already filters to opted-in users). - CHANGELOG entry for v1.4.38.1 rewritten to drop the `EVENT_DEFAULT_ENABLED` identifier reference and describe the default-off posture in user-readable language. ### Tests - Unit suite 4565 → 4551 (rewrote `mood-reminder.test.ts` for the new contract; net delta is a rewrite, not a coverage loss). Added tests for the ledger-after-delivery semantics, the per-user try wrapper, the P2002-race "lost the race but delivered" path, the six-locale dispatch, and the FR apostrophe regression. ### Operator notes - No new migration. No env-var change. - Coolify auto-deploys main on tag push. ## [1.4.38.1] — 2026-05-17 — iOS v0.5.4 push-notification coordination Coordinates the server side of the iOS v0.5.4 release. Two strictly additive patches that unblock the new push-notification surface without changing existing behaviour. ### Added - **APNs category on med-reminder pushes.** `sendViaApns` now sets `aps.category = "MEDICATION_REMINDER"` on every medication-reminder payload so iOS renders the three action-buttons (Genommen / Snooze 15 min / Übersprungen) wired up in iOS v0.5.3. `aps.mutable-content = 1` is set by default so a future Notification Service Extension can hook the payload without a server-side change. The med-reminder metadata gains `scheduledAt` (ISO 8601) so the iOS "snooze 15 min" action pins to the schedule slot rather than wall-clock delivery time. - **Optional daily mood-reminder event.** New per-user opt-in flag (default off) and a 15-minutely worker cron that fires a single push at the user's local 22:00 when no mood has been logged for the local date. Idempotency is anchored by a unique (user, date) ledger row, so a re-tick inside the same 22:00 window and concurrent workers cannot double-fire. Users who never opt in see no behavioural change. ### Compatibility - Older iOS builds that don't register the `MEDICATION_REMINDER` category render the push as a plain alert (iOS ignores unknown category identifiers). Backward-safe. - `0069_v054_mood_reminder` migration uses `IF NOT EXISTS` + `DO $$ ... EXCEPTION WHEN duplicate_object` guards mirroring the idempotent pattern from `0061_audit_log_carrier` and `0068_v1436_insights_exclude_metrics`. ### Tests - Unit suite 4524 → 4565 (+41 new in `mood-reminder.test.ts` + `apns.test.ts`). ### Operator notes - Migration applies cleanly on deploy. No schema downtime. - No env-var change. The cron entry is registered at worker boot. - Coolify auto-deploys main on tag push. ## [1.4.38] — 2026-05-17 — Robustness sweep, perf hotspots, full localization Closes the web punch-list before the v1.5 iOS sprint. The release folds the v1.4.36 → v1.4.37.2 perf carry-overs into a wider sweep: the iOS dashboard summary route drops from ~4.6 s cold to ~500 ms via a DAY-bucket sparkline read plus a 60 s response cache; the insights comprehensive aggregator gains the same SQL-aggregation playbook on its remaining hot sub-query. A focused robustness sweep lands fourteen correctness fixes across geo-backfill, rollup freshness, drain logging, and BP fast-path windows. The Coach feature gate cascade gains a discovery-style test that walks every insights route and surfaces two previously-orphan API surfaces missing the assistant-surface gate — both now hardened. Cross-tz fragility on the correlations and bp-in-target fast paths gets a runtime guard that falls back to live SQL for any user more than ±3 h from UTC, with the proper per-user-tz bucket minting deferred to the iOS sprint. UX polish lands seventeen P1/P2/P3 items including aria-controls on drill-down chevrons, dropdown max-width on small viewports, polite live regions on insights load, and a colour-blind-safe icon on the GLP-1 take-now pill. Localization steps from ~27 % to ~63 % coverage across es / fr / it / pl with roughly 3,400 new strings plus placeholder restoration for three medication-cluster keys. Profile updates now validate the timezone field against the IANA zone list at the write boundary, closing a self-DoS regression that the new dashboard SQL surfaced. ### Performance - **`GET /api/dashboard/summary` cold-mount 4.6 s → ~500 ms.** Four unbounded sub-queries (per-type latest, sparkline raw, today's intakes, 365 d intakes plus a conditional fifth) replaced with six bounded reads: a `$queryRaw DISTINCT ON (type)` over the 7 d window, a `$queryRaw` over `measurement_rollups` DAY buckets, the existing `groupBy` for type stats, the two intake reads, and a 365 d `to_char` distinct-day scan. The whole builder wraps in `caches.analytics` keyed `${userId}|dashboard-summary` at 60 s TTL. Per-sub-query timing annotates land in `meta.dashboard` so the next perf-verify can attribute regressions without re-instrumenting. - **`/api/insights/comprehensive` minor.** Consolidated BP sys / dia raw-row reads into a single `findMany({ type: { in: [...] } })` with JS partition (preserving order) — one round-trip instead of two. - **Sparkline contract.** For BP / weight / HRV the DAY-bucket mean matches the raw read (≤ 1 per day). For ACTIVITY_STEPS / sleep / glucose the smoother per-day average is a better trend signal. ### Coach - **Two orphan API gates closed.** A new discovery test (`coach-route-gate-inventory.test.ts`) walks every `src/app/api/insights/<…>/route.ts` and asserts each handler either calls `requireAssistantSurface("coach")` or appears on an explicit allowlist. The walk surfaced two unguarded routes — `GET` + `DELETE` on `/api/insights/chat/[id]` and `POST` on `/api/insights/chat/messages/[id]/feedback` — both now gated. - **Cascade test fixture rebuilt** to assert the cross-cut gate surface from a single source of truth, and the SSR-mode proof swapped to a spy-based assertion that does not need a full DOM render to verify the lazy-load chain. ### Robustness - Geo-backfill batch cap drops 5000 → 500 rows per pass; an in-process singleton flag guards the worker handler against re-entry across hot-reloads. - `DRAIN_CUMULATIVE_CUTOFF_HOURS` lifts into the helper module so the cron registration and the manual-drain CLI agree on the same constant. - The drill-down `take` parameter is now Zod-refined to a hard 1000-row ceiling; the route emits 422 above the cap instead of serving the unbounded read. - Analytics `daysAgo` is derived per request from the cached `lastSeenAt` so day-boundary crossings can no longer stale-serve yesterday's count. - `ensureUserRollupsFresh` now dedups concurrent same-userId callers through a `Map`, so a probe-storm folds into one round-trip. - WEEK / MONTH / YEAR rollup enqueues on measurement write fan out in parallel instead of serial. - `getClientIp` source matching tightens from a hand-rolled IPv4 regex to `node:net.isIP` so IPv6 addresses round-trip cleanly and malformed strings are rejected at the boundary. - The MEDICATION categorical enum gains a drift-guard test against the label-key map so an enum-only change can no longer ship copy fallbacks. - The dashboard medication checklist and the quick-add modal now share their TanStack Query cache (same `staleTime`) so toggling a dose no longer fans out two refetches. - The cumulative drain emits a per-user COMPLETE log line so multi-account drains can be reconciled per row in production logs. - BP fast-path priorYear read window is calendar-aware (leap-year safe). - The `correlations.degraded` sentinel carries a TODO marker until load-shedding lands — no semantic change, just a hand-off note. - The private `dayKey` helper in `bp-in-target-fast-path` renames to `bucketDayKey` so the cross-file term lines up with the cross-tz guard wave naming. - Health-score `bpInTargetPct` reuses the same prior-week query across windows instead of issuing one read per window. ### Cross-tz fragility - New `isNearUtc(userTz, now)` helper in `@/lib/tz/format`. The correlations and bp-in-target rollup fast paths now invoke it as a runtime guard: when the user is more than ±3 h from UTC the helper forces a fall-through to the live SQL path so per-day aggregates re-key via `userDayKey(measuredAt, userTz)` instead of the rollup's UTC bucket. Meta annotates land (`correlations.tz_guard`, `bpInTarget.tz_guard`) so production logs prove branch selection. Proper per-user-tz bucket minting remains a v1.5 iOS-sprint deliverable. ### UX - Drill-down chevrons gain stable `drilldown-{desktop,mobile}-${dayKey ?? id}` ids threaded as `aria-controls` on the trigger and `id` on the disclosed panel. - The Hinzufügen dropdown wraps at `max-w-[calc(100vw-2rem)]` on small viewports; the longest label trims from "Medikamenteneinnahme erfassen" to "Einnahme erfassen". - Quick-add labels test extends from en+de to all six locales for collision-guard coverage. - Select trigger right padding tightens on Safari for chevron parity. - Medication intake empty state promotes the CTA into the footer slot. - Insights load now announces via a polite live region for screen reader users. - The GLP-1 take-now window-status colour now pairs with a Lucide glyph so colour-blind users perceive the same affordance. - Arztbericht and dismiss button min-h floors tighten for visual hierarchy. ### Localization - es / fr / it / pl coverage steps from ~27 % to ~63 % per locale, roughly 3,400 leaf-value substitutions. Translated namespaces: dashboard, measurements, mood, auth, nav, charts, thresholds, comparison, onboarding, notifications, insights (sleep, sub-pages, coach settings, relative-time, hero, recommendation, health-score, daily-briefing, coach), medications (intake, status chips, categories, weekdays, intake-history, GLP-1 cluster, schedule controls), targets (medical ranges, status labels), and the doctor-report PDF strings. - Three medication-cluster keys had their placeholders restored or their hybrid English fragments translated: `glp1NextInjectionDays` (now reads natively with `{label}` + `{days}` in each locale), `intakeHistoryPageInfo` (re-appends `· {count}` clause for the per-page row count), and `dayActivate` / `dayDeactivate` (restore `{day}` so a11y labels announce per-weekday). - `admin`, `settings`, and `achievements` namespaces remain intentional T3 — operator-facing surface that will land in a follow-up wave alongside the iOS launch. ### Security - `getClientIp` source matching uses `node:net.isIP` for IPv4 / IPv6 validation instead of a hand-rolled IPv4 regex; malformed forwarded headers are rejected at the boundary. - Profile updates validate `User.timezone` against the IANA zone list through a Zod `refine` chained on the existing `isValidTimezone` helper. Returns 422 "Invalid IANA timezone" at the write boundary so the new `to_char(measured_at AT TIME ZONE $tz)` SQL in the dashboard summary route cannot be self-DoS'd by a corrupted stored value. ### Tests - Unit suite 4520 → 4524 (four new profile-update timezone-refine tests). Integration baseline unchanged. ### Deferred to v1.4.39 / v1.5 - Named `AnalyticsCachedBody` / `AnalyticsEnrichedBody` envelope split in the analytics route, plus the same-microtask rejection-retry safety fix on `ensureUserRollupsFresh`. - Coach client-side disabled-state copy on `message-thread.tsx` (server API gates are closed; only the local message copy remains). - Repo hygiene sweep (orphan imports, unused helpers, dead i18n keys). - Cumulative sparkline read should use `r.sum` instead of `r.mean` for `ACTIVITY_STEPS` / `ACTIVITY_FLIGHTS` / cumulative metrics. - Coach gate inventory walker should match the closed `route.{ts,tsx,js,jsx,mjs,mts}` set. - Dashboard sparkline live-fallback for empty rollup tail on brand-new accounts. - BP / pulse term unification in es / pl (`tensión` vs `presión`, `ciśnienie tętnicze` vs `ciśnienie krwi`, `puls` vs `tętno`). - Proper per-user-tz rollup bucket minting (v1.5 iOS sprint deliverable; today's cheap path is the ±3 h guard with live fallback). ### Operator notes - No schema change. No env-var change. No public API change. - Coolify auto-deploys main on tag push; first webhook may pull stale `:latest`, redeploy after the docker-publish workflow completes. ## [1.4.37.2] — 2026-05-17 — Slim summaries SQL aggregation Second hotfix on top of v1.4.37. The v1.4.37.1 fire-and-forget release removed the 60 s event-loop block but live HAR still showed `/api/analytics?slice=summaries` taking ~3.1 s per cache miss. Root cause: the v1.4.35 implementation read EVERY DAY rollup bucket for the user (`findMany` without a `bucketStart` window) and composed `count / min / max / mean` in JavaScript. On a power-user account that materialised as a ~306k-row transfer plus a JS loop. The slim slice's contract is the all-time per-type aggregate — exactly what a SQL `GROUP BY type` returns in a single round-trip. ### Fixed - `computeFromRollups` in `src/lib/analytics/summaries-slice.ts` swaps the unwindowed `prisma.measurementRollup.findMany` for a per-type `$queryRaw` GROUP BY that hands back 8 rows of `count / min / max / mean` instead of ~306k bucket rows. The downstream JS aggregation is bypassed because the per-type aggregate is already shaped server-side. Cache-miss budget on Marc-sized accounts: ~3.1 s → < 100 ms expected. ### Operator notes - No schema change. No env-var change. No public API change (the response body shape is identical to v1.4.37.1). - Coolify auto-deploys main on tag push. ## [1.4.37.1] — 2026-05-17 — Event-loop unblock on the read path Hotfix on top of v1.4.37. Post-deploy verification surfaced live `/api/analytics` cold-mount hits of ~62 s and parallel `/api/health` checks of ~58 s on the live instance — far above the v1.4.37 perf claim of 1.5–3 s. Root cause: the synchronous `ensureUserRollupsFresh(userId)` call at the top of each heavy read path (analytics route, comprehensive aggregator, summaries slice) folds the trailing 90-day DAY window when the rollup watermark trails the newest measurement. On an account with high-frequency Apple Health step ingest the watermark falls behind every few minutes, so each cache miss synchronously paid the full refresh cost on a Node.js worker that also serves the rest of the request fan-out — including `/api/health`, `/api/version`, and concurrent iOS calls — and the event loop was blocked for the duration of the recompute. ### Fixed - `ensureUserRollupsFresh` is now fired-and-forgotten on all three read-path call sites (`src/app/api/analytics/route.ts`, `src/lib/insights/comprehensive-aggregator.ts`, `src/lib/analytics/summaries-slice.ts`). The function already carries its own try/catch envelope, so the `void`-call cannot reject. Correctness is preserved by the downstream `probeRollupCoverage` check: when a type is partial the live fallback fires; the only user-observable change is that the very first request after a fresh measurement landed may serve data up to ~60 s old, and the next request returns the up-to-date value once the background refresh completes. ### Operator notes - No schema change. No env-var change. - Coolify auto-deploys main on tag push; first webhook may pull stale `:latest`, redeploy after the docker-publish workflow completes. ## [1.4.37] — 2026-05-17 — Final web polish before iOS focus The closing web release before the v1.5 iOS-app sprint. Carries the v1.4.36 perf carry-over for the full `/api/analytics` route (cold worst-case 111 s → ~1.5–3 s on power-user accounts), consolidates Apple Health step samples into a one-row-per-day list view with chevron drill-down and a nightly drain, restores `IntakeHistoryListV2` to match the pre-v1.4.28 contract (hides planned and skipped rows by default), converges every Coach affordance behind a single feature gate, lifts the medication-detail Mounjaro card to byte-equivalent parity with Ramipril, brings the Insights overview hero row to height parity, hardens the `getClientIp` source chain behind a `TRUST_CF_CONNECTING_IP` env flag, and ships the Arztbericht hero card on the Settings → Export page. Documentation refreshed across all three repos (README, healthlog-docs, healthlog-landing) with five new architecture diagrams in the Dracula palette. ### Performance - **`/api/analytics` full slice on the rollup-coverage probe.** `correlations`, `healthScore` and `bp_in_target` each gained a rollup-fast-path that fires whenever every logged type has DAY coverage. Live SQL fallback remains the source of truth for partial-coverage accounts. The route emits a per-branch `meta..path: "rollup"|"live"` annotate so production logs prove the branch selection. Correlation window tightened 30 → 28 days with `CORRELATION_WINDOW_DAYS` constant + sentinel annotate. - **Apple Health step consolidation.** New `groupBy=day` + `dayKey` modes on `GET /api/measurements` collapse the per-sample step rows into one daily total per row (sum + sample count); a chevron reveals the per-sample drill-down on demand. Server-side aggregation reads from `pickCumulativeDaySum`, which is source-priority-aware. A nightly `drainPerSampleCumulative` pg-boss job at 03:45 Europe/Berlin (36 h grace window so late Watch syncs surface in real time) shrinks the underlying table by the same factor. Power-user accounts should see roughly 50× row compression once the drain catches up. ### AI Insights - `IntakeHistoryListV2` on the medication detail page no longer surfaces planned/scheduled or skipped rows by default; the API gains an explicit `?status=completed` filter and the V2 component pins it. Skipped rows render distinctly when explicitly requested. Empty-state copy added for medications with only planned-no-taken entries. ### UX - **HealthScoreCard** stretches to the full height of the Insights overview hero row via a grid layout with `1fr` on the provenance accordion; the right-column card now ends at the left-column baseline instead of clipping at the divider. - **TopBar overflow menu** + **sidebar dropdown** keep menu items on a single line. Container widened to `w-60` on both surfaces so "Benachrichtigungs-Center" no longer wraps and German labels in the top-bar user menu no longer clip without ellipsis. - **Targets page** card gap tightened (`gap-3 md:gap-4` + `pb-0` on the CardHeader) to kill the ~36 px dead band below each metric label. - **Select chevron** right-margin matches the date-picker icon gutter at every size. - **Mood mini chart** wrapper collapsed to render byte-aligned with the BP and Weight minis in the trends row. - **Dashboard "Hinzufügen" button** centre-aligns with the title on mobile (`items-center sm:items-start`) so it no longer floats next to a 2-line heading. - **Medication detail card symmetry** — the Mounjaro card now carries the same status pill (take-now / overdue / very-overdue), the same purple dose accent, the same category-map label, and the same primary-action row as Ramipril. GLP-1-specific data (injection-site, rotation hint, side-effect quick-log) lives in the header-actions kebab so the visual shape stays identical across medication kinds. - **Coach disable cascade** — when the global Coach feature flag is OFF, every Coach affordance vanishes (FAB, snapshot button, HeroStrip CTA, SuggestedPrompts chips, `/targets` CoachDrawer, TargetCard CTA, personal-dropdown entry). A new invariant test pins six in-band surfaces; cross-cut gates carry sibling tests. - **Onboarding checklist** toggle + dismiss button raised to the 44 px mobile touch floor (uncovered while wiring the e2e fix). - **Settings — Timezone override** removed. The page now silently seeds the browser's IANA zone on first detect; the "Übernehmen" button and its i18n keys are gone. - **Settings → Export — Arztbericht hero card** promotes the doctor report to a prominent CTA at the top of the page with a one-line value statement. Existing export options stay below in a "Weitere Export-Optionen" section. - **Dashboard quick-add — Medikamenteneinnahme** new action in the "Hinzufügen" menu opens a ResponsiveSheet with a smart medication picker (auto-selects the medication with an open dosing window, falls back to alphabetical), pre-filled dose (read-only for now, marked informational), and a time field defaulting to now. Cache invalidation fans out to medications, analytics, insights, intake summary, achievements and the per-medication compliance chart. - **Insights BMI status** persists a sentinel cache row on the 20 s LLM timeout path so the spinner stops re-firing on every cold mount; a structured 4-line skeleton replaces the bare centred spinner. - **Trend annotation tri-state contract** carried over from v1.4.36 works correctly under the new chart-slot heights. ### Security - **`TRUST_CF_CONNECTING_IP` env flag** (default off) lets Cloudflare-fronted deployments read `cf-connecting-ip` so the rate-limit bucket key and `audit_logs.ipAddress` column reflect the real client IP instead of the proxy. `.env.example` documents the security implications explicitly: only set behind Cloudflare; nginx / Caddy / direct deployments must leave it off. - **Hourly geo-backfill** pg-boss job backfills city + country + carrier on existing `audit_log` rows that landed without resolution (e.g. the v1.4.36 UI fix surfaced gaps the backfill now closes). ### Refactor - New shared modules under `src/lib/medications/` (`window-status.ts`, `category-label.ts`) collapse two previously duplicated helpers used by both the Ramipril and Mounjaro card paths. - `CUMULATIVE_DAY_SUM_TYPES` derives from `CUMULATIVE_HK_TYPES` so the cumulative-type registry is a single source of truth across the importer, the drain, the route, the UI and the analytics metric-key mapping. - `cumulativeMetricKey` mapping hoisted out of the analytics route into the same shared module. - `getClientIp` and `getClientIpOrTrustWarning` share one ladder via the trust-warning helper. ### Schema - No new migrations. ### Validation - `dayKey` now rejects impossible calendar values (e.g. `2026-02-30`) so the drill-down can't silently shift to a neighbouring day via `Date` overflow. - `groupBy=day` / `dayKey` requests with `offset > 0` are rejected upfront; real pagination on the collapsed branch lands in v1.4.38. - `dayKey` drill-down window now honours DST transitions in the user's IANA zone (Berlin Mar 30 + Oct 26 stop showing wrong hour-overlap counts). ### CI - Vitest 4 timeout-option signature corrected on the 5 MB payload guard test. - Integration suite `isolate:true` for the mock-state-sensitive notification dispatch tests. - e2e mobile-touch-target assertions updated to the v1.4.34.5 44 px floor. ### Documentation - README rewrite with a hero section, status block, "How it works" walkthrough, and four architecture diagrams referenced inline. - `healthlog-docs` site refresh: three new integration mirrors (Apple Health, Withings, AI providers), three placeholder pages filled, two new concepts/self-hosting pages added. - `healthlog-landing` value-statement upgrade with two new sections (How-it-works, AI Coach) and the new diagrams. - Five new SVG diagrams in `docs/diagrams/` (Dracula palette, hand-authored to match the existing landing-page aesthetic): data flow, Coach pipeline, self-hosting topology, source-priority ladder, security model. ### Tests - Unit suite 4354 → 4486 passing (1 skipped). `pnpm typecheck` + `pnpm lint` clean. Integration suite stable at 230 passing; the two pre-existing mock-isolation flakes (`apns-dispatch` / `integration-status`) are now stabilised by the `isolate:true` fix shipped in this release. ### Deferred to v1.4.38 See `.planning/round-v1438-backlog.md` for the full triage table (~50 items across the six W10 reviewer axes). Headline: - Cross-tz fragility in the rollup fast-paths (UTC-anchored buckets vs user-local-day pairing) — Berlin-only today, must land before the iOS user base broadens. - Real pagination on the collapsed `groupBy=day` branch. - BP fast-path window day-edges (cosmetic ~3 % drift today). - Per-process singleton-guard on the hourly geo-backfill for multi-worker deployments. - Several smaller drift-risk and dead-branch cleanups identified by the code-review pass. ## [1.4.36] — 2026-05-17 — Perf, charts, AI payload trim, UX punch Builds on the v1.4.35 rollup foundation. The aggregator hot paths now skip the legacy live `COUNT/MIN/MAX/AVG $queryRaw` when every logged type already has DAY-bucket coverage, daily chart fetches opt into reading from `measurement_rollups`, and the Insights feature extractor swaps raw measurements for the same bucket source — which caps the prompt payload in the low hundreds of kB rather than double-digit MB for power users. The Insights page itself stops blocking the entire shell on `/api/insights/comprehensive`, so each section renders its own skeleton and fills in independently. On top of the perf wave: chart slot heights pinned to kill Recharts' `width=-1 height=-1` warning and the matching CLS, a tri-state trend annotation contract that ends the cold-mount "more data needed" flash, medication intake history restored on the detail page, About card folded into the Admin Console with an auto-checking update badge in place of the manual button, cumulative-metric tiles (Steps + four others) reading the day's running sum, and an IP-whois fallback that surfaces city + country when the carrier lookup misses. ### Performance - **Heavy-aggregate skip on the rollup-fresh + fully-covered path.** `comprehensive-aggregator` and the slim `summaries-slice` now consult a per-type coverage probe (`src/lib/measurements/rollup-coverage.ts`) before running the legacy `COUNT/MIN/MAX/AVG $queryRaw`. If every logged type has DAY coverage the probe wins and the live aggregate is skipped entirely. Partial coverage (e.g. an account with BP buckets + a freshly logged WEIGHT type) falls back to the live aggregate so no type silently underreports. - **Daily chart fetches opt into rollup buckets.** `GET /api/measurements?aggregate=daily&source=rollup` reads DAY buckets from `measurement_rollups` instead of scanning the measurements table when the window exceeds seven days. The Insights chart fetchers pass `source=rollup`; iOS and other clients are unaffected. - **`/api/insights/targets` cached.** Wrapped in the analytics LRU (60 s TTL, per-user key, evicted on measurement / mood / medication mutations). - **Insights page: early-skeleton paint.** The page-level `isLoading` gate that held the entire shell on `/api/insights/comprehensive` is gone. DailyBriefing, CorrelationRow and TrendsRow render their own skeletons and fill in independently. (This is early-skeleton paint, not Suspense streaming — the page is `"use client"` + `next/dynamic({ssr:false})`, so the boundaries the rewrite tried to install never triggered and were dropped during reconcile.) ### AI - **Insights `extractFeatures` swap to rollup buckets.** The feature extractor reads `measurement_rollups` DAY buckets instead of raw measurements, dropping the prompt payload on a power-user account from ~26 MB to the low hundreds of kB. A hard 5 MB `FEATURES_MAX_BYTES` guard catches future regressions: the route handler downgrades on first oversize (drop raw measurements) and again on second oversize (drop anthropometrics + medications + compliance + sleep + steps + HRV + resting HR via the exclude filter). A third failure returns 422 with `insights_payload_too_large` — never a 500. - **Coach exclusion toggles surfaced.** The Coach settings sheet gained medications and anthropometrics switches. The Coach PUT mirrors `excludeMetrics` onto a new `users.insights_exclude_metrics` column so the Coach sheet and the Insights privacy panel share a single contract. - **`compactSections` empty-block omit.** Coach and Insights prompts no longer ship `Schlafdaten: [keine]` / `Medikamente: [keine]` lines for sections the account has no data for. ### Charts + Insights UI - **`trends-row-chart-slot` fixed to `h-[140px]`.** Mood, BP and Weight trend cards now align byte-for-byte. Recharts' `width=-1 height=-1` warning is gone; expect a measurable Lighthouse CLS drop on the Insights page. - **`ChartSkeleton` mini variant** matches the loaded chart's wrapper dimensions so the trends row stops shifting on hydration. - **`TrendAnnotation` tri-state contract** (`pending` / `needs_data` / `generated`). Fixes the cold-mount and regenerate-in-flight flash of "mehr Daten nötig, um diesen Trend zu kommentieren". `pending` wins over `needs_data` so a regenerate already in flight never drops into the empty-hint copy. ### UX - **Medication intake history restored** via `` on the medication detail page. Server-paginated, sortable by `takenAt`, no inline CRUD (consistent with the v1.4.28 retirement rationale — edits and deletes go through the regular medication- intake routes). Mounted for every medication kind, not just GLP-1. - **Tab-strip scroll** `inline: "center"` → `inline: "start"` on the Settings and Admin Console nav strips. The selected chip now lands flush-left instead of jumping into the middle of the viewport. - **Cumulative-metric tiles** (Steps, Active Energy, Walking/Running Distance, Flights Climbed, Time in Daylight) read the day's cumulative sum from 00:00 in the user's timezone via a new `pickCumulativeDaySum` helper, source-priority-aware in the same shape SLEEP_DURATION already used. - **Insights nav strict-gate** flipped (`if (!availability) return true` → `return false`). Types without measurements no longer appear in the strip. - **About → Admin Console.** "Über HealthLog" moved out of the personal dropdown into a new `/admin/about` slug. The public marketing `/about` page is untouched. `/settings/about` stays as a bookmarkable permalink for any authenticated user. - **Update badge auto-check.** The manual "Update prüfen" button is retired. A 44 px-square, focus-ringed, `aria-label`-bearing badge surfaces next to the version line when the existing 24 h auto-check returns `newer_available`; the latest tag is interpolated into both the label and an `sr-only` span. - **IP-whois fallback** surfaces city + country with "Carrier nicht verfügbar" / "Carrier unavailable" when the upstream ASN lookup misses. The free-tier ipwho.is shortcoming is now graceful UX, not a blank chip. ### Accessibility - IntakeHistoryListV2 sort headers and pagination buttons clear the 44 px touch floor and carry `focus-visible:ring`. - UpdateBadge ditto, with a real `aria-label` and an `sr-only` tag exposure inside the anchor. ### Schema - **Additive `users.insights_exclude_metrics`** (`String[] NOT NULL DEFAULT '{}'`). Migration `0068_v1436_insights_exclude_metrics` is idempotent under re-run. ### Refactor - New shared helpers under `src/lib/insights/` and `src/lib/measurements/`: `compactSections`, `applyInsightsExcludeFilter`, `pickCumulativeDaySum`, `probeRollupCoverage`, `isFullyCovered`. Tested standalone. - `ensureUserRollupsFresh` now annotates `{ rollup_refresh_failed: true, rollup_refresh_error: ... }` on failure instead of swallowing silently. The read path still returns `{ recomputed: false }` so the response never fails because of a populator hiccup. - Cumulative metric-key lookup hoisted out of a four-level nested ternary into a `switch`. - 42 orphan i18n strings removed (7 keys × 6 locales) — the `settings.about.updates*` / `checkUpdates` cluster left behind by the manual update-check retirement. ### Tests - Unit suite 4285 → 4354 passing (1 skipped). `pnpm typecheck` + `pnpm lint` clean. Integration suite stable at 230 passing; two known mock-isolation flakes (`apns-dispatch` / `integration-status` and `enqueueBootTimeRollupBackfill` when run alongside `admin-backups-audit.test.ts`) pass in isolation and predate v1.4.36. ### Deferred to v1.4.37 - `applyInsightsExcludeFilter` shallow `next.context` mutation needs a contract test pin. - Narrow-aggregate query still scans 90 days of `measurements`; the column-pruning is real but not sub-second on cold rows. - `/settings/about` legacy route still serves AboutSection to any authenticated user — content scope is benign; decision pending on redirect vs 404 vs documented permalink. - `/api/measurements?source=rollup` response omits `id` / `unit` / `source` for the bucketed shape; iOS unaffected (does not pass `source=rollup`). A dedicated `MeasurementBucketResource` schema or an `id`/`unit` echo lands next. - `BUCKETED_TYPES` in `features.ts` duplicates the rollup-populator enum (drift risk). - COUNT-probe call sites in `summaries-slice.ts` and `comprehensive-aggregator.ts` could collapse into one helper. - Cumulative SUM `mean × count` over-counts when two sources contribute the same day (Apple Health + Withings both posting steps). Pre-existing chart behaviour, not a v1.4.36 regression. ## [1.4.35.1] — 2026-05-17 — Auto-converging rollup backfill on worker boot Follow-on to v1.4.35. Removes the operator action that was implicit in the foundation release: instead of self-hosters having to run `pnpm tsx scripts/backfill-rollups.ts` after upgrading, the worker boot now discovers any account with measurements but no rollup coverage and enqueues a one-shot full fold via pg-boss. Idempotent across reboots (the discovery query only matches uncovered users) and zero reader-path impact (serial concurrency, runs in the background). ### Boot-time backfill - **New `rollup-full-backfill` queue** (`ROLLUP_FULL_BACKFILL_QUEUE`, concurrency = 1) carrying `{ userId, enqueuedAt }`. The handler invokes `recomputeUserRollups(userId)` with the default 5-year window across all four granularities — equivalent to the manual CLI run, but inside the worker process so no `tsx` install gymnastics in production images. - **`enqueueBootTimeRollupBackfill`** runs once at `startReminderWorker` boot after every `boss.work` subscription is in place. The discovery query finds users with at least one row in `measurements` and zero rows in `measurement_rollups`, then sends one job per uncovered account with `singletonKey: boot-backfill|{userId}` so a fast restart while a fold is queued doesn't pile up duplicates. - **Best-effort:** any error during discovery is captured in the return value and logged through `workerLog`; the worker boot never fails because the backfill missed. ### Tests - 5 new unit cases pin the helper's contract (silent no-op without boss, one job per uncovered user, singleton-key coalesce counted as `skipped`, error surfacing, zero-users path). 2 new integration cases verify against the real testcontainer: only the uncovered user lands on the queue when a third already-folded user co-exists; zero jobs enqueued when every account is already covered. - Unit suite 4280 → 4285. Integration suite 228 → 230. `pnpm typecheck` + `pnpm lint` clean. ## [1.4.35] — 2026-05-17 — Persistent measurement rollups + partial read-swap Foundation release for the persistent `measurement_rollups` cache tier. Adds a new additive table, populates it from every write path, and switches two reader surfaces (the comprehensive insights aggregator and the slim analytics summaries slice) onto it for the linearly-composable stats. Slope / R² / standard deviation / anomaly counts continue to run against live SQL — they don't compose across DAY buckets and stay on the canonical aggregate. A full read-swap across every analytics surface is planned for a follow-up. ### Schema - **New table `measurement_rollups`** keyed on `(user_id, type, granularity, bucket_start)` with `count / mean / min_value / max_value / sd / slope / r2 / computed_at`. Backed by a composite-descending index on `(user_id, type, granularity, bucket_start DESC)`. Additive only — no `ALTER` on existing tables. Migration `0067_v1434_measurement_rollups` is idempotent under re-run. - **New enum `measurement_rollup_granularity`** with `DAY`, `WEEK`, `MONTH`, `YEAR` granularities. The DAY grain is recomputed inline on every measurement write; the wider grains queue onto pg-boss. ### Write hooks - Every measurement create / update / delete endpoint now calls `recomputeBucketsForMeasurement` inside a `try / catch` after its successful commit. The DAY bucket is folded synchronously so the next read is correct; WEEK / MONTH / YEAR follow on the pg-boss `rollup-recompute` queue (concurrency = 2, singleton-keyed per bucket to coalesce burst writes onto a single worker run). - The Apple Health import worker calls `recomputeUserRollups` once at completion instead of per-row, scoped to the import's date range, so a 100k-sample backfill no longer spawns 100k write hooks. ### Reads - `comprehensive-aggregator` (the engine behind `/api/insights/comprehensive`) sources `count / min / max / mean` per type from the DAY buckets via `aggregateBuckets`, plus the `dailyByType` correlation feed straight from the bucket means. A defensive parity check against the live aggregate's `COUNT(*)` falls back to live SQL when divergence is detected — covers the cold-mount edge case where the rollup populator is mid-flight. - `summaries-slice` (the slim slice behind `?slice=summaries`) does the same. The all-time window degrades cleanly on accounts that never ran the explicit backfill: the rollup covers only what the warm-on-read populator pre-folded, the parity check diverges, and the live SQL values take over — no semantics break. - `ensureUserRollupsFresh` is a cheap watermark query that runs before every read; on a warm process it's a single indexed lookup, on cold mount it folds the trailing 90-day window into the DAY rollup before the bucket read fires. ### Backfill - New `scripts/backfill-rollups.ts` walks every user (or one account via `--user `) and folds their measurement history into all four granularities. Single-user serial so the Prisma pool stays out of contention. Idempotent — every run upserts under the composite primary key. ### Tests - Three new integration cases in `tests/integration/measurement-rollups.test.ts` pin the parity contract end-to-end against a real Postgres testcontainer: the aggregator's `count / min / max / mean` matches live SQL byte-for-byte, `dailyByType` matches a parallel `date_trunc('day', …)` GROUP BY query, and a freshly-written measurement is reflected on the next read. - Unit suite up by 31 cases (4249 → 4280); integration suite up by 6 cases (222 → 228). `pnpm typecheck` + `pnpm lint` clean. ## [1.4.34.5] — 2026-05-17 — Audit follow-on: critical-path tests + iOS textarea zoom Follow-on to v1.4.34.4. Two batches: the missing critical-path integration tests the test-coverage audit flagged, and the textarea viewport-zoom fix the mobile-deep audit flagged as F-5. ### Tests - **24 new integration tests across 5 files** closing the critical gaps from `.planning/round-audit-test-coverage.md`: - `tests/integration/auth-password-change.test.ts` pins the v1.4.34.3+ `destroyAllSessions` three-transport revocation contract end-to-end (Session.deleteMany, ApiToken.revoked, RefreshToken.revokedAt). - `tests/integration/admin-reset-password.test.ts` mirrors the same contract for the admin-driven reset. - `tests/integration/withings-oauth.test.ts` covers connect / callback / disconnect plus state-token replay rejection. - `tests/integration/passkey-register.test.ts` covers register-options + register-verify + tampered-attestation rejection + challenge single-use. - `tests/integration/cache-invalidation-coverage.test.ts` grids the v1.4.34 IW-G invalidation matrix across 5 write surfaces. - Integration suite goes from 197/50 to 222/55. tsc + lint clean. ### Mobile - **Five textareas no longer trigger iOS Safari zoom-on-focus.** The Coach composer, bug-report description, admin feedback note, medication-side-effects notes, and medication-intake JSON import all shipped `text-sm` (14 px). iOS Safari zooms the viewport whenever an input renders below 16 px. Switched each to `text-base sm:text-sm` so the mobile baseline clears the floor while desktop keeps the compact look. ## [1.4.34.4] — 2026-05-17 — Audit-driven hotfix bundle (security, UX, code-quality, docs) A consolidated hotfix landing the findings from a six-axis audit covering mobile security, UX + accessibility, code quality + docs, README + discoverability, web performance, and mobile-deep responsive behaviour. None of the changes break the public API or the existing data shape; every fix is additive and SAFE under the v1.4.34.x web freeze. The full audit reports live in `.planning/round-audit-*.md`. ### Security - **Password rotation now revokes every transport.** `destroyAllSessions` previously deleted only `Session` rows; long-lived `ApiToken` Bearer credentials and short-lived `RefreshToken` rotations survived a user-initiated password change or an admin reset. A stolen iOS Bearer kept working past the user's most obvious self-remediation. The helper now wraps a `$transaction` that revokes all three transports in one shot. Same rotation runs from `POST /api/auth/password`, `POST /api/admin/users/[id]/reset-password`, and `DELETE /api/settings/account`. - **`/.well-known/` proxy bypass tightened.** Replaced the prefix-match `startsWith("/.well-known/")` with an explicit-allowlist `Set` seeded only with `apple-app-site-association`. New IETF discovery endpoints must be added explicitly so a future `/.well-known/openid-configuration` doesn't silently inherit "no auth" status. Also dropped the stale `/api/auth/codex/callback` PUBLIC_PATHS entry (the route was renamed to the device-code family). - **HSTS gains `preload`.** The domain is now eligible for the Chromium preload list (submit to hstspreload.org after this lands). - **Withings host removed from the global CSP.** `wbsapi.withings.net` was in every page's `connect-src`; mirrored the AI-host gating pattern so the host enters CSP only on `/settings/integrations/withings` and `/api/withings/` paths. Closes a DOM-XSS exfil shape on every non-Withings page. - **`getClientIp` trust-violation signal.** When `TRUST_PROXY_HOPS` and the XFF chain length disagree, every anonymous caller collapsed to the `"unknown"` rate-limit bucket. The helper now warns once per process and exposes a tagged `getClientIpOrTrustWarning(request)` return so future callers can route to a tighter universal bucket. - **Service-worker URL trust.** `notificationclick` now rejects payload URLs whose origin doesn't match `self.location.origin` and falls back to `/`. Push payloads are VAPID-authenticated but the push-server is our own; a server-side bug should not be able to navigate the user's PWA window off-origin. - **PWA manifest hygiene.** Added `"scope": "/"`, `"id": "/?source=pwa"`, `"display_override": ["standalone"]` so sub-path deployments don't inherit root scope and install-prompt heuristics anchor on a stable id. ### UX + accessibility - **i18n leaks closed.** Dialog and Sheet close-X buttons no longer ship hardcoded `Close` — both read `common.close` in every locale. Same fix on the ChartSkeleton's loading announcement (new `charts.loadingLabel` key) and the notifications-section breadcrumb (`nav.breadcrumb`). All six locale files updated. - **Focus rings on mobile + tablet navigation.** The bottom-nav primary links, the More overflow trigger, and the mobile top-bar user menu trigger gained `focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-offset-2`. Keyboard and switch-control users can now see focus on every nav surface. - **Tap-target floor (WCAG 2.5.5).** Input, NativeSelect, Select, and DateTimeInput now ship `h-11 sm:h-10` so mobile clears the 44 px floor while desktop keeps the 40 px norm the Button primitive converged on in v1.4.33. - **Admin chip-strip auto-scroll-into-view.** Mirrored the v1.4.33 settings-shell pattern: the active admin chip now scrolls to the centre of the strip on route change. - **List-page subtitles on mobile.** Measurements, Mood, and Medications pages now keep their descriptive subtitle on `