# Changelog ## [1.4.40] — 2026-05-21 — Comprehensive Critical+High architecture closure and iOS PB30 enablement v1.4.39.x stitched the rollup tier across mood, medication compliance, and cumulative metrics, and closed the dashboard read paths the rollup tier replaced. v1.4.40 is the architecture-closure release on top of that base: the Prisma pool starvation root cause documented in the v1.4.39 empirical trace is fixed at the source, the soft-delete invisibility contract is now consistent across every reader tier (the audit's Critical Finding #3), the per-tile Suspense boundaries that let dashboard chart tiles stream independently are in place, the queryKey factory has CI enforcement that catches bare-literal regressions, and the iOS PB30 backend prerequisites (Apple App-Site Association, AI consent receipts CRUD, time-sensitive APNs payload, public privacy page, notifications/status surface) are live so the iOS v0.5.x sprint can land its dependent screens without further backend churn. Eleven touch-disjoint waves landed on `develop` before this release commit. The wave reports under `.planning/phase-W-*-v1440-report.md` document the per-wave decisions, file inventories, and test deltas; the multi-axis QA round under `.planning/round-v1440-QA-*-findings.md` covers the cross-cutting verdict that gated this tag. ### Added - **`POST /api/consent/ai` + `GET /api/consent/ai/latest` + `DELETE /api/consent/ai`** — AI consent receipts for App-Store Guideline 5.1.2(i) and GDPR Art. 7 audit trail. Discriminated by `kind` over `ai_full / ai_insights_only / ai_coach` so each consent surface the iOS client collects independently ends up as its own row. Append-only: revoking a receipt writes a new row, never mutates history. 64 KB byte-bounded artefact cap (UTF-8 byte length, not character count) keeps the audit table from absorbing multi-megabyte rows from a misbehaving client. - **`GET /.well-known/apple-app-site-association`** — universal-link AASA payload pinned to `S8WDX4W5KX.dev.healthlog.app`. Response is served as `application/json` without a charset parameter (Apple's `swcd` daemon is strict about the Content-Type shape) and the payload structure is regression-tested for app-ID parity against `applinks.details[].appIDs`, `webcredentials.apps`, and `appclips.apps` so a future split rotation cannot drift one bundle out of the trio. - **`GET /privacy`** — public bilingual (German / English) privacy page covering the nine SB-3 disclosure requirements (data categories, third-party processors, sub-processors, retention, deletion rights, AI-assistance scope, marketing posture, contact). Paired-section layout (no JS-driven locale switching) so the static document remains comprehensible to a regulator regardless of the browser's `Accept-Language`. - **`GET /api/notifications/status`** — operator-and-iOS-side surface for the APNs reachability ledger. Reports `aps_ready / aps_last_error / aps_last_delivery_at / device_token_count` so the iOS app can flag a stale-token failure mode without a separate diagnostic call. - **CI knip gate** (`.github/workflows/knip.yml`) — fails any push to `main` carrying unused exports, unlisted dependencies, or orphaned binaries. The whitelist (`knip.json`) carries three files (`e2e/setup/test-helpers.ts`, `compliance-line-chart.tsx`, `src/lib/logging/index.ts`) deferred pending the "delete with their dedicated tests" follow-up; everything else is green. - **`src/lib/rollups/` umbrella** — every rollup helper now lives under one canonical import root. The previous in-tree split between `src/lib/measurements/rollups*`, `src/lib/mood/mood-rollups*`, and `src/lib/medications/medication-compliance-rollups*` is collapsed into `@/lib/rollups/{measurement,mood,medication-compliance,read-wmy,read-cumulative}.ts`. Importers updated in lockstep; zero orphan re-exports. ### Changed - **Per-tile Suspense boundaries on the dashboard.** Each chart tile now mounts inside its own `` so a slow tile no longer blocks the others on first paint. The parent gate is the slim / thick analytics merge (already split per v1.4.39.2); the per-tile Suspense layer is the structural foundation the v1.4.41 React Server Components migration will graft onto. `mood-chart` queryKey dedup eliminates one round-trip on cold mount. - **queryKey factory enforcement** (`src/lib/__tests__/query-keys.test.ts`). A walker test fails CI if any guarded file declares a bare-literal `queryKey: [...]`. Guarded roots are `src/components/charts/`, `src/components/comparison/`, `src/app/page.tsx`, and `src/hooks/use-auth.ts`. Cheaper than a custom ESLint rule and points the failure message at the exact `file:line` a contributor needs to fix; opt-in expansion as future waves migrate the remaining sites away from bare literals. ### Fixed - **Prisma pool starvation root cause** (empirical-trace finding #1). The 15-way `fetchMeasurementSeriesChunked` fan-out in `/api/analytics` thick used to monopolise ≥ 8 of the default-10 `pg.Pool` connections during a power-user cold mount, blocking every Wave-B and Wave-C chart-tile fetch behind it. The fan-out is now wrapped in `p-limit(4)` so analytics holds at most 4 pool slots, and the `pg.Pool` `max` is raised from the library default 10 → 20 (overridable via `DATABASE_POOL_MAX`) so a second concurrent power-user retains ≥ 8 free slots after both branches hit their `p-limit(4)` cap. The cap is a per-request instance, not module-level, so a stale limit cannot leak in-flight state across HTTP boundaries. - **Soft-delete invisibility full-wire** (audit Critical Finding #3). Eleven reader-tier helpers across `src/lib/measurements/rollups.ts`, `src/lib/measurements/rollup-coverage.ts`, `src/lib/analytics/{summaries-slice,correlations-fast-path,bp-in-target-fast-path,health-score-fast-path}.ts`, `src/lib/insights/comprehensive-aggregator.ts`, `src/app/api/dashboard/summary/route.ts`, `src/app/api/measurements/route.ts`, `src/app/api/measurements/series/route.ts`, and `src/lib/ai/coach/snapshot.ts` now filter `deletedAt: null` (or the SQL equivalent `m."deleted_at" IS NULL`) at every aggregate, every cursor walk, every `DISTINCT ON` latest probe, and every rollup-rebuild SQL. Three integration-test contracts in `tests/integration/measurement-soft-delete.test.ts` pin the invariant against a Postgres testcontainer. - **Six remaining insights `measurement.findMany` sites** that the W-INSIGHTS wave's mood-rollup swap left unfiltered (`/api/insights/{targets,cards,generate}` plus `src/lib/insights/{features,glp1-plateau,pulse-status}.ts`). All six now filter `deletedAt: null` so the iOS-adapter card stream, the AI prompt feature aggregator, the GLP-1 plateau detector window, and the per-type tile-strip averages stop counting tombstoned readings once iOS sync starts emitting deletions. - **Compliance-rollup hook gap on bulk-projection paths.** Both `/api/medications/intake?scope=today` and `/api/dashboard/summary` mint fresh `(medicationId, scheduledFor)` rows in PENDING state when a daily schedule is projected on first read. Without a recompute hook the rollup for the affected `(user, medication, day)` tuples stayed at its previous (pre-projection) `scheduled` count, which inflated the apparent compliance % until the user logged against the new row. Both call sites now fire one recompute per distinct `(medicationId, dayKey)` tuple, deduplicated through a `Set` so the cost stays bounded. The recompute call is wrapped in `Promise.allSettled` so any future change that lets the helper throw still leaves the user request 200-OK. - **`/api/dashboard/summary` nested-ternary regression** in the heroNumber branch flattened to an `if / else if / else` chain so the linter, the type-narrower, and a human reader all parse the same way. - **Lint regression** in `src/lib/rollups/` (post-umbrella-move) — a stray `any` import path and one un-narrowed `unknown` resolved by the typecheck-led restructure. - **`dashboard-suspense-boundaries.test.ts` regex pin** updated to the post-W-INFRA shape. The test pinned `useMemo(..., [user?.timezone])` but the production code lifts `user?.timezone` to a `userTimezone` local one line above the `useMemo` so the dependency array stays a stable reference across renders. - **Consent artefact 64 KB cap** enforced via `Buffer.byteLength(value, "utf8")` (not the prior `z.string().max()` which counts UTF-16 code units). A UTF-8 artefact full of multi-byte code points would have slipped past the 64 KB row budget; the audit-table guarantee is byte-bounded, not code-unit-bounded. ### Performance Expected on Marc-sized accounts; numbers anchored on the v1.4.40 empirical trace and the wave-level instrumentation. Live perf-verify rides the post-deploy window. - **Wave-C chart-tile first-paint: +7.3 s → +1.6 s.** Bounded analytics fan-out (p-limit 4 + pool max 20) lets the 6× `/api/measurements?source=rollup` Wave-C burst release incrementally as analytics rotates lanes, instead of gating the entire burst behind the thick analytics drain. - **6 insights routes cold-mount.** The mood-rollup swap on `/api/insights/{features,targets,cards}` moves mood aggregation off the live `MoodEntry.findMany` walk onto the v1.4.39 mood-rollup tier; the cold-mount budget for the affected routes drops onto the same flat-200 ms band the v1.4.39 `/api/mood/analytics` numbers land in. - **`avg30LastYear` now populated.** The 425-day `since` cap on the `/api/analytics` live-fallback per-type loop (v1.4.39) lets the year-ago baseline tile resolve from raw data without forcing a 347 k-row scan; the v1.4.40 pool cap keeps that fallback off the hot path. - **`slope90` via MONTH-tier reader.** `readBestGranularityRollups` auto-routes the 90-day slope window onto the MONTH bucket where coverage allows, eliminating a per-cold-mount aggregate on multi-year tenants. ### Operator notes - **Migration 0074** adds the `consent_receipts` table (id, userId, kind, artefact, signedAt, revokedAt, createdAt) and the matching index over `(userId, kind, revokedAt, signedAt DESC)`. Additive; no destructive column drops; safe to run forward on a live database. - **AP-2 .p8 key install gates SB-5.** The APNs `time-sensitive + priority 10` payload landed in the worker (`MEDICATION_REMINDER` only — the parameterised test pins all six other event-types do not bypass Focus) but real delivery requires the production `.p8` private key to be installed in the Coolify secret store. Until that key lands, `aps_last_error` will surface as `auth-failed` on `/api/notifications/status` — that is the expected pre-key state, not a regression of this release. - **No breaking API contract change for iOS v0.5.4.** Every existing route shape is byte-compatible; the new routes (`/api/consent/ai*`, `/.well-known/apple-app-site-association`, `/api/notifications/status`, `/privacy`) are additive surfaces. - **No env-var change required for upgrade.** `DATABASE_POOL_MAX` is optional (defaults to 20). - `pnpm test --run` green at 4726 passing / 1 skipped (4727 total); `pnpm typecheck`, `pnpm lint`, `pnpm knip --include files,dependencies,binaries,unlisted` all green; the knip CI gate now fails any push to `main` carrying unused exports. ## [1.4.39.4] — 2026-05-21 — Dashboard symmetry for daily-schedule intake projection v1.4.39.3 fixed `/api/medications/intake?scope=today` to project active schedules through the new `expandTodayIntakes` helper and idempotently backfill missing rows for daily meds (`daysOfWeek: null`). `/api/dashboard/summary` reads the same today-window for its compliance tile but had no projection step, so the iOS Dashboard tile fell to "Heute nichts geplant" even when the intake route surfaced the same meds correctly. ### Fixed - **`/api/dashboard/summary` compliance tile** now mirrors the intake route's projection. Reuses the canonical `expandTodayIntakes` helper plus an idempotent `createMany({ skipDuplicates: true })` that survives a concurrent intake-route hit racing the same `(userId, medicationId, scheduledFor, REMINDER)` row in before the existence probe converges. ### Operator notes - No migration. No env-var change. No API contract break. - Daily meds with `daysOfWeek: null` (DB convention for "every day") now surface in the iOS Dashboard tile + Erfassen sheet the moment the user opens the app, instead of waiting for the reminder worker to enter RED phase at the end of the dose window. ## [1.4.39.3] — 2026-05-21 — Dashboard slim/thick merge robustness and list-page value precision The post-deploy CI for v1.4.39.2 surfaced eight e2e failures across two viewport profiles (chart-overlay-controls, dashboard, charts-mobile, measurement-flow) that were all downstream of two narrow regressions introduced — or, in one case, surfaced — by the v1.4.39.2 dashboard split. v1.4.39.3 fixes the root causes, hardens the merge contract with a pure helper + unit coverage, and brings the e2e route-mock patterns in line with the v1.4.37 W-CI lesson so the regression class cannot recur. ### Fixed - **Dashboard slim/thick merge no longer blanks the tile strip when the slim slice resolves empty.** The v1.4.39.2 inline merge used `slim?.summaries ?? thick?.summaries`. JavaScript treats a populated `{}` as truthy, so a zero-data slim resolve short-circuited the thick fallback even when thick carried the full per-type payload — the tile strip painted blank, the chart row never mounted, and the range tabs / overlay-controls trigger downstream of the strip cascaded into eight test failures. The merge now extracts to `mergeSlimAndThickAnalytics` (`src/lib/analytics/merge-slim-thick.ts`) which uses object emptiness as the discriminator: a populated slim still wins on overlapping fields (the v1.4.39.2 progressive-paint contract is preserved), an empty slim falls through to thick, and both empty leaves the dashboard's data-floor gates to render the appropriate empty state. - **`MeasurementList` no longer truncates non-grouped readings to integers.** The v1.4.37 W7c collapsed-list view passed every value through `fmt.integer`, which kept the per-day step / activity aggregates correct but silently truncated single weight / body-fat / body-temperature readings — 78.4 kg rendered as "78 kg" on both the desktop table and the mobile card. Grouped rows (`isGrouped === true`, the daily aggregate row for cumulative HK types) keep `fmt.integer` because the underlying readings are integer-only by definition; non-grouped rows now use `fmt.number` which honours up to three fraction digits without forcing a minimum (so 78 stays "78" and 78.4 stays "78.4" / "78,4" depending on locale). ### Changed - **Playwright route mocks for `/api/analytics` migrate to the regex pattern across every authenticated spec** (`dashboard`, `chart-overlay-controls`, `measurement-flow`, `charts-mobile`, `a11y`, `v1427-responsive-sheet`, `v1427-coach-launch`, `v1427-measurements-add-param`, `v1427-insights-empty-state`, `onboarding-tour-passthrough`). The literal `**/api/analytics` glob Playwright minimatch-compiles does not match the sliced URL form `/api/analytics?slice=summaries` that the v1.4.39.2 dashboard split fires alongside the thick request, so the slim request fell through to the real route and on the seeded test user that returns empty summaries — exactly the failure mode the v1.4.39.3 production fix guards against, but the regex form is the durable test-side alignment with the v1.4.37 W-CI lesson that already swept `onboarding-flicker.spec.ts` and `mobile-viewport.spec.ts`. ### Notes - No schema migration. Runtime + test-fixture-only changes. - `pnpm test --run` green at 4662 / 4663 (1 long-standing skip); the new `mergeSlimAndThickAnalytics` helper carries eight behaviour- level unit tests covering the empty-slim, slim-only, thick-only, both-empty, and `lastSeenByType` precedence cases. - Full Playwright suite green at 116 passing / 36 skipped / 152 total on both `chromium-desktop` and `chromium-mobile` profiles, locally against the production Next.js build (`pnpm build && pnpm e2e`). Closing every one of the eight failures from CI run `26213226723`. ## [1.4.39.2] — 2026-05-21 — Per-request rollup convergence and progressive dashboard paint v1.4.39.1 wired the rollup write hook into the previously-bypassed Withings sync, `/api/import`, and admin-restore paths. New writes from those surfaces now fold the persistent rollup tier on touch, and the boot-time backfill discovery surfaces every per-(user, type, day) gap so a worker restart converges any stranded accounts. v1.4.39.2 closes two follow-ups Marc surfaced from the post-deploy trace: the dashboard chart still painted its `< 3 daily points` empty-state on the 30-point range for accounts whose historic rollup partition had not yet been folded, and the cold-mount UX waterfall left every per-type tile waiting on the heavy `/api/analytics` envelope. ### Fixed - **`GET /api/measurements?source=rollup`** now reconciles the rollup table against the live `measurements` table when the read returns suspiciously few rows. A request that lands while the boot backfill is still folding the historic partition would previously short- circuit on the sparse rollup (`rollupRows.length > 0`) and never reconcile with the live table. The route now probes `COUNT(DISTINCT date_trunc('day', measured_at))` against `measurements` for the requested window when the rollup carries fewer than three rows on a window of at least seven days; if the live table holds more distinct days than the rollup, the route folds the `(user, type, DAY, [from, to])` partition inline via `recomputeUserRollups` and re-reads. The chart paints the full window on the same request without paying the cost on subsequent requests (the rollup is now converged). The probe is gated on rollup sparsity so covered-tenant hot paths stay single-read. ### Changed - **Dashboard analytics fan-out** split across two parallel queries. The per-type tile strip now reads from the slim `?slice=summaries` branch and paints as soon as the slim slice resolves; the BD-Zielbereich and glucose tiles stream in from the thick branch afterwards. Pre-fix the single thick-slice query blocked every per- type tile until the heavy fan-out resolved, so the mood and medication tiles (separate routes) painted first and every other tile arrived as one delayed burst. Both queries share the `caches.analytics` 60 s LRU server-side so warm hits stay free. ### Notes - No schema migration. Runtime-only fix on top of the v1.4.39.1 write-path hooks. - `pnpm test --run` green at 4651 / 4652 (1 long-standing skip); targeted suites (`src/app/api/measurements`, `src/lib/measurements`, `src/app/__tests__`) all clean. - The inline rollup fold cost is bounded by the requested window and one measurement type. For the dashboard chart's 30-day BP_SYS window that is a single 30-row upsert against the composite primary key — well inside the request budget. ## [1.4.39.1] — 2026-05-21 — Rollup tier catches Withings + import + admin-restore write paths The v1.4.39 dashboard chart "Noch nicht genug Daten" empty-state on the 30-point range traced to three measurement write paths bypassing the rollup table's per-write hook: Withings sync (BP / weight / pulse / body-fat / activity / sleep), the JSON / CSV `/api/import` endpoint, and the admin backup restore. Once the chart's `source=rollup` fast- path landed in v1.4.36, those days never reached the chart at 30 / 90 days — the live raw fetch at 7 days kept showing data, the rollup fast-path at 30 / 90 days under-counted distinct days, and the chart tripped its `< 3 daily points` empty-state. v1.4.39.1 wires the rollup write hook into every remaining ingest path and tightens the boot-time backfill discovery so any stranded accounts re-converge on the next worker boot. ### Fixed - **Withings sync** (`sync.ts` / `sync-activity.ts` / `sync-sleep.ts`): every (type, day) pair the sync touched is now handed to `recomputeBucketsForMeasurement` after the row writes. Collapsed via `collapseToTypeDayKeys` so a 30-day catch-up costs at most ~N (type, day) recomputes rather than one per row. Best-effort: a populator hiccup never fails the user's sync. - **`/api/import` endpoint**: measurement creates now track touched `(type, day)` pairs and fold the rollup tier at the end of the batch. Mirrors the v1.4.39 mood-rollup hook on the same route. - **Admin backup restore**: the transaction now wipes the user's `measurement_rollups` partition alongside `mood_entry_rollups`, and the post-transaction step kicks a full `recomputeUserRollups` so the restored dataset paints fresh tiles instead of carrying the previous owner's daily means forward. - **Boot-time backfill discovery** (`enqueueBootTimeRollupBackfill`): the missing-coverage join moved from per-type to per-day, so an account whose Withings sync wrote 27 days of BP without ever firing the rollup hook now re-surfaces on the next worker boot even when one prior rollup row already existed for the same type. The legacy `sum_value IS NULL` branch is preserved on the same UNION. ### Notes - No schema migration. Additive write-hook plumbing only. - `pnpm test --run` green at 4648 / 4649 (1 long-standing skip). - The dashboard chart's read-side `source=rollup` short-circuit stays put — once every write path folds the rollup tier on touch, the short-circuit is always correct. A defensive coverage-mismatch fallback is on the v1.4.40 backlog. ## [1.4.39] — 2026-05-21 — Mood, medication-compliance, and cumulative-sum rollup tiers The v1.4.38 chain settled the measurement-rollup fast-path. v1.4.39 extends the same "raw data stays untouched, a derived second layer serves the read path" posture to two more endpoints that still walked the source table on every cold mount — `/api/mood/analytics` and `/api/medications/intake?scope=compliance` — and folds a cumulative `sum_value` column into the existing `measurement_rollups` tier so step / flight / distance / daylight / active-energy sparklines no longer re-derive their daily totals in Node. Raw `measurements`, `mood_entries`, `medication_intake_events` tables are byte-unchanged. The three new tiers are derived caches that self-heal via boot-time backfill on first reach. ### Added - **`mood_entry_rollups`** — per-(user, granularity, bucket) mood stats. Reuses the existing `RollupGranularity` enum so the worker shares one type across mood and measurement tiers. Synchronous DAY-tier write hook on every `MoodEntry` create / update / delete; WEEK / MONTH / YEAR folded asynchronously through pg-boss. Boot-time backfill queue (`mood-rollup-full-backfill`) discovers legacy accounts and converges on first worker boot. - **`medication_compliance_rollups`** — per-(user, medication, day) scheduled / taken / skipped ledger. `day` is a user-timezone-anchored `YYYY-MM-DD` string so multi-instance reads do not re-derive boundaries. Hook fires on every intake-event mutation plus the reminder-worker mint path. Boot-time backfill queue (`medication-compliance-full-backfill`) handles legacy accounts. - **`measurement_rollups.sum_value`** — nullable cumulative-metric column populated alongside `mean / count` in every rollup fold. Existing rows backfill on next reach via the extended `rollup-full-backfill` discovery query. - **`rollup-read-wmy.ts`** — `readWeekRollups` / `readMonthRollups` / `readYearRollups` / `readBestGranularityRollups` reader helpers. The auto-router picks the largest granularity that resolves the requested window (90 d → DAY, 365 d → MONTH, 1 095 d → YEAR) with coverage-miss fall-through. Ready for the v1.5 multi-year trend card; not exposed via any route yet. - **`computeLongWindowSummary(userId, type, windowDays)`** in `summaries-slice.ts` — granularity-routed `count / min / max / mean / sum` aggregate for long-window consumers. - **`rollup-read-cumulative.ts`** — `readCumulativeDaySums` / `readCumulativeDaySumsBatch` / `resolveBucketSum` helpers with legacy-NULL fallback. ### Performance Expected on Marc-sized accounts; numbers anchored on the `.planning/round-v1438-perf-analysis.md` audit and confirmed by the unit-test fixture suite. Live perf-verify rides the post-deploy window. - **`/api/mood/analytics` cold mount: 12.7 s → ~200 ms.** Was an unbounded `MoodEntry.findMany` walk + JS aggregation; now a bounded rollup read. 5-year `since` ceiling on the rollup branch keeps the query plan stable on multi-year tenants. Live-fallback retained for coverage misses and pre-aggregates daily means before `summarize()` so `summary.mean / latest / min / max / avg7 / avg30 / slope30` stay byte-identical between the two branches on multi-entry days. - **`/api/medications/intake?scope=compliance` cold mount: 3.2 s → ~200 ms.** Was an unbounded intake-event findMany + per-day JS bucketing; now a per-day rollup read. Coverage probe counts rolled days vs days with intake events (partial-coverage cases route to the live fallback, not the rollup). Race-safe atomic upsert closes the read-aggregate-then-upsert window under concurrent reminder-worker and Telegram intake. - **`/api/dashboard/summary` cumulative sparkline: ~500 ms → ~300 ms.** Reads `sum_value` directly instead of recomputing via `mean × count`. - **`/api/measurements?groupBy=day` cumulative path:** consumes `sum_value` directly; eliminates per-type JS aggregation on `ACTIVITY_STEPS / FLIGHTS_CLIMBED / WALKING_RUNNING_DISTANCE / TIME_IN_DAYLIGHT / ACTIVE_ENERGY`. - **`/api/analytics` live-fallback row cap: 347 k → ~5 k.** Trailing 425-day `since` cap on the `fetchMeasurementSeriesChunked` per-type loop. Defense-in-depth — the v1.4.38.8 per-type fast-path gate makes this path unreachable in the common case, but a regression that re-triggers it can no longer pull the entire row history. The 425 d window preserves `summary.avg30LastYear` (year-ago baseline tile); the rollup fast-path stays untouched. ### Fixed - **Mood + medication-compliance DAY recompute race.** Concurrent writes for the same `(user, day)` could interleave their SELECT and UPSERT and leave a stale row until the next write. Both helpers now use a single atomic `INSERT … SELECT … ON CONFLICT DO UPDATE` that re-aggregates inside the upsert subquery. - **Mood rollup async worker enqueue blocked the user response.** The WEEK / MONTH / YEAR pg-boss enqueue is now fire-and-forget; the read-critical DAY pass commits synchronously above so the cold-mount cost no longer drags pg-boss latency into mood-write responses. - **Partial-coverage zero-fill on `/api/medications/intake?scope=compliance`.** The coverage probe used to flip to the rollup path on a single rollup row in the window — legacy accounts with a mid-deploy in-progress backfill saw zero-filled tiles for the un-rolled days. Probe now compares rolled-day count to days-with-events. - **Coverage-miss backfill enqueue scoped to the caller's user.** The request-path warm-up no longer kicks off a cluster-wide discovery scan on every cache-miss; cluster discovery stays on the worker boot path. Mirrors the mood-rollup pattern. - **`dashboard-summary` nested ternary** in the sparkline branch flattened per the project's no-nested-ternaries rule. ### Operator notes - Migrations `0070`, `0071`, `0072` are additive only. `0072` adds a nullable `DOUBLE PRECISION` column with no default — catalog-only DDL on PostgreSQL 11+, no table rewrite, writes resume in milliseconds. - No environment-variable change. No API contract break. No iOS contract change. - Boot-time backfills converge automatically on first reach. Operator trigger remains available via `POST /api/admin/rollups/recompute`. - Tests: 4 524 → 4 640 unit (+116). Integration suite green. ## [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 `