# Changelog ## [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 `