# Changelog ## [1.4.34.2] — 2026-05-16 — `pull_policy: always` so Coolify deploys actually pull Bundle-release hotfix for the v1.4.34.1 deploy stall. The GHCR image for v1.4.34.1 published successfully, but Coolify's `docker compose up` on the apps-01 host kept reusing the cached `:latest` digest because `docker-compose.yml` had no pull policy declared — Docker's default ("only pull if missing") happily skipped the registry round-trip and restarted the container with the prior v1.4.34 image, completing each deploy in ~20 s without ever fetching the new manifest. Two consecutive force-rebuilds of v1.4.34.1 reproduced the pattern. ### Changed - `docker-compose.yml` now declares `pull_policy: always` on the `app` service. Compose-up re-checks the registry digest on every `up` and pulls when GHCR has a newer manifest, regardless of the tag string. The minor bandwidth cost per deploy is the price of correctness; the alternative — pinning to a version tag per release — would require every operator (self-host included) to bump the tag on every release. v1.4.34.1's perf work ships in this image because all of it is on the `:latest` tag the Coolify deploy will now actually pull. ## [1.4.34.1] — 2026-05-16 — Insights cold-mount perf hotfix + scatter-card sizing Hotfix on top of v1.4.34. The Insights page mount was paying ~29 s on every cold load against accounts populated by the v1.4.34 Apple Health importer. Root cause: `/api/insights/comprehensive` walked the unbounded 90-day measurement set into JS, then ran per-type `summarize()`, BMI, blood-pressure classification, target adherence, and four Pearson correlations from the same pile of rows. On a 100 000+ row account the route consumed one of the 20 pool connections for the whole 29 s, which cascaded sibling endpoints into Cloudflare "no available server" 503s. The scatter-correlation card also surfaced a `width(-1)/height(-1)` Recharts warning on first paint because its dynamic-mount lacked a non-zero floor before aspect-ratio resolved. ### Changed - `/api/insights/comprehensive` now reads through a SQL-side aggregator (`src/lib/insights/comprehensive-aggregator.ts`) that groups by metric type with Postgres-native `AVG` / `MIN` / `MAX` / `REGR_SLOPE` / `REGR_R2` on the `measurements (user_id, type, measured_at)` index path. Returns the same `DataSummary`-shaped per-type bundle the legacy route stitched together; the medication-compliance block keeps its bounded Prisma reads — those were already on the safe path. The route is wired through the server cache keyed on `${userId}|comprehensive` (60 s TTL) so the second consumer inside the window resolves on a `Map.get()` instead of re-running the aggregate. Witnessed wall-time: the cold path drops from ~29 s to a few hundred milliseconds against a fixture matching the production volume that triggered the bug; the warm path resolves in single-digit milliseconds. - Cache-wrap the five remaining read routes the v1.4.34 server-cache blueprint left staged but unwired: `/api/mood/analytics`, `/api/workouts`, `/api/bugreport/status`, `/api/medications`, `/api/dashboard/widgets`. All five sit on `caches.*` instances already provisioned by the v1.4.34 IW-G primitive; each handler now reads through `cached(...)` and benefits from the existing per-user invalidation matrix (`invalidateUserMeasurements`, `invalidateUserMood`, `invalidateUserMedications`, `invalidateUserDashboardWidgets`, `invalidateAppSettings`). The Cloudflare 503 cascade on `/api/measurements?aggregate=daily` resolves as a downstream consequence — once the comprehensive route stops monopolising the pool, the origin accepts new connections fast enough that Cloudflare's origin-fail circuit-breaker no longer trips. ### Fixed - Scatter-correlation card no longer logs the Recharts `The width(-1) and height(-1) of chart should be greater than 0` warning on first mount. The wrapper now carries a `min-h-[180px]` floor that matches the loading skeleton so `ResponsiveContainer` always measures a non-zero parent before aspect-ratio settles. ## [1.4.34] — 2026-05-17 — Apple Health import + reliability + web freeze The final functional web release before the iOS native client lands. Headline work: a streaming Apple Health `export.zip` importer for prospective iOS-migrating accounts, a server-side aggregation cache that collapses the three hottest dashboard reads onto in-process LRU slots with single-flight coalescing and per-user invalidation, a broader compliance classifier with a dedicated `early` bucket so ahead-of-window doses count as compliant, the Coach launch surface hoisted onto every authed page with a dashboard-hero CTA, and a trimmer Settings shelf that folds Sources into Targets. The release also lights up the `cache..outcome` annotation on the active wide event so production logs carry the cache hit-ratio signal without leaking userIds. ### Added - **Server-side aggregation cache.** A new `ServerCache` primitive (`src/lib/cache/server-cache.ts`) extends the v1.4.33 Coach snapshot LRU shape into a reusable per-route layer with TTL expiry, capacity-bounded LRU eviction, single-flight read coalescing, and per-instance hit / miss / eviction / stampede counters. Wires the three hottest dashboard reads — `/api/analytics` (slim + default), `/api/gamification/achievements`, `/api/medications/intake?scope=compliance` — and bolts per-user invalidation onto every measurement / mood / medication / workout / dashboard-widget write endpoint so the next read paints fresh data instead of waiting out the TTL. Every cache hit / miss surfaces on the active wide event as `cache..outcome` + `cache..key_hash` so production logs carry the hit-ratio signal without leaking userIds. - **Apple Health `export.zip` import.** New endpoint `POST /api/import/apple-health-export` (synchronous multipart upload, asynchronous ingest via a dedicated pg-boss `apple-health-import` worker, per-`MeasurementType` ingestion stats). Streams the upload straight to disk so a multi-gigabyte export never lands in V8 heap, hashes the bytes inline for content-based idempotency, and unpacks `apple_health_export/export.xml` with a hand-rolled ZIP central-directory walker that handles Zip64 for archives past the 4 GB barrier. The parser folds every ``, ``, ``, and `` into the existing `Measurement` and `Workout` row shapes — spot rows keyed by `HKMetadataKeyExternalUUID` (or a deterministic `sample:` fallback), cumulative HK types collapsed into one `stats::` row per user-local day to match the iOS daily-aggregation convention. Live per-stage progress surfaces through `GET /api/import/apple-health-export/{jobId}/status`. An admin variant at `POST /api/admin/import-apple-health-export` imports on behalf of a target user (cookie-only `requireAdmin()` gate; Bearer tokens never elevate). - **`ImportJob` schema model + migration.** New Prisma model captures the per-upload state machine (`queued | unpacking | parsing | upserting | done | failed`), the content-hash for idempotency short-circuits, and the per-`MeasurementType` ingestion counters the status route surfaces back to the client. - **`lastSeenByType` on `/api/analytics`.** Both the slim (`?slice=summaries`) and default slices return a per-type `{ lastSeenAt, daysAgo }` map. The dashboard trend tiles wire 12 mounts to a new `tileStaleDays()` helper so an "X days / weeks / months ago" hint paints under any per-metric tile whose last sample crosses the 7-day floor. Six locales gained additive week / month plural keys; Polish uses the genitive-plural form that covers every non-1 count the bucket math can produce. - **Dashboard "Ask the coach" hero CTA.** A new pill next to the existing "Hinzufügen" / "Add" launches the Coach drawer directly from the dashboard hero. The `` hoisted from the insights layout to the auth shell so the drawer is reachable from every authed route; the floating action button stays scoped to `/insights/**` so it cannot distract on the dashboard. - **Shared achievements query hook.** `useAchievementsQuery()` centralises the three previous consumers — recent-achievements card, the `/achievements` mother page, and the unlock notifier — onto one TanStack queryKey + cache slot so dashboard cold mount fires the endpoint once instead of twice. - **Typed authed Cache-Control presets.** New `src/lib/http/cache-headers.ts` module exports `NO_STORE_BUT_BFCACHE = "private, max-age=0, must-revalidate"`, `SHORT_LIVED_PUBLIC`, and an `applyAuthedHeaders()` helper. The presets land at the framework level via a `next.config.ts` `headers()` rule that stamps the bfcache-friendly directive on every authed HTML response, restoring Chromium bfcache eligibility for in-app back / forward navigation. - **`scripts/print-bundle-report.mjs`.** Restores the at-a-glance "top client chunks by parsed size" signal Turbopack dropped from `next build`; reads `.next/analyze/client.json` (written by `@next/bundle-analyzer` when `pnpm analyze` runs with `ANALYZE=1`) and prints a sorted table plus totals. ### Changed - **Settings sidebar: "Sources" folded into "Targets & Sources".** Per-metric threshold ranges and per-metric source priority now share one `/settings/thresholds` page so the same metric's threshold and device-source preference sit together instead of one sidebar entry away. `/settings/sources` keeps a `permanentRedirect` so external bookmarks and docs links follow through unchanged. Section count: 11 → 10. - **Insights tab strip: vital pills collapse under a "Vitals" parent.** Five vital pills (HRV, Resting HR, Oxygen, Body Temperature, Active Energy) hide behind one parent pill that opens a popover sub-list. Parent-pill active state mirrors the URL so spatial orientation survives the collapse; each sub-page keeps its own URL and bookmark resolves unchanged. Strip footprint: 14 → 10 entries when every metric has data. - **Coolify env-var audit.** `mcp__coolify-apps01__env_vars` inspection captured the section-1 / section-2 duplicates that have accumulated under apps-01 since v1.3.1. Audit pinned at `.planning/round-v1434-iwa-coolify-env-audit.md`; no env-var deletes performed (operator action). ### Fixed - **Compliance classifier: early intakes count as compliant.** The classifier picked up a dedicated `early` bucket (window-start minus three hours through window-start), the `on_time` post-window tolerance widened from one hour to three hours, and the `late` band sits between the new `on_time` ceiling and the configurable `lateMinutes` knob. Heatmap consumers route the new bucket through the compliant path; the dedicated `early` counter rides on `DailyComplianceEntry` for downstream consumers that want to distinguish ahead-of-window from on-window intakes. - **Compliance heatmap fallback retired.** The v1.4.33 defensive `looksClassifierBug` fallthrough is gone now that the underlying classifier widening removes the every-dose-very-late mode the fallback covered. ### Performance - **`/api/gamification/achievements` consumer collapse.** Two of three previous consumers shared the same TanStack queryKey literal; the third carried a per-user discriminator so the cache treated it as a fresh slot. Dashboard cold mount fired the endpoint twice. Collapsing onto the shared hook trims that to a single request; with the new server-side cache slot warm, the duplicate-mount worst case rides single-flight coalescing. - **GHCR build fires once per release tag.** The `docker-publish.yml` workflow lost its `push.branches: [main]` trigger; the `:latest` raw-tag enable rule moved onto the tag ref so each release produces exactly one multi-arch build that refreshes both the semver tags and the `:latest` alias. - **NFT-trace warnings silenced.** `next.config.ts` gained an `outputFileTracingExcludes` block that narrows the Turbopack tracer away from the `MAXMIND_LICENSE_KEY` env-access path so the trace-report no longer warns on every dependent route file. ### Refactor - **Dashboard analytics consumers.** The two cards that previously decided their own freshness copy now lean on the typed `tileStaleDays()` helper and the additive `lastSeenByType` field on the analytics response. Mood and BD-Zielbereich tiles keep the default `null` because they have no underlying per-type freshness signal. ### Accessibility - **Dashboard hero CTA hits the WCAG 2.5.5 touch-target floor.** The new "Ask the coach" pill carries `min-h-11` on mobile and the matching `sm:min-h-9` desktop floor so the touch target stays above 44 px on the Pixel 5 boundary. - **Insights tab-strip Vitals parent reads correctly to assistive tech.** Parent pill carries `aria-current="page"` whenever the current URL matches one of its child vital sub-pages, plus a `data-slot="insights-tab-strip-group"` hook for visual-regression testing. ### Internal - **Two e2e flake windows tightened.** `onboarding-flicker` swapped its 12-sample 50 ms poll loop for a single Playwright auto-retrying `toBeHidden({ timeout: 700 })` assertion so the 1-2 ms race window between the analytics-pending shell and `useAuth().user` resolving collapses to a single retry slot. `mobile-viewport` dropped `nav a[href]` from the touch-target sweep (the bottom-nav owns its own WCAG spec) and gated the 44-px floor on `matchMedia('(min-width: 640px)').matches === false` so the Pixel 5 viewport never tripped into the `sm:` tier during WebKit render commits. - **Server-cache observability.** Every `cached()` invocation passes an `annotate` callback that lands two keys on the wide event: `cache..outcome` ∈ `{ hit | miss | stampede }` and `cache..key_hash` (non-reversible djb2 32-bit hash). Ops can grep `cache.analytics.outcome` over any time window to compute the hit ratio per deployment. - **AASA followups verified.** The v1.4.33 `/.well-known/apple-app-site-association` handler serves a direct 200 on every fronting origin; Apple's CDN has ingested matching bodies for all three domains. ### Web freeze v1.4.34 marks the last functional release of the web codebase before the iOS native client launches. Subsequent v1.4.x tags carry security fixes, dependency bumps, and hotfix-only corrections. New feature work is paused until the iOS app clears Apple review, at which point a v1.5.0 version-bump-only release tags the milestone. The Prisma schema head comment and `.planning/v15-strategic-plan.md` §5 decision-log row pin the freeze trigger in-tree. ## [1.4.33] — 2026-05-17 — Polish and reliability Quality-leap release between two HealthKit milestones. The headline is a P0 hotfix for the `/api/analytics` 500 that broke the Insights mother page for any account with more than a few thousand measurements: the six per-metric status helpers were spreading the entire numeric history into `Math.min` / `Math.max`, which trips a stack overflow once a single argument list exceeds the V8 spread cap. Both the surfaced symptom and the latent risk in five sibling helpers were folded onto a reduce-based min/max path. Around that hotfix landed a deep polish pass: the Insights mother page now defers three below-the-fold blocks behind `next/dynamic` to trim the cold mount cost, the analytics endpoint gained a `?slice=summaries` slim slice that the per-metric sub-pages now ride, the Coach snapshot builder picked up a 60 s LRU keyed on `(userId, scope)`, the Settings shell consolidated three duplicate copies of the about / notifications / about-dropdown surfaces, the navigation strip dropped a redundant "Home" group label and a colliding "Notifications" sibling, every icon-only button on the medications and admin surfaces gained an accessible name, every Progress bar gained one too, three pages had their heading hierarchy repaired to a sequential `h1 → h2 → h3`, the mobile Coach FAB now hides while a chart tooltip is open so it cannot occlude the read-out, and the auth shell normalised every authenticated route to a single `max-w-screen-xl` container. The release closes nineteen issues from a five-surface audit + a runtime bug-hunt pass, plus six follow-up tracks of polish across the affected surfaces. ### Added - **Slim `/api/analytics?slice=summaries` slice.** Per-metric sub-pages opt into a payload that drops `correlations` + `healthScore` + `medications` blocks, cutting the cold-mount response on those routes by roughly half. The mother page stays on the default thick slice so the correlation row + health score badge still resolve from the same cache key. - **`useScrollResetOnRoute()` hook.** Single source of truth for the route-change scroll-to-top behaviour, replacing per-page `useEffect` duplicates across seven mounted surfaces. - **`` primitive.** Extracted from three near-duplicate copies inside the Settings shell so future section additions inherit the icon / title / description treatment without redeclaring the markup. - **Apple App Site Association handler.** `/.well-known/apple-app-site-association` now answers 200 with `application/json` on every host that fronts the app, advertising the iOS bundle's App ID prefix (`S8WDX4W5KX.dev.healthlog.app`) under `webcredentials.apps` so the passkey ceremony shares cleanly between the web origin and the iOS app. The proxy gained a `/.well-known/` public-prefix entry so the Apple CDN fetch lands on the asset instead of the auth gate. ### Changed - **Insights mother page deferred below-the-fold blocks.** Daily briefing, correlation row, and trends row now resolve through `next/dynamic` with skeleton fallbacks that match the existing loading shape. Above-the-fold hero stays an eager import so the initial paint shows the greeting + health-score badge without a flash. - **Settings section renamed.** "Notifications" became "Notification channels" (German: "Benachrichtigungs-Kanäle") so it no longer collides with the inbox at `/notifications` ("Notification Center" / "Benachrichtigungs-Center"). - **Settings Auswertungen section renamed.** The German label "KI-Auswertungen" became "Auswertungen" in user-facing copy per the long-standing rule that user-facing surfaces drop the model-vendor prefix. - **Insights tab strip regrouped by metric category.** Pills now cluster vitals / cardiovascular / activity / wellbeing so long-strip scrolling lands on a related neighbour. - **About section folded into the user-card dropdown.** The in-shell Settings nav no longer surfaces an "About" entry; the sidebar user-card dropdown now owns the "About HealthLog" link. Route `/settings/about` still resolves for direct links. - **Auth shell container width normalised.** Every authenticated route now renders inside a single `max-w-screen-xl` container, retiring three competing widths that drifted across the Insights / Settings / Dashboard surfaces. - **Card defaults normalised.** Every card now defaults to `p-4 md:p-6` padding so mobile + desktop spacing stay in lock step without per-instance overrides. ### Fixed - **`/api/analytics` 500 (P0).** Stack overflow from `Math.min(...values)` / `Math.max(...values)` spreads on long histories. Hotfixed on the surfaced helper and folded across the six sibling status helpers. - **Spotlight onboarding tour intercepted dashboard clicks.** Overlay z-index + pointer-events combination meant the first post-completion tap landed on the dimmed overlay instead of the tile underneath. - **BP chart Y-axis unit.** Read "Hg" instead of "mmHg". - **Bottom nav viewport overlap.** Hardened against the last-line clip on shorter viewports. - **`/insights/puls` subtitle.** Read "Ruhepuls" instead of "Puls". - **Weight chart duplicate Y-axis ticks.** Tick generator emitted the same value twice on narrow domains. - **Web-vitals beacon self-throttling.** Beacon was sampling at full rate and tripping its own queue cap; sample rate dropped to 10% so RUM telemetry actually reports. - **Medication compliance classifier flushing every dose to `very_late`.** Defensive fallback when the dose-window estimator can't resolve a window. - **`/api/insights/generate` POST gating.** Endpoint now refuses on a disabled assistant master flag instead of returning a 500. - **Mood Log overflow on narrow viewports.** Cards no longer shred their internal grid below 360 CSS px. - **F13 username readability.** Header username now respects contrast tokens on the dark theme. - **F14 mobile bottom-nav padding.** Bottom nav no longer vertically clips its second row on the smallest mobile viewport. - **F17 threshold toggle parity.** Settings threshold toggles now reflect their server-side enabled state on mount instead of always opening on. - **Notifications section redundancy.** Three near-duplicate status surfaces collapsed onto one. - **Insights coach-rail labels.** Promoted to semantic `

` headings so the accessible-name tree resolves consistently. - **Heading hierarchy on three surfaces.** Repaired non-sequential `h1 → h3` jumps so the accessible-name tree resolves with the expected nesting. - **Icon-only buttons on the medications page.** Every icon button gained an accessible name; same sweep on the admin feedback inbox and reminders surface. - **Progress-bar accessibility.** Every `` instance now carries an accessible name so a screen-reader pass reads the metric label rather than a bare percentage. - **Button loader CLS.** Buttons now reserve space for the in-flight loader so the surrounding layout doesn't shift when a request lands. - **Mobile Coach FAB occlusion.** FAB auto-hides while any chart tooltip is open so it cannot cover the read-out. - **Settings mobile section strip.** Scroll-snap on the horizontally scrollable strip so the active pill always lands on the leading edge. - **Passkey breakpoint.** Passkey card no longer clips its internal grid on the tablet breakpoint. - **Settings tile padding parity.** Every settings tile reads `p-4 md:p-6` after the card-default normalisation. - **Sidebar "Home" group label.** Redundant collapsible label dropped from the desktop sidebar; the four nav entries now live at the top level. - **Legal-page narrow column convention.** Pinned in code comments so a future refactor doesn't widen the privacy / imprint / terms pages back out. ### Performance - **Coach snapshot builder LRU cache.** 60 s TTL keyed on `(userId, scope)` keeps repeat Coach drawer opens within a short window off the snapshot-build path. - **Assistant flags memoised per request.** Resolver lookup was re-running per surface mount on the Insights cold path. - **Web-vitals sample rate dropped to 10%.** Stops the beacon from self-throttling under high-traffic load. ### Refactor - **`Math.min` / `Math.max` spread folds.** Six per-metric status helpers now reduce instead of spread, retiring the latent stack overflow path that surfaced as the P0 above. - **Scroll-reset consolidated.** Seven per-page `useEffect` duplicates retired behind `useScrollResetOnRoute()`. - **Settings card header consolidated.** Three near-duplicate header markups retired behind ``. ### Accessibility - **Icon-only buttons named** on the medications page + admin feedback inbox + admin reminders surface. - **Progress bars named** across the app so the accessible-name tree exposes the metric label. - **Heading order repaired** on three surfaces so the accessible-name tree nests correctly. - **Coach rail labels** promoted to semantic `

` headings. ### Internal - **Retired** the now-orphan `settings.kiInsights` locale key after the Auswertungen section rename, the `AssistantDisabledNotice` component, and the dead `settings.placeholder` locale entries left behind by earlier releases. - **Test fixtures** aligned with the renamed Insights / Notifications / Settings surfaces; the import-string scan on the Insights mother page now accepts both eager and `next/dynamic` spellings. ## [1.4.32] — 2026-05-17 — HealthKit Tier 1 wave A First public surface wave for the HealthKit Tier 1 metrics that the iOS contributor brief locked in for v1.5. The headline item is a workouts flow that lands end-to-end on the web: a list page at `/insights/workouts`, a detail page with route preview and summary stats, and a dashboard tile that surfaces the three most recent sessions. Alongside the workouts surface arrives a family of five new metric sub-pages — HRV, resting heart rate, blood oxygen, body temperature, and active energy — each carried by a shared scaffold so adding the next HealthKit metric is a four-line page module. The release also cleans up two latent issues uncovered during the wave-A audit: the workouts list endpoint had a Prisma field-name bug that would have produced a 500 the moment a real client called it, and HRV plus resting heart rate were sitting in the `vitals` insight bucket where they did not belong. ### Added - **Workouts API.** `GET /api/workouts` returns a paginated list shaped to the iOS contract (`distanceM`, `activeEnergyKcal`, `avgHr`, `maxHr`) using the v1.4.30 canonical-row picker; `GET /api/workouts/{id}` returns a single workout with the same field shape. Both endpoints honour `requireAuth()` and respect the existing per-user isolation guarantees. - **`/insights/workouts` list page.** Browsable workout archive with newest-first ordering, type-filter pills, and an Apple-Health-onboarding hint when the list is empty. - **`/insights/workouts/[id]` detail page.** Per-workout view with summary tiles (duration, distance, energy, average and max heart rate), an inline-SVG route preview when GPS samples are present, and a graceful-unavailable notice for per-second HR samples. - **Dashboard recent-workouts tile.** New widget id `recentWorkouts` defaults to visible and surfaces the three most recent sessions on the home page. Self-gates on a non-empty list and renders an Apple-Health-onboarding hint otherwise. - **Five HealthKit metric sub-pages.** New pages at `/insights/hrv`, `/insights/ruhepuls`, `/insights/sauerstoffsaettigung`, `/insights/koerpertemperatur`, and `/insights/aktive-energie`, each rendered through the shared `` scaffold with its own chart-cog popover state slot. Body temperature surfaces the existing manual-entry CTA; the other four intentionally render their empty state without a primary action because the Apple Health / Withings ingest is the only path for those metrics. - **Insights tab-strip pills.** Six new pills (workouts plus the five HealthKit metrics) join the strip and self-gate on data presence, so brand-new accounts still see the seven pre-existing pills only. ### Changed - **HRV + resting HR realigned to the cardiovascular bucket.** Both metrics moved out of `vitals` into `cardiovascular` to match the iOS handoff brief's category table. The insight bucket map, the per-metric status card grouping, and the comprehensive narration all read the new placement; no client contract changes because the bucket only drives in-app grouping. ### Fixed - **Latent 500 on workouts list endpoint.** The list query referenced two non-existent Prisma columns (`distanceMeters` and `energyKcal`); tests mocked Prisma with `as never` so the bug never surfaced in CI. The endpoint now uses the v1.4.30 `pickCanonicalWorkoutRows()` helper and projects the iOS contract field shape end-to-end. ## [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 `