# Changelog ## [1.4.31] — 2026-05-16 — Operator toggles + insights tab-strip + Coolify auto-deploy fix Three orthogonal patches in one release. The biggest item is a per-surface operator toggle matrix that lets the maintainer carve which model-driven surfaces stay visible without removing the provider configuration — Coach, Daily Briefing, per-metric status cards, correlations, and the Health-Score delta explainer each get an admin switch, plus a master kill-switch above them all. Alongside that lands the root-cause fix for the /insights tab-strip blocking on mobile: an abort timeout on the advisor fetch, a `React.memo` on the strip with its `availability` prop memoised in the layout shell, and a lazy-loaded Coach drawer collapse the worst-case tap-block window from the LLM-completion tail down to the bounded parallel-fetch window. The release also closes the long-standing Coolify auto-deploy race: five releases in a row the webhook reported "finished" but Coolify still pulled the prior `:latest` digest because the publish workflow fired the webhook inside GHCR's CDN propagation window; a 90 s sleep before the trigger step lands inside the existing `continue-on-error: true` envelope. ### Added - **Assistant-surface operator toggles.** Six boolean columns on `AppSettings` (`assistantEnabled` + five sub-flags) carve the visibility cut for every model-driven surface. Master forces every sub-flag false in the resolver, so a single flip kills the whole assistant. New admin panel at `/admin/assistant` (slug between `ai-quality` and `coach-feedback`) renders the six toggles; sub-toggles grey out when the master is off. Defaults preserve the v1.4.30 behaviour (every surface visible) so upgrades require no admin action. - **`GET /api/feature-flags`.** Projects the matrix over HTTP for every client — web React tree + iOS native. `requireAuth()` gates the route, `Cache-Control: private, max-age=60` caps the read cost on the hot /insights mount path. - **`PUT /api/admin/settings/assistant-flags`.** Dedicated admin write surface that echoes both the raw column values and the resolved (master-killed) shape. Optimistic UI on the admin panel; the runtime feature-flag cache invalidates on every write so toggled surfaces react within the same operator session. - **`` component.** Small inline notice for callers that resolve a 403 + `errorCode: "assistant.disabled."` from the server. Localised in all six locales. - **`useFeatureFlags()` hook.** Fails open — any network error or absent `` returns the all-on default so the user never loses an affordance to an instrumentation gap. ### Fixed - **Insights tab-strip blocking on mobile.** Three orthogonal client-side fixes per `.planning/research/v15-insights-blocking-bug.md`: - `fetchAdvisor` gets an 8 s `AbortController`; `AbortError` falls through to the existing graceful-null return path so the UI surfaces the regen CTA instead of pinning the query in `isFetching: true` for the LLM tail. - `` is `React.memo`'d, the inner `buildTabs` call is `useMemo`'d on `availability`, and the layout shell memoises the `availability` prop on its three leaf inputs. Together they collapse the "shell re-renders → strip re-renders → 8 pills re-render" cascade on every cache-write of analytics or comprehensive. - `` is now lazy-loaded via `next/dynamic` so the SSE machinery, chat reader, suggested-prompts rail, and settings sheet don't initialise on every cold /insights mount. ### Changed - **Server-side gating of assistant endpoints.** Every LLM-driven endpoint now reads the operator flag set near the top of the handler and throws a typed `AssistantDisabledError` when the relevant surface is off. The api-handler catches the error and returns 403 + `meta.errorCode: "assistant.disabled."`. Older clients without the errorCode see a generic 403; v1.4.31+ clients render the `` empty state. Gated endpoints: `/api/insights/chat` (coach), `/api/insights/generate` (coach), `/api/insights/comprehensive` (coach), `/api/insights/cards` (insightStatus), `/api/insights/correlations` (correlations), and the six `*-status` per-metric routes (insightStatus). ### CI - **Coolify auto-deploy race fix.** `Trigger Coolify deploy` step in `.github/workflows/docker-publish.yml` now sleeps 90 s before firing so GHCR's CDN edges have time to propagate the fresh `:latest` digest. The webhook lands after the edge read catches up, Coolify pulls the new digest, and the running container recreates cleanly. Full root-cause + hypothesis matrix in `.planning/round-coolify-auto-deploy-fix-2026-05-16.md`. - **OpenAPI pre-commit hook.** New `.githooks/pre-commit` runs `pnpm openapi:check` when the staged diff touches Zod schemas or API routes; on drift it regenerates the spec, re-stages the file, and continues the commit. Activated via the new `scripts/install-hooks.sh`. Skips in CI so the existing `security.yml` server-side gate stays authoritative. ### iOS contract Every change is additive on the wire. - `GET /api/feature-flags` is net-new. iOS clients that predate it never call the endpoint and see the all-on default implicitly. - The six `AppSettings.assistant*` columns default to `true` in the migration so existing rows pick the defaults up without data movement. - The 403 + `meta.errorCode: "assistant.disabled."` envelope is additive — pre-v1.4.31 iOS reads the 403 as a generic auth error and degrades gracefully through its existing 403 handler. v1.4.31+ iOS reads the `errorCode` to render the surface-specific empty state. - The locked-contract entry at `.planning/v15-ios-handoff/08-locked-contracts.md` §14 documents the rule that the flag matrix gates BOTH server-routed AND on-device assistant surfaces. Apple Foundation Models on-device Coach + Briefing flows in the v0.6.0 iOS path honour the same flag matrix as the server-routed surfaces. ## [1.4.30.1] — 2026-05-16 — Categories endpoint + conflict-resolution lock A two-item follow-up to v1.4.30. The categorisation overlay shipped yesterday as a TypeScript map; the new `GET /api/measurement-categories` endpoint projects it over HTTP so the iOS-side picker can fetch the canonical shape rather than carry a hard-coded mirror — the unblocker the iOS team flagged as the one missing piece. In parallel the SyncMode conflict-resolution policy that the v0.6.0 standalone-first track depends on lands as §13 of `08-locked-contracts.md`. ### Added - **`GET /api/measurement-categories`.** Thin projection of `src/lib/measurements/categories.ts` over HTTP. Response carries `version: 1`, an ordered `categories` array with `labelKey` translation keys, and the `assignments` map keyed on `MeasurementType`. `requireAuth()` gates the route — any logged-in user passes — and `Cache-Control: public, max-age=600` enables a 10-minute edge cache. Locked per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R1. ### Documented - **SyncMode conflict-resolution policy.** `.planning/v15-ios-handoff/08-locked-contracts.md` gains §13 per R9 of the iOS-team response doc. Hard-spec covers bulk-backfill, steady-state bidirectional sync via `(updatedAt, syncVersion)` optimistic concurrency, the 409 write-conflict + 410 Gone delete-conflict envelope shapes, the LWW-by-`updatedAt` resolution with server-wins on millisecond tie, and the `GET /api/sync/state` response. The "What is NOT in this file" stub renumbers to §14. - **iOS contributor brief.** §3 bumps the live-production marker to v1.4.30 and adds the four v1.4.30 endpoints plus the new v1.4.30.1 endpoint to the iOS-consumable list. §5b / §6 reframe the Coach SSE story per R6 — the server endpoint stays live; the iOS native server-Coach drawer is deferred pending MDR Class-IIa pre-review. v1.5.0 iOS ships Apple Foundation Models on-device Daily Briefing + Trend Observations as the primary assistant surface. - **v1.5 strategic plan.** §2 slots Apple Health XML import as v1.4.34 immediately before the web-freeze marker (per R2), detailing the multipart endpoint, the streaming XML parser, the async job model, the per-MeasurementType ingestion stats, the idempotent UPSERT on `externalId`, and the admin endpoint variant. Effort L (~3-4 days). The freeze trigger renumbers from v1.4.33 to v1.4.34; the decision-log row "Apple Health XML import slot" lands; the §4 deferred row and §6 open question #3 close out. ## [1.4.30] — 2026-05-16 — iOS-coordinated foundation (Daily-Stats + SyncMode) Server-side prep for the next iOS TestFlight build. Five surfaces land together so the iOS engineer can pick them up in one cut-over: a locked `externalId` shape for daily-aggregated cumulative HealthKit rows, the SyncMode foundation columns + handshake + bulk-backfill endpoints, a first-class `MoodEntry.note` column that replaces the legacy `tags: ["note:"]` workaround, a cross-source workout dedup helper, and two new MeasurementType enums (`WALKING_STEADINESS`, `AUDIO_EXPOSURE_EVENT`) plus the shared `MEASUREMENT_CATEGORIES` overlay that drives the iOS permission picker and the future Insights nav. ### Added - **Daily-stats `externalId` helper.** `dailyStatsExternalId(hkId, date)` mints `"stats::"` alongside the v1.4.29 `CUMULATIVE_HK_TYPES` set. The shape is locked in `.planning/v15-ios-handoff/08-locked-contracts.md` §12 and `.planning/v15-ios-handoff/06-ios-responsibilities.md`. iOS emits one row per day per cumulative type via `HKStatisticsCollectionQuery`; the unique index collapses re-syncs idempotently, and a future PATCH-on-divergence call updates a late-watch-sync revision without inserting a second row. - **Drain script + admin endpoint** at `scripts/drain-per-sample-cumulative.ts` and `POST /api/admin/drain-per-sample-cumulative` (gated by `requireAdmin()`, default `dryRun: true`). Collapses pre-Option-A per-sample APPLE_HEALTH cumulative rows into one row per day per type. Idempotent — re-running on a fully-collapsed account reports zero buckets touched. - **SyncMode foundation.** Migration 0062 adds `Measurement.sync_version` (Int, default 1), `Measurement.deleted_at` (Timestamp, nullable) for soft-deletes, and `User.last_synced_at` (Timestamp, nullable). The new `GET /api/sync/state` returns the handshake response (lastSyncedAt, server clock, live + tombstoned counters); the call also bumps the checkpoint so iOS reads the OLD value then trusts subsequent writes via the standard read paths. - **Bulk backfill endpoints.** `POST /api/mood-entries/bulk` and `POST /api/medications/intake/bulk` accept up to 500 entries per call with the same response envelope as the measurements + workouts batch. Probe-then-upsert distinguishes inserted vs duplicate on the mood path; idempotency-key collision yields duplicate on the intake path. Both rate-limited at 60/min/user. - **`MoodEntry.note` column** (migration 0063). The bulk + single POST + PUT routes thread the new field through; the existing `tags: ["note:"]` workaround backfills via `scripts/backfill-mood-note-column.ts` (CLI dry-run by default, `--confirm` commits). - **`pickCanonicalWorkoutRows()`** at `src/lib/measurements/pick-canonical-workout-rows.ts`. Cross-source workout dedup symmetric to `pickCanonicalSourceRows()`. Buckets rows by 5-minute startedAt slot + sportType and walks the existing measurement source ladder; metric-aware tunes (route → Apple wins, HR zones → Withings wins) defer to v1.5.x. - **`MEASUREMENT_CATEGORIES` overlay** at `src/lib/measurements/categories.ts`. UI-only category map (vitals / body / activity / sleep / hearing / environment / cardiovascular / metabolic) that drives the iOS HealthKit permission picker, the post-v1.5 web Insights nav, and the Coach evidence shelf chip-grouping. Completeness wall in the test suite catches a new MeasurementType lacking a category assignment. - **Two MeasurementType enums** (migration 0064) — `WALKING_STEADINESS` (iOS 15+ Mobility daily rollup, ×100 scaled from Apple's 0..1 fraction) and `AUDIO_EXPOSURE_EVENT` (iOS 13+ category flag fired when the rolling 7-day average crosses the WHO 80-dBA threshold; environmental + headphone events share the same enum value). The wiring registries (`apple-health-mapping`, `categories`, `pr-direction`, `chart-tokens`, six locale files) pick them up in the same release. ### Changed - **Real-Postgres integration coverage expanded.** Every new endpoint introduced in v1.4.30 rides the v1.4.29 testcontainer fixture: the drain helper, the admin endpoint, the sync-state handshake, the mood-entries bulk upsert, the medication-intake bulk insert + idempotency-key collision, the mood-note column round-trip. Full integration suite at 47 files / 190 specs. - **`HK_QUANTITY_TYPE_DEFERRED` trimmed.** Three identifiers move out of the deferred set into the mapping table: `HKQuantityTypeIdentifierAppleWalkingSteadiness`, `HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent`, `HKCategoryTypeIdentifierHeadphoneAudioExposureEvent`. ### iOS contract Every API change is additive. The new endpoints (`/api/sync/state`, `/api/mood-entries/bulk`, `/api/medications/intake/bulk`, `/api/admin/drain-per-sample-cumulative`) are net-new — no iOS consumer yet. The SyncMode columns carry defaults so existing iOS POSTs round-trip unchanged. The two new MeasurementType enums are net-new values; iOS clients that predate the codegen pass will not encounter them in read paths because no source writes them yet, and the codegen path will pick them up on the next iOS regeneration. **Cutover sequence.** v1.4.30 ships with the helper + drain script + server tolerance for both shapes. The next iOS TestFlight build adopts `HealthKitStatisticsService.swift` and starts posting daily- aggregated rows for the five cumulative types. Operator runs the drain script once after the new TestFlight cuts over; per-sample row pressure on `Measurement` drops 50-200× for cumulative types. ## [1.4.29.1] — 2026-05-16 — Daily-step aggregation hotfix A one-line follow-up to v1.4.29. The dashboard 7-day step chart now renders daily totals (sums in the thousands), not per-sample averages (hundreds), matching the server-side `aggregate=daily` contract that landed earlier today. ### Fixed - **Client-side daily aggregator.** `health-chart.tsx` reduced every HealthKit type with `sum / count` regardless of whether the metric is cumulative or spot. The branch now consults `CUMULATIVE_HK_TYPES` and picks `sum` for steps, active energy, flights climbed, walking + running distance, and time in daylight, while the spot metrics (BP, weight, pulse, BG, body fat, mood, sleep) keep the mean. ### Tests - The existing chart suite (137 specs) continues to pass; the one-line branch is exercised by the v1.4.29 server-side aggregation tests and needs no additional coverage. ## [1.4.29] — 2026-05-16 — Dashboard performance + chart polish A targeted performance + polish patch. The headline is a faster dashboard on data-rich accounts: the pulse chart drops up to ~5 000 raw rows per range tab in favour of one row per day, the duplicate `/api/dashboard/widgets` fetch on every mount collapses to one, and three long-running analytics reads are bounded to the trailing window the tile path actually needs. Two production regressions close — the `aggregate=daily|weekly|monthly` path on `/api/measurements` 500'd for every grain, and the same path averaged cumulative step counts instead of summing. Mobile chrome gets a tighter rhythm: dashboard tiles pin to a single 140-px contract at `` range test asserts the default 30-day window asks for `aggregate=daily` and the `windowOverride="last7days"` mini-chart does not. - `` mobile-tile-height contract pinned via a new snapshot test (`trend-card-tile-height.test.tsx`). ## [1.4.28.1] — 2026-05-16 — Dashboard-save hotfix "Speichern" on the Dashboard works again for every account whose layout was last persisted before v1.4.28. The v1.4.28 retire of the `glp1` widget id left an orphan entry in `dashboardWidgetsJson` for legacy users, which made the save validator reject the payload; the resolver now filters unknown widget ids on read. ### Fixed - **Dashboard save against legacy layouts.** `resolveDashboardLayout` in `src/lib/dashboard-layout.ts` drops widget ids that are no longer in the registry before returning the layout, so the `dashboardWidgetsJson` round-trip (`GET /api/me/dashboard` → user edit → `PUT /api/me/dashboard`) succeeds even when the persisted JSON still names `glp1` or any other retired widget. ### Tests - `src/lib/__tests__/dashboard-layout.test.ts` gains a case for the retired-id filter (19/19 green). ## [1.4.28] — 2026-05-16 Bug-fix and consistency follow-through after the v1.4.27 mobile sweep. The headline is a tighter dashboard and insights surface: six widgets retire from code entirely (GLP-1 tile, dashboard `` mount, GLP-1 detail-page intake history + inventory, the `` block and its regeneration affordance, the weekly-report route), four broken edges close (workout-edit duplicate- timestamp 409, BD-Zielbereich tile rebuilt on the shared `` primitive, `/insights/puls` chart timeout fallback, sticky tab-strip scroll lock), and one consistency contract lands per maintainer directive: every medication-list row, every medication- detail section header, every trend tile, every Coach launch glyph, every chart-height contract reads on one shape. The HealthScore card grows to fill its column, gains an accessible `?` tooltip explaining the delta, and drops the placeholder "Wochenbericht erstellen" button. iOS contracts are intact; the only `/api/*` evolution is additive (the `aggregate=monthly` grain on `/api/measurements` and an internal-only `/api/internal/web-vitals` beacon route). ### Removed - **GLP-1 dashboard tile.** `src/components/dashboard/glp1-tile.tsx` and every reference (`dashboard-layout.ts` entry, the `dashboard.glp1.*` i18n keys, the test fixtures) retire. The tile is gone from `/`; the recovered vertical real estate flows to the trends row + HealthScore column. - **Dashboard `` mount.** The standalone Drug-Level pane retired from `/`. The component itself stays — it serves the `/medications/[id]/history` page exclusively. The `compact` mode and `windowHoursBefore` override drop with the dashboard mount; the history-page recipe paints the chart inside `` on the same heading scale as every other section on the page. - **`` surface.** The "Persönlicher Berater" card at the bottom of `/insights` and its "Insights aktualisieren" regeneration button retire. The Coach drawer is the single assistant entry point. Component, mounts, test fixtures, i18n keys, and the cached-advisor query all retire together. - **Weekly-report route.** `/insights/report/[week]` and the `` mount on the hero retire. No remaining consumer; the Coach drawer covers the equivalent ask. - **GLP-1 detail-page intake history + inventory.** The "Dosis- Historie" disclosure and "Bestand" section retire from the GLP-1 medication detail page. The page now collapses to header, schedule, dose-titration ladder, and side-effects — every section the maintainer flagged as wanted; every section flagged as unwanted is gone. Inventory tracking remains opt-in for a future release. - **Hero "Wochenbericht erstellen" button.** The placeholder affordance retires from the `/insights` action row. The row now carries a single "Coach fragen" button so the HealthScore card on the opposite column has the height to reach the last suggested prompt. ### Fixed - **Workout edit raised a 500 on duplicate timestamps.** The save path on `PUT /api/measurements/[id]` returned a generic 500 when a sport-typed sample (`ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`, `WALKING_RUNNING_DISTANCE`, `EXERCISE_TIME`) collided with an existing row on the same `(type, measuredAt)` natural key. Returns 409 with a `measurements.duplicateTimestamp` translation key now; the iOS native client and the web measurement-form both surface the conflict to the user instead of a generic toast. - **BD-Zielbereich tile rendered "1.1." instead of numbers.** The tile's bespoke rendering path read the schedule date through `Intl.DateTimeFormat` and missed the underlying value. Tile rebuilt on the shared `` primitive so the chrome, the state shape, the prop API, and the fallback behaviour match the Weight and BP siblings. The divergence has been the open finding across v1.4.22, v1.4.25, v1.4.26 patches — this release rewrites the tile instead of patching. - **`/insights/puls` hung waiting on the assessment provider.** The pulse status card called the language-model provider with no client-side timeout. A timeout helper (`with-timeout.ts`) caps every status-card provider call at 20 s and returns the rule-based fallback string on timeout; the page renders the chart immediately and the assessment line resolves async. - **Scroll stuck on `/insights` mother-page navigation.** The sticky tab-strip's intersection-observer was fighting the focus-on-mount logic so the page sometimes refused to scroll past the tab row. The observer rebinds on route change and the focus-on-mount effect now waits for the next animation frame so the scroll position is locked in before the observer fires. - **`` paint exceeded the active range window.** The chart fetched every dose-event in history regardless of the selected range. Now bounded to the active range — 30 days / 90 days / all-time — so the wire payload and the recharts render scale linearly with the visible window. - **Mood trend tile painted a residual `rounded-xl` border.** The shadcn `` default paints `rounded-xl` while the trends row needs `rounded-md` to match the `` shape. Mini- mode override adds `rounded-md`; the visual rhythm of the row reads on one envelope. - **Side-effects card overflowed at 320 px.** The "Nebenwirkungen erfassen" CTA chip ran past the section header. The qualifier drops from every locale ("Erfassen" / "Log" / "Consigner" / "Registrar" / "Registra" / "Dodaj"); the section title carries the context. The date column on the entry rows narrows from 88 px to 56 px so the free-text slot recovers 32 px of wrap headroom. - **Medication-list row shape diverged for GLP-1 entries.** The GLP-1 row painted with a brand icon and a middle-dot separator; every other row painted two-line without an icon. Both routes through a shared `` now — line 1 is `{name} {dose}`, line 2 is the class label plus state badges. The GLP-1 outlier shape is gone. - **HealthScore card came up short below the action+prompts column.** The card now opts into `flex h-full flex-col` with the disclaimer footer pinned via `mt-auto`; the hero row switches to `md:items-stretch` on `md+` so the score column reaches the bottom of the suggested-prompt rail. Equal-height contract across the hero strip. - **`/measurements` aggregation truncated long-window queries.** The `take` cap applied before bucketising, so a 365-day daily-grain query returned only the first N raw rows. Aggregation now runs as a Postgres `date_trunc` GROUP BY and the cap applies to the bucketised result; a 1-year window returns up to 365 daily buckets. The all-time chart range resolves to monthly grain (24- bucket ceiling) or weekly when history is under two years. - **Schlaf sub-page missed the per-section assessment slot.** Six of seven insights sub-pages mount `` underneath the chart; Sleep did not. Documented as the intentional skip with a `// no per-section assessment yet` marker so future contributors do not re-flag it. - **Insights-targets locale strings missed on FR / ES / IT / PL.** `measurements.duplicateTimestamp` and `insights.sleep.description` now carry native translations in every locale. ### Changed - **BD-Zielbereich tile aligned to ``.** Tile chrome, state shape, prop API, and fallback states all match Weight and BP. The Z-value rounds to one decimal at the boundary, the legend carries the same micro labels across siblings, the empty state reads the same copy with the same CTA shape. - **Medication-list rows route through ``.** GLP-1 and standard rows both render `{name} {dose}` on line 1 and class label plus state badges on line 2. State badges break onto their own row at 320 px so the two-line shape holds. - **Medications detail-page chrome collapsed to one heading scale.** Every section on `/medications/[id]` mounts through `` (`text-base font-semibold leading-6 tracking-tight`). DrugLevelChart's standalone header migrates to `

` with the same classes. Micro labels lift from `text-[10px]` / `text-[11px]` to `text-xs` across Scheduling, Titration, SideEffects. Three scales survive the page: heading, body (`text-sm`), micro (`text-xs`). - **Coach launch shape consolidated to three primitives.** A single `` mounts once per Insights surface (FAB on `