# Changelog ## [1.4.46] — 2026-05-22 — Server reconcile (PR worker cumulative-bucketing + intake auto-skip + APNS admin test) Discovery follow-up from the v1.4.45 marathon-close review surfaced three server gaps that the audit waves hadn't covered. v1.4.46 lands all three as touch-disjoint fixes. ### Fixed - **REG-9 — PR detection picked single per-hour slices instead of daily totals for cumulative HK kinds.** `findBestMeasurement` in `src/lib/personal-records/pr-detection-worker.ts` ran `findFirst orderBy value desc` for every metric. For `ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`, `WALKING_RUNNING_DISTANCE`, and `TIME_IN_DAYLIGHT` — every member of `CUMULATIVE_HK_TYPES` — a noisy 4 000-step hour-bucket fragment from a hike locked in as the user's "best step count" while the legitimate 24 000-step day was ignored. New `findBestCumulativeDay` helper SUMs each `(user_id, type)` partition by `date_trunc('day', measured_at)` via `$queryRaw`, picks the winning day by `ORDER BY day_total {DESC|ASC} LIMIT 1`, then re-reads the latest slice on that day to recover the row's `unit / source / externalId`. The PR's `value` is the day-sum; `achievedAt` is the `MAX(measured_at)` on the winning day so the unique-index contract `(userId, metricType, metricSlot, achievedAt)` stays stable across re-runs. Spot kinds (resting HR, VO2 max, HRV, body composition) keep the existing per-row pick — each row already represents the day's measurement. Two regression cases pin the contract. - **Intake-auto-skip cron was missing entirely.** `MedicationIntakeEvent` rows the user neither took nor manually skipped stayed in `pending` forever, inflating the missing column on the streak chart and crowding the next day's intake out of the "today" window. New `intake-auto-skip` cron in `src/lib/jobs/intake-auto-skip.ts` runs hourly at `:05` (off the `:00` reminder-check tick and `:30` moodlog-sync) and flips `skipped = true` for every event matching `skipped = false AND takenAt IS NULL AND scheduledFor < NOW() - INTERVAL '24 hours'`. The 24 h grace window keeps late marks (morning dose recorded at noon) legitimate. The flip is the same state the manual "Skip" button writes, so the compliance rollup picks new rows up via the next read-path call. Wired through `reminder-worker.ts` (`createQueue` + `boss.schedule` + `boss.work`). Eight regression cases pin the cron + grace contract, the spec'd `updateMany` shape, idempotency on re-run, and the strict `<` boundary semantics. - **`/api/admin/notifications/test` switch had no APNS arm.** An iOS-paired admin's APNS channel fell through to the "Unknown channel type" default, so the Settings → Notifications "Test" button reported the channel as broken even though the dispatcher routed real notifications through APNs without issue. New arm mirrors the WEB_PUSH branch: `sendViaApns(userId, payload)` owns its own per-device fan-out (`prisma.device.findMany` for the user's iOS devices) and returns the same `SendOutcome` envelope. On failure the route surfaces the sender's `reason` in the per-channel error (`apns_no_devices`, `apns_not_configured`, …) so the admin UI shows the actionable cause. Six regression cases pin the contract. ### Tests - 5076 → 5089 (+13: 2 cumulative-bucket cases, 8 auto-skip cases — including grace-window boundary, idempotency, and cron contract — plus 3 APNS-branch cases that didn't already cover the route). ## [1.4.45] — 2026-05-21 — Analytics 9 s perf fix, audit-driven polish, Zod multi-issue rollout, Withings parked-state automation > Version note — v1.4.43 was skipped: v1.4.44 shipped as a same-day REG-11 iOS hotfix on `main` while this marathon was running (REG-11 = Home dashboard tile renders neither chart nor latest value when the most recent reading is older than 7 days; root cause was in `/api/dashboard/summary` SQL gates). The REG-11 fix is included in this release alongside the marathon work. v1.4.45 keeps the version monotone above the hotfix tag. v1.4.42 closed the iOS-readiness story. v1.4.45 is the post-deploy discovery + closure release: a four-axis audit round (analytics perf / mobile-UI / QoL / security) surfaced one Critical `/api/analytics` 9 s regression that had been latent since v1.4.40, five High mobile-UI WCAG paper-cuts, two PII / log-growth gaps on the security surface, six High QoL copy + i18n gaps, and the chart empty-state false-positive raised after deploy. Eleven implementation waves landed: nine close every audit Critical + High plus the recurring polish items (chart-gate raw-count, QoL copy + plural forms, Withings classifier wiring across both sync paths, ops hardening including the BuildKit version-pin lesson from v1.4.42), one rolls out the v1.4.42 W2-ZOD `returnAllZodIssues` helper to the 41 sibling routes that still dropped every issue past the first, and one closes the v1.4.42 W2-WITHINGS deferred B4 (park after 24 h persistent-failure streak) + B7 (per-kind failure counters). Three companion waves close the audit Mediums + Lows for mobile-UI, QoL, and security. Fourteen touch-disjoint waves landed on `develop` before this release commit. The wave reports under `.planning/phase-W*-v1443-report.md` document the per-wave decisions, file inventories, and test deltas; the multi-axis QA round under `.planning/round-v1443-QA-*-findings.md` covers the cross-cutting verdict that gated this tag; the audit reports under `.planning/round-v1443-AUDIT-*-findings.md` document the discovery phase that fed the marathon. ### Added - **`returnAllZodIssues` rolled out across 41 API routes** (W6) — every measurements / medications / mood / auth / settings / admin / consent / bugreport / feedback / device / token / ingest route now returns every Zod issue under `details.issues` plus a sanitised `{ path, code, message }` projection. iOS-contract hot paths additionally write a `.validation-failed` audit-ledger breadcrumb (fire-and-forget). The CSV-import outlier (`/api/medications/[id]/intake/import`) preserves its `Invalid format:` prefix via `meta.errorCode = "medication.intake.import.invalid_format"`. 220 new unit cases pin 2-issue / 3-issue scenarios per touched route. - **Withings persistent-failure park automation** (W14) — a `persistent` failure streak running > 24 h flips `IntegrationStatus.state` to a new `parked` sentinel. Settings card renders a pill `data-state="parked"` with copy "Pausiert — manuell wieder verbinden" (DE) / "Paused — reconnect manually" (EN) plus a "Wieder verbinden" / "Reconnect" CTA. New endpoint `POST /api/integrations/withings/resume` (rate-limited 5/min/user, idempotent) clears the park via `resumeIntegrationFromPark` and the per-card status query invalidates so the pill flips back without a refresh. Schema migration `0075_v1443_integration_park` adds the `consecutive_failures_by_kind` JSONB column + `persistent_failure_started_at` timestamp; backfill on next write bucketises the legacy single-counter into the current `FailureKind`. - **`POST /api/auth/check-user` audit-ledger breadcrumb** (W13 M-1) — every branch (`found` / `not_found` / `passkey_only` / `email_fallback`) now writes `auditLog("auth.check-user", { ipAddress, details: { branch, identifier_hash } })` with the identifier hashed via `hashToken`. The route's per-IP throttle (30/15 min) stays; the audit row closes the operator-grep gap for enumeration-attempt investigations. - **`/api/settings/account` DELETE cascade** (W12 M3) — split the danger-zone "Konto vollständig löschen" from the existing "Alle Gesundheitsdaten löschen" so a user reading "Gefahrenzone" reaches the right destructive action. New endpoint cascades User + sessions + passkeys + audit log under a Prisma tx; concurrent active sessions are invalidated synchronously. Six new integration tests pin the happy path + sibling-session invalidation + 401 / 422 / last-admin guard. - **`` mounted in ``** (W12 M5) — listens for `online` / `offline` window events and renders "Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist". Bilingual via i18n. Closes the PWA "blank tile + no explanation when airplane-mode toggles mid-form-fill" paper-cut. - **`` 3 s "still computing" caption** (W12 L4) — after 3 s of skeleton paint a small caption renders to set user expectation when `/api/analytics` is on the slow path. Bilingual. - **`` + neutral danger-zone visuals** (W12 M3 + L5) — `AlertTriangle` icon dropped, title in neutral grey, button stays red. Same protective gate, less visual hostility. - **`formatDateOrRelative(iso)` helper** (W12 L8) — within 24 h renders relative ("vor 12 min"), older renders absolute. Wired into `measurement-list.tsx:572,717` so adjacent timestamps no longer mix formats. - **`scrollBehaviorForUser()` helper in `src/lib/motion.ts`** (W5 H5) — reads `prefers-reduced-motion: reduce` and returns `"auto"` or `"smooth"`. Wired into the four JS-driven smooth-scroll sites (`settings-shell`, `admin-shell`, `coach-panel/message-thread` × 2). - **`charts.noDataInRangeTitle` + `charts.noDataInRangeDescription`** copy across all six locales (W11 M6). Replaces the silent `return null` branch in `` with a proper `` so the dashboard layout doesn't reflow when a chart enters its empty branch. - **`charts.needMoreDistinctDaysTitle` + `charts.needMoreDistinctDaysDescription`** copy across all six locales (W2 C1). Distinguishes the "few raw measurements" branch from the "lots of data, only on 1-2 days" branch on the chart's empty state. - **`` / `` / `` skeleton heights reserved** to match loaded content (W11 M3) — `h-[24rem]` for DailyBriefing, `h-[20rem]` for the others. Closes the CLS hot-spot on cold mounts. - **``** painted when `trendCards.length === 0 && analyticsSlimQuery.isLoading && layout.widgets.some(w => w.tileVisible)` (W11 M5). User sees the strip silhouette during the slow window instead of header + empty page + 9 s jump. ### Changed - **`/api/analytics` slim slice `computeAvg30LastYearMap` capped at `p-limit(4)`** (W1) — closes the 9 s regression introduced in v1.4.40 W-WMY-WIRE. The unbounded `Promise.all` over 15 measurement types drowned the `pg.Pool` max=20 even after v1.4.40's W-POOL raise; capping at 4 collapses the burst to match the thick route's existing `ANALYTICS_TYPE_FETCH_CONCURRENCY=4`. Expected dashboard cold-mount: 9.0 s → 2-3 s (concurrent slim + thick both cold) / ~50 ms (warm cache); WMY-converged tenants ≤ 500 ms cold. The slim slice's per-(userId, date) cache key is unchanged; concurrent dashboards still single-flight per slice. - **Withings activity + sleep sync catch-blocks routed through `WithingsApiError` typed classifier** (W7) — both `sync-activity.ts:174` and `sync-sleep.ts:155` previously caught raw thrown values + branched on `Error.message` string-matching. Now they typed-cast through `WithingsApiError` so a non-Error rejection lands in the `unknown` branch instead of silently sliding into `error_transient`. A typed-classification regression suite (`src/lib/withings/__tests__/sync-typed-classification.test.ts`) pins both paths. - **`IntegrationStatusPill` warning state for `persistent` failures** (W4 H3) — v1.4.42's classifier introduced the fourth FailureKind but mapped it to `error_transient` so the pill read "Fehler — neu verbinden" identically to a transient failure. New "warning" pill state with copy "Verbunden, aber Serverfehler" gives the operator + user the signal that the access token still works but the upstream is responding with `601` / `293` / `294`. - **`auth.check-user` rate-limit wrapper + 6 auth routes converted** (W13 M-4) — `checkAuthSurfaceRateLimit` wraps `checkRateLimit` and routes anonymous-bucket trust-violations to a tight 100/15-min global bucket instead of every misconfigured anonymous request collapsing into one shared `unknown` bucket. Applied to `/api/auth/login`, `/api/auth/register`, `/api/auth/passkey/login-options`, `/api/auth/passkey/login-verify`, `/api/auth/refresh`, `/api/auth/check-user`, `/api/auth/password`. - **Coach SSE re-detects refusal on replayed turns** (W13 M-3) — every prior turn loaded from `coach_messages.encryptedContent` now runs through `detectRefusal` before re-entering the prompt. A positive hit short-circuits the SSE with a refusal response + emits an `audit.coach.replay-injection` audit row. Closes the "false-negative amplification" path where a regex-bank miss on turn N kept re-entering on every subsequent turn. - **`/api/auth/passkey/login-verify` body now Zod-narrowed** (W13 L-3) — replaces the raw `as AuthenticationResponseJSON` cast with an explicit Zod parse upstream of `verifyAuthentication`. Closes the type-narrowing gap a future refactor could trip on; the verifier remains the runtime owner. - **`activeLocale()` reads the full `Locale` union** (W12 M8) — sub-locale users (fr / es / it / pl) now get their native number + date formats from `Intl.DateTimeFormat` instead of the `en` fallback. The legacy `format.ts` helper backed ~25 SSR + audit-log call sites; the new branch falls back to `en` only when the cookie is unrecognised. - **404 + global-error copy tightened across all locales** (W12 L1) — "Page not found / The page you were looking for doesn't exist or has been moved." → "Diese Seite existiert nicht." / "This page doesn't exist." period. Bilingual lockup retained on `global-error.tsx` (root layout has failed by the time this paints — no i18n provider). - **`relativeMinutesAgo` / `relativeHoursAgo` / `relativeDaysAgo` keys split into `…One` / `…Other`** (W4 H6) — `count === 1` now renders the singular form across all six locales. The integration pill abbreviated forms (`vor 1 min` / `vor 1 d`) remain. - **`coach.network` mapped to its own `errorNetwork` i18n key** (W12 M6) — distinct copy from the provider-error bucket so a user sees "Keine Internetverbindung" when their network is dead vs the original generic provider-unreachable copy when the provider itself is down. - **`DayDrillDown` error renders `measurements.loadError` instead of `measurements.saveError`** (W4 H1) — closes the "Save error" misattribution on GET-only failures. - **Switch tap target extends to 44 × 44 via `::before` pseudo-element** (W5 H1) — every Settings / sources / dashboard-layout / coach-settings / notification toggle now meets WCAG 2.5.5 without changing the visual. - **Comparison-baseline buttons in chart overlay popover bump to 44 px on mobile** (W5 H2) — `min-h-11 sm:min-h-9` so phones get the safer target without densifying desktop. - **Mood-form kebab + edit-mode footer kebab bump to 44 × 44** (W5 H3) — `h-11 w-11` to match the medication-form pattern. - **Bottom-sheet close-X reaches 44 px on phones, keeps 36 px on desktop** (W5 H4) — `min-h-9 sm:min-h-11` gated on breakpoint. - **`Loader2 animate-spin` paired with `motion-reduce:animate-none` across 21 sites** (W11 M1) — closes the motion-sensitivity gap on chart loading + settings spinners + admin backup runners + drug-level + doctor-report dialogs. - **`phase-config-dialog.tsx` Input + Button bump to 44 px on mobile** (W11 M2) — drops `text-sm` from the Input so the iOS-zoom-on-focus is closed. - **`correlation-card.tsx` skeleton mirrors the loaded scatter chart's aspect ratio** (W11 M4) — `aspect-square sm:aspect-[3/2] min-h-[180px] sm:h-auto`. Closes the ~60 px CLS on the insights cold mount. - **`insights-tab-strip.tsx` group-popover items bumped to 44 px** (W11 L1) — outer tab pills already met the floor; inner popover items lagged. - **`chart-overlay-controls.tsx` trigger adopts the responsive `min-h-11 sm:min-h-9` pattern** (W11 L2) — consistent with the v1.4.33 maintainer-item-7 settlement. - **`RecentWorkoutsTile` + `DrugLevelChart` reserve loaded-card height during loading** (W11 L6) — no card-pop on first paint. - **`dedupeWorkoutBatch` honours `User.sourcePriorityJson`** (W9) — write-time picker threads the same priority ladder the read-time picker uses. The v1.4.42 wave's docstring warning ("does NOT consult sourcePriorityJson") is now obsolete; the batch route does a single `prisma.user.findUnique` lookup before the dedup pass. - **`/api/dashboard/widgets` 422 audit-ledger row dedupes per `(userId, action)` over 60 s** (W8 B2) — a misconfigured iOS client retrying every 100 ms no longer writes 10 audit rows per second. The 422 response is still per-call; only the audit-ledger row dedupes. ### Fixed - **`auth.login.failed` audit row no longer persists the raw typed identifier** (W3 H-1) — replaced with `identifier_hash: hashToken(identifier)`. Closes the PII gap surfaced by the v1.4.20 retroactive directive: an admin scrolling `/api/admin/audit-log` no longer sees every wrong-email a user typo'd at the login screen, and a future audit-table compromise no longer hands an attacker a list of probed identifiers. - **`WithingsApiError.message` capped at 1024 chars in the constructor** (W3 H-2) — the v1.4.42 classifier persisted the upstream `json.error` string verbatim into `AuditLog.details`. Withings is the trusted upstream but the operator-readable audit row should match the encrypted-error column's 1024-char cap. - **Chart empty-state copy split on raw measurement count** (W2 C1) — ``, ``, `` now distinguish "fewer than 3 entries" (empty-state title) from "many entries but only on 1-2 distinct days" (new `needMoreDistinctDays` title). The user-reported "Erfasse 3 Einträge" on a populated account is closed. - **Chart `aria-busy` empty branch no longer silently returns null** (W11 M6) — replaced with a `` rendering the `noDataInRange` copy. - **`docker-publish.yml` bakes the build-tag into the image at build time** (W8 B11) — closes the BuildKit cache version-stale paper-cut from v1.4.42. - **`pnpm check-env` enforces manifest ↔ `.env.production.example` lockstep on CI** (W8 B5). - **`global-error.tsx` bilingual lockup** (W4 H5 + W12 L1 follow-up). - **`not-found.tsx` localised via `getServerTranslations`** (W4 H4). - **German `daysAgo` integration-pill abbreviation** (W12 M7) — "vor {count} T." → "vor {count} d" matches the rest of the app's stale-hint pattern. - **SW `CACHE_VERSION` re-anchors per build** (W12 L3) — `public/sw.js`'s literal `"v1.4.38.4"` had drifted four releases stale; the activate-step's old-cache eviction had been a no-op. A new `prebuild` script writes `public/sw-version.js` from `package.json`'s version + `importScripts('/sw-version.js')` re-anchors `CACHE_VERSION` per deploy. - **Onboarding checklist measurement-completion copy** (W12 L2). - **Doctor-report unavailable sections render disabled instead of vanishing** (W12 M4). - **`auth/login + auth/register` test mocks updated for `checkAuthSurfaceRateLimit`** (post-cherry-pick reconcile) — W6's new test files pinned the old `checkRateLimit` signature; W13's M-4 wrapper rename collided at runtime causing 500 instead of 401 / 422. Both factories + `beforeEach` arming updated. ### Performance Anchored on the v1.4.43 W1-ANALYTICS-PERF audit (`.planning/round-v1443-AUDIT-analytics-9s-findings.md`). - **Dashboard cold-mount `/api/analytics` (concurrent slim + thick):** 9.0 s + 9.0 s in parallel → est. 2-3 s + 2-3 s in parallel (slim slice fan-out capped at `p-limit(4)`). Warm cache (60 s TTL hit) unchanged at ~50 ms. WMY-converged tenants ≤ 500 ms cold. ### Operator notes - **Schema migration required**: `prisma/migrations/0075_v1443_integration_park/migration.sql` extends `IntegrationStatus` with `consecutive_failures_by_kind` (JSONB) + `persistent_failure_started_at` (TIMESTAMP). The migration is idempotent (`IF NOT EXISTS` guards on both columns) and reversible (backfill is non-destructive — the legacy single-counter column stays for one release as a fallback). Run `prisma migrate deploy` post-deploy; no downtime expected. - **No env-var change.** `pnpm check-env` (v1.4.42) still passes; the new env-check CI gate enforces manifest ↔ `.env.production.example` lockstep. - **No API contract break for iOS v0.5.4.** `returnAllZodIssues` envelope is additively extended (existing `error` field preserved); iOS clients that hard-coded `body.error` keep working. New endpoints (`/api/integrations/withings/resume`, `/api/settings/account` DELETE) are additive. - **Withings persistent-failure park** flips `IntegrationStatus.state` to `"parked"` after > 24 h of consecutive `persistent` failures (Withings `601` / `293` / `294`). A parked integration's next scheduled sync is short-circuited until the user clicks "Wieder verbinden" (rate-limited 5/min/user). Operator can also call the resume endpoint manually for stuck accounts. - **Build pipeline**: `docker-publish.yml` now bakes `NEXT_PUBLIC_APP_VERSION` at build time so `/api/version` can never serve a stale version after a Coolify cache re-use. Verify `/api/version` post-deploy as usual. - `pnpm test --run` green at 5076 passing / 1 skipped (5077 total), up from 4815. `pnpm typecheck`, `pnpm lint`, `pnpm knip` (enforcing) all green. Two pre-existing integration-tier failures in `tests/integration/workout-batch-{create,race}.test.ts` reproduce on `origin/main` unchanged and are NOT caused by this release; flagged for v1.4.44 investigation. ## [1.4.44] — 2026-05-21 — REG-11 dashboard summary hotfix (iOS Home tile sparkline + value) iOS-operator-blocking hotfix for REG-11: the Home dashboard tile rendered neither chart nor latest value for BP / Puls / Körperfett when the most recent reading was older than 7 days. Root cause was in `/api/dashboard/summary` SQL gates — both `latestIn7d` and `sparkBuckets` required `measured_at >= sevenDaysAgo`, which returned empty for sparse accounts. The iOS tile rendered from `latestValue: null` + `sparkline: []`. Five iOS-side attempts had been wrong because the bug was server-side. v1.4.44 was a same-day same-author hotfix on `main` while the v1.4.45 audit-marathon was running; the marathon work is preserved in v1.4.45. ### Fixed - **`/api/dashboard/summary` `latestIn7d` → `latestEver`**: drops the 7-day filter; `DISTINCT ON (type)` returns the latest reading EVER per type, bounded at N-metrics rows. Sparse accounts (one BP reading 60 days ago, nothing since) now get the tile back. - **`sparkBuckets` rewritten via `ROW_NUMBER() OVER (PARTITION BY type ORDER BY bucket_start DESC)`**: trailing N daily buckets per type render regardless of calendar age, bounded at SPARK_DAYS × N-metrics. - **BP + Pulse `metrics.push` gated on `latest || allTimeCount > 0`** like BodyFat already was — accounts with zero readings ever no longer get empty placeholder tiles. ### Tests Four new cases in `src/app/api/dashboard/summary/__tests__/route.test.ts` pin REG-11: - BP with a 60-day-old reading → tile emits with historical `latestValue` + `sparkline` - BP with null readings ever → tile NOT emitted - Weight within 7 days → behaviour unchanged (regression guard) - Sparkline window picks up 7 daily buckets even if all older than 7 calendar days ### Operator notes - No migration. No env-var change. No API contract break for iOS v0.5.4. - v1.4.43 was skipped; v1.4.45 carries the audit-marathon closure. ## [1.4.42] — 2026-05-21 — Knip enforcing, queryKey factory closed, iOS Workouts dedup, Withings off-response classification v1.4.41 closed the iOS BP/Weight 14 s perf paper-cut and the soft-delete reader-tier completeness story. v1.4.42 is the follow-up polish-and-iOS-readiness release: the knip CI gate flips to enforcing-mode (zero unused exports + zero unused types on `main`), the long-tail queryKey factory migration closes the v1.4.40 audit-H1 contract for the settings / medications / admin / hooks surface, the `/api/dashboard/widgets` 422 returns every Zod issue so the next iOS contract-debugging session takes one round-trip instead of one per wrong field, Withings off-responses are classified into transient / reauth / persistent (rate-limit-induced `601`s no longer silently retry forever), the iOS HealthKit ingest gets a cross-source workout dedup helper that lets Apple Watch + Withings ScanWatch paired captures collapse to one row at write time, and a tree-hygiene wave lands (BERLIN_DAY_FORMATTER consolidated across nine call sites, Suspense double-comment consolidation, doctor-report-data byte-escape so diffs become readable, pr-detection-worker soft-delete filter, offhost-backup DR-intent comment). Six touch-disjoint waves landed on `develop` before this release commit. The wave reports under `.planning/phase-W*-v1442-report.md` document the per-wave decisions, file inventories, and test deltas; the multi-axis QA round under `.planning/round-v1442-QA-*-findings.md` covers the cross-cutting verdict that gated this tag. ### Added - **`returnAllZodIssues(error, status?, meta?)` shared helper** (`src/lib/api-response.ts`). Replaces the `apiError(parsed.error.issues[0].message, 422)` pattern that dropped every issue past the first. Envelope: `{ data: null, error: "Validation failed", details: { issues: [{ path, code, message }] } }`. Sanitises issues — `issue.params` is never echoed (can carry the raw rejected user input). First consumer is `PUT /api/dashboard/widgets`; 41 sibling routes documented in the W2 phase report for v1.4.43 rollout. - **`dedupeWorkoutBatch()` write-time cross-source dedup** (`src/lib/workouts/canonical-rows.ts`). Anticipates the v1.5 iOS HealthKit observer queue ingest where Apple Watch + Withings ScanWatch paired captures collide on the same logical workout. Groups by `(userId, activityType, startedAt ± 90 s)`, prefers the canonical source ladder `APPLE_HEALTH > WITHINGS > MANUAL > IMPORT` shared with the read-time picker, breaks ties on `caloriesKcal > earliest createdAt > input order`. Wired into `POST /api/workouts/batch` pre-`createMany`; losers surface as `duplicate` in the per-entry envelope so the iOS sync cursor advances identically to the existing `externalId` dedup. The name intentionally diverges from the v1.4.30 read-time `pickCanonicalWorkoutRows` so auto-completion paths don't conflate write-time payload-internal dedup with read-time cross-batch dedup. - **`pnpm check-env` CLI** (`scripts/check-env.ts` + `scripts/env-manifest.json` + `docs/ops/env-check.md`). Pre-deploy env-var sanity check that catches the v1.4.40 AP-2 silent-disable pattern (three of four `APNS_*` vars set, `.p8` missing) via the `allOrNone` group marker. Two modes: `pnpm check-env` against `process.env`, `pnpm check-env --file ` against a Coolify export. Exit code 0 / 1 / 2 for green / missing required / malformed manifest. The renderer surfaces the satisfying alternative on `anyOf` rows (`[OK] APNS_KEY (satisfied by APNS_KEY_FILE)`) so an operator scanning the output never grep-hunts for the wrong variable name. - **Withings off-response classifier** (`src/lib/withings/response-classifier.ts`). Pure `(httpStatus, body) → { success | transient | reauth_required | persistent }` taxonomy plus `WithingsApiError` subclass + `classifyError` fallback for pg-boss-rehydrated errors. Surfaces rate-limit (`601`) and contract-mismatch (`293 / 294`) responses to the admin alert path instead of letting them silently retry forever. `FailureKind` extended with `persistent`; persistent failures map to `state=error_transient` with a distinct audit kind so the next sync still runs but the operator sees the trail. - **Nine new `queryKey` factory entries** at `src/lib/query-keys.ts` covering per-medication `compliance` / `titration` / `cadence` / `glp1Details` / `intakeDrugLevelChart` / `intakeList`, the `withingsStatus` integration read, the paginated `adminAuditLogFiltered`, and `workoutsRecentList`. - **Berlin-anchored day-key helper** (`toBerlinYmd`) exported from `src/lib/tz/resolver.ts` next to `toBerlinDayKey`. Returns the `{year, month, day}` numeric parts directly so consumers building `Date.UTC(...)` keys (insights bucket series, BP-in-target windows) share the same formatter instance as the cache-key path. ### Changed - **Knip CI gate flips to enforcing-mode** (`.github/workflows/knip.yml`). The `--include files,dependencies,binaries,unlisted` flag is dropped; any new unused export, type, file, dependency, or binary on `main` now fails the gate. v1.4.42 brings the baseline to 0 / 0. The ignore-block under `knip.json` scopes shadcn (`src/components/ui/**`) and contract types (`src/lib/validations/**`) — both are intentional surface area we keep. - **Long-tail `queryKey` factory migration closed** (W3). Forty call-site files across `settings/`, `medications/`, `admin/`, `hooks/`, plus three `app/` pages, refactored from bare-literal `queryKey: [...]` to `queryKeys.()`. The custom ESLint rule (`eslint-plugins/healthlog/queryKey-factory.js`) and the test-guard substitute (`src/lib/__tests__/query-keys.test.ts`) extend their `GUARDED_DIRECTORIES` / `GUARDED_FILES` in lockstep so the gate fires at the same boundary at IDE / `pnpm lint` / `pnpm test` time. The `medicationIntakeList` factory entry decomposes its params into a flat tuple to match the `chartData` + `adminAuditLogFiltered` siblings (the prior packed-object form depended on stable JSON-stringify ordering for cache-key correctness). - **`PUT /api/dashboard/widgets` 422** now returns every Zod issue plus a worker-side `auditLog` row keyed `dashboard.widgets.validation-failed`. The iOS team picking up the v1.4.41 product-lead H4 callout now sees the full shape mismatch in one response and an operator-grep trail outside the iOS dev console. - **`BERLIN_DAY_FORMATTER` consolidated** to `src/lib/tz/resolver.ts`. The seven `src/lib/insights/*-status.ts` files that each carried a byte-identical 20-LOC declaration plus the `bucket-series.ts` and `bp-in-target.ts` siblings (two more sites the W4 wave's initial pass missed) all share one formatter instance now. Net ~120 LOC drop, same runtime behaviour, no DST drift. - **Tile-strip Suspense placeholder** (`src/app/page.tsx`) gains `min-h-[6rem]` plus the cosmetic `flex min-w-0 flex-col` classes so the placeholder chrome matches the live `TrendCard` byte-for-byte. The all-suspend edge case (future RSC hoist of every tile) holds the row open during a synchronous transition instead of collapsing to zero height. - **Dashboard Suspense double-comment** (`src/app/page.tsx`) — the two adjacent JSX comment blocks describing the same boundary (v1.4.40 W-RSC seed + v1.4.41 W-FRONTEND-FACTORY fallback hoist) collapse into one 5-6 line block keyed to current behaviour with a one-line history trailer. Pure documentation; rendered output byte-identical. - **`apiError` and `returnAllZodIssues` share a `buildJsonErrorResponse` builder** so the meta / headers passthrough lands in one place; a future extension (e.g. an `errorId` autoinject for Sentry) can't drift between the two helpers. - **`recordWithingsSyncFailure` extracted** from the two byte-identical 14-LOC catch-blocks in `src/lib/withings/sync.ts`. The sync-activity / sync-sleep migration to typed errors (deferred per the W6 backlog) will collapse to a single-line catch-block when it lands. - **`formatAdminAlertPayload` uses a `FailureKind` copy table** instead of nested ternaries. Adding a future fourth `FailureKind` is a one-row table edit, not two more arms in two ternary stacks. ### Fixed - **Withings rate-limit (`601`) + contract-mismatch (`293 / 294`) responses no longer silently retry forever.** The classifier maps them to `persistent` so the admin alert label fires the first time the response shape lands, instead of after the worker has cycled them through the transient-retry queue indefinitely. - **`pr-detection-worker` soft-delete filter** — the v1.4.40 audit's W-DELETED-2 sweep missed the personal-records worker; a soft-deleted measurement could remain the user's "best weight" badge until the next ingest crossed the threshold. Both `prisma.measurement.count` (warm-up gate) and `findBestMeasurement` reads now scope to `deletedAt: null`. - **`offhost-backup.ts:219` DR-intent comment** — the nightly disaster-recovery S3 snapshot deliberately includes soft-deleted rows so a future "restore from yesterday" brings back a row the user undeleted on the source side. The inline comment now documents the asymmetry vs the user-facing `/api/export/full-backup` which correctly excludes them. - **`src/lib/doctor-report-data.ts` byte cleanliness** — the file was checked in as `Binary files differ` because the sanitiser regex held literal control bytes (NUL + US + DEL) in its character class. The regex now uses `[\x00-\x1F\x7F]` escape-sequence form so the file becomes UTF-8 clean and future code reviews can read the diffs. Same runtime behaviour; the existing 18-case test suite still passes. - **Two dead re-exports dropped** (W1 reconcile callouts): `listSupportedTimezones` re-export in `src/lib/tz/resolver.ts` (callers import from `@/lib/tz/format` directly) and the `describeInjectionSite` re-export in `src/components/medications/glp1-medication-card.tsx` ("Re-export so the parent doesn't need to import it" — no caller ever did). - **`pnpm check-env` catches the v1.4.40 AP-2 silent-disable it was conceived to catch.** The APNs group in `scripts/env-manifest.json` shipped without `allOrNone: true`, so an operator setting three of four `APNS_*` vars and leaving `APNS_KEY` / `APNS_KEY_FILE` empty would still exit 0 — exactly the silent-disable shape the wave was meant to prevent. Adding the flag closes the gap; the regression test pins the AP-2 scenario directly. ### Operator notes - **No migration.** No env-var change. No API contract break for iOS v0.5.4 — `/api/dashboard/widgets` 422 envelope is additively extended (existing `error` field preserved), and the new `dedupeWorkoutBatch` write-time dedup affects only batches with overlapping `(userId, activityType, startedAt ± 90 s)` rows where the source-priority ladder already determined the canonical row at read time. - **`pnpm check-env`** is the new pre-deploy gate that would have caught the v1.4.40 AP-2 silent-disable. Manifest at `scripts/env-manifest.json`; run before any production release. - **Knip CI gate is now enforcing on `main`.** Any push that introduces an unused export or type fails the gate. The shadcn surface and contract types are scoped via `knip.json` ignore-block; future shadcn updates land additively. - **Withings `persistent` failures** map to `IntegrationState=error_transient` so the next scheduled sync still runs. The distinct audit-kind + admin-alert label lets the operator triage a stuck integration without waiting for the user to surface it. - **Write-time workout dedup does not consult `User.sourcePriorityJson`.** A user who customised Settings → Sources (e.g. promoted `MANUAL` above `APPLE_HEALTH`) sees their Manual rows dropped at write-time when paired with an Apple Watch row within the 90 s window. Scope-narrow today; v1.4.43 closes it via a one-row `prisma.user.findUnique` lookup at the batch route. The docstring on `dedupeWorkoutBatch` carries the load-bearing operator notice. - `pnpm test --run` green at 4815 passing / 1 skipped (4816 total); `pnpm typecheck`, `pnpm lint`, `pnpm knip` (no `--include` flag) all green. ## [1.4.41] — 2026-05-21 — iOS perf hotfix, soft-delete completeness, tree hygiene v1.4.40 closed the architecture-level Critical+High audit findings and shipped the iOS PB30 backend prerequisites. v1.4.41 is the follow-up: one user-visible perf hotfix on the iOS-facing insights endpoints (consolidated into a shared timeout-stub helper that now backs all seven status routes), three remaining soft-delete reader-tier gaps closed (the W-DELETED-2 invariant pinned by integration tests), type-consolidation across analytics + backups, a four-branch iOS-onboarding discovery endpoint with per-IP rate-limiting, and a code-hygiene pass that retires the v1.4.39 W-SUM legacy-NULL UNION discovery arm, extracts the today-intake projection helper, and trims a batch of dead exports. Eight touch-disjoint waves landed on `develop` before this release commit. The wave reports under `.planning/phase-W-*-v1441-report.md` document the per-wave decisions, file inventories, and test deltas; the multi-axis QA round under `.planning/round-v1441-QA-*-findings.md` covers the cross-cutting verdict that gated this tag. ### Added - **`POST /api/auth/check-user`** — four-branch discovery endpoint for iOS onboarding (`not_found` / `passkey_only` / `email_fallback` / `exists`). Lets the iOS client surface the correct flow (register vs sign-in vs passkey vs recovery) on the first identifier entry without a separate "do you have an account" prompt. Per-IP rate-limited (30 / 15 min) mirroring `/api/auth/passkey/login-options`; no enumeration leak in the response shape beyond what the sibling routes already disclose. Identifier matched verbatim against `username` + `email` so a `MixedCase@Example.com` registration resolves through the route. - **`src/lib/insights/persist-timeout-stub.ts`** — one shared helper for the timeout-stub persist + cache short-circuit pattern. Now backs all seven `*-status` routes (bmi, blood-pressure, weight, pulse, mood, medication-compliance, general). Each route's stub is keyed to today's Berlin day; the next mount short-circuits at the cache lookup instead of re-racing the same provider call. Replaces ~120 LOC of duplicate `prisma.auditLog.create({ … timeout: true })` blocks. - **`src/lib/medications/scheduling/project-today-intakes.ts`** — single canonical home for the today-intake projection + idempotent `createMany` + per-`(med, day)` compliance recompute that previously lived inline in both `/api/medications/intake?scope=today` and `/api/dashboard/summary`. ~200 inline lines collapse to a 145-line helper + two short call sites; the helper returns `{ projected, backfilled }` so the existing `annotate` telemetry continues to ride the same payload. ### Changed - **Types consolidated.** `AnalyticsData` (four named per-surface shapes) lifts into `src/types/analytics.ts`. `BackupRow` / `BackupsList` lifts out of the admin route handler into `src/types/backups.ts`. Prompt helpers under `src/lib/insights/prompt*.ts` unified into `src/lib/ai/prompts/`. queryKey factory expanded with `auth`, `notifications`, and `about` migrations. - **queryKey factory enforcement (W-PROCESS-DOCS).** A real ESLint rule (`eslint-plugins/healthlog/queryKey-factory.js`) replaces the v1.4.40 test-guard substitute for fail-fast IDE/CI feedback. The guarded surface mirrors the test-guard's `guardedRoots` exactly — `src/components/{charts,comparison}/`, `src/app/page.tsx`, `src/hooks/use-auth.ts`, `src/app/auth/`, `src/app/notifications/`, `src/components/settings/about-section.tsx`. Future waves extend both lists in lockstep. - **`/streak/*` formally deprecated** per the SB-9 follow-up: the endpoint no longer responds; the legacy iOS build path returns `404`. - **Operator: pg.Pool sizing guidance for multi-container deploys.** Added a section under `docs/operator/` documenting how `DATABASE_POOL_MAX` interacts with horizontal-replica counts and the in-tree `p-limit(4)` analytics fan-out cap. - **Per-tile Suspense fallback now layout-stable.** The dashboard tile-strip Suspense boundary swaps its prior `null` fallback for an `aria-hidden` placeholder div that mirrors the trend-card chrome (`bg-card border-border rounded-xl p-4 md:p-6`). The tile body is synchronous today so the fallback rarely paints, but a future RSC hoist of any tile slot would otherwise leave the grid track empty and trigger CLS as the cell paints in. The structural pin in `src/app/__tests__/dashboard-suspense-boundaries.test.ts` was updated to match. ### Fixed - **Insights status routes — recurring ~14 s warm response on iOS.** The v1.4.37 bmi-status pattern persists a sentinel `auditLog` row on a 20 s AI-provider stall so the next mount short-circuits at the cache lookup. The blood-pressure and weight status routes shipped without that persist and re-raced the same provider call on every reload. Both routes now route through the shared `persistTimeoutStubAndReturn` helper, as do the four remaining sibling routes (general, pulse, mood, medication-compliance) that carried the same bare-fallback shape and would have leaked the same paper-cut on any of their respective provider stalls. Response envelopes are byte-compatible — iOS v0.5.4 contract preserved. The medication-compliance route's cache-read picked up a stub-row recogniser so its `{ summary, medications }` envelope short-circuits cleanly on the helper's `text` + `timeout: true` payload. - **`/api/auth/check-user` identifier mismatch.** The route called `identifier.toLowerCase()` before the `OR`-match on `username` / `email`, but `registerSchema` applies no transform on write — both columns are stored exactly as typed. A user registered as `MixedCase@Example.com` would never resolve and iOS onboarding would route them to the sign-up branch despite an existing account. The route now queries the identifier verbatim; tests pin both the casing invariant and the rate-limit 429 path. - **Soft-delete invisibility in three remaining reader tiers.** The v1.4.40 audit closure wired `deletedAt: null` filtering through eleven core read paths. The remaining three — `/api/export` bundle reads, `/api/gamification/achievements` queries, and the doctor-report PDF aggregator — are now also filtered. Integration tests under `src/app/api/export/__tests__/soft-delete-filter.test.ts` pin the invariant on all five W-DELETED-2 reader tiers (the prior pass shipped assertions for three out of the five the file header listed). - **Tree hygiene — retired v1.4.39 W-SUM legacy-NULL UNION arm.** The `sum_value IS NULL` discovery arm in `enqueueBootTimeRollupBackfill` was added in v1.4.39 W-SUM to converge DAY rollup rows that pre-dated the writer change. Production data has since converged, so the arm is removed; per-day missing coverage remains the sole discovery anchor. The read-side `mean × count` fallback in `/api/dashboard/summary` retains legacy-row readability for any self-host operator that has not converged. - **Lint warnings cleared, unused `tx?` params dropped, 13 dead exports trimmed.** Five `@typescript-eslint/no-unused-vars` warnings on `src/app/insights/page.tsx` and `src/lib/analytics/summaries-slice.ts` removed (leftover from the v1.4.37.2 GROUP BY rewrite). `recomputeMoodBucketsForEntry`, `recomputeMedicationComplianceForDay`, and `recomputeMedicationComplianceForEvent` lose their dead optional `tx?: Prisma.TransactionClient` parameter — no call site ever passed one. Thirteen knip-flagged dead exports narrowed or removed (project-wide knip drops from 48 → 35 unused exports; the exported-types bucket at 52 is deferred to v1.4.42 — most are zod-`infer` downstream types consumed via JSON or the iOS contract and require a per-flag audit). Two mid-file `import type` lines (`src/app/insights/page.tsx`, `src/components/onboarding/getting-started-checklist.tsx`) lifted to the top-of-file import block; the duplicate `DataSummary` import alias in `src/types/analytics.ts` + `src/app/page.tsx` collapsed to a single canonical name. ### Performance Anchored on the v1.4.41 W-INSIGHTS-HOT / W-RECONCILE-INSIGHTS wave reports; numbers ride the post-deploy window. - **Seven insights `*-status` routes: subsequent-mount path 14–20 s → ~50 ms.** Once the AI provider has stalled once that day, the timeout-stub short-circuit eliminates the re-race for the rest of the day. The first-of-day stall still spends the 20 s budget (same as bmi-status pre-v1.4.41). The recurring case the iOS client hits is the subsequent-mount path — eliminated on every status route the dashboard touches. ### Operator notes - **No migration.** No env-var change. No API contract break for iOS v0.5.4 — every existing response shape is byte-compatible; the new route (`POST /api/auth/check-user`) is an additive surface; the timeout-stub fallback writes use the existing `audit_log` table. - **AP-2 .p8 key install is now closed.** The APNs `time-sensitive + priority 10` payload (shipped in v1.4.40 SB-5) is effective as of v1.4.41 deploy — the `.p8` private key is installed in the Coolify secret store. `aps_last_error` should no longer surface as `auth-failed` on `/api/notifications/status`; the iOS team can verify Focus-bypass behaviour against the live deploy. - **`/api/dashboard/widgets` 422 on iOS is an iOS payload-shape mismatch, not a server validator gap.** W-IOS-COORD traced the recurring 422 to one of three iOS payload candidates (unknown widget id, out-of-range `order`, missing required field). The server validator is correct and additive-safe. The iOS team picks up the investigation against the next iOS build; v1.4.42 will land multi-issue Zod-error diagnostics so a future shape mismatch reports every offending field instead of just the first. - **Knip exports / types tier remains staged.** Post-v1.4.41 baseline: 35 unused exports + 52 unused exported types. The `--include` flag flip to enforcing mode is deferred to v1.4.42 once the `knip.json` ignore block has been triaged (the remainder is dominated by zod-`infer` downstream types, shadcn surface area exports, and `typeof X[number]` alias backings — each needs a per-flag audit). - `pnpm test --run` green at 4732 passing / 1 skipped (4733 total); `pnpm typecheck`, `pnpm lint`, `pnpm knip --include files,dependencies,binaries,unlisted` all green. ## [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 `