diff --git a/.planning/round-v1433-audit-menu.md b/.planning/round-v1433-audit-menu.md new file mode 100644 index 000000000..f5c307575 --- /dev/null +++ b/.planning/round-v1433-audit-menu.md @@ -0,0 +1,184 @@ +# v1.4.33 menu-structure audit + +Read-only audit of every navigation surface in the web app. Goal: find the duplicates and overstuffed strips that show up when you actually count items, plus the few stale entries that should have followed retired features out the door. + +## 1. Executive summary + +Overall the navigation model is healthy but it is undeniably overstuffed on three surfaces and carries one true duplicate. + +- **One genuine duplicate.** `Notifications` exists twice in the user-menu dropdowns — once as the `/notifications` inbox/preferences page (linked from both the sidebar user-card menu and the mobile top-bar dropdown) and once as a Settings section at `/settings/notifications` (channel cards: Telegram, ntfy, Web Push, plus the Notification status panel). The two destinations do not share content and both names read as "Notifications" in copy. This is the most visible duplicate in the product today. +- **Three overstuffed surfaces.** + - **Settings sidebar — 11 sections.** That is one above the "seven plus or minus two" comfort limit and the strip now scrolls horizontally on a Pixel-5 width, which the maintainer wired the `no-scrollbar` shim around. Several adjacent sections (Sources next to Thresholds, Export as a one-page section, Advanced as a two-card section) could be merged or absorbed. + - **Insights tab strip — twelve pills.** Overview plus eleven metric tabs (Blutdruck, Gewicht, Puls, Stimmung, Medikamente, BMI, Schlaf, Workouts, HRV, Ruhepuls, Sauerstoff, Körpertemperatur, Aktive Energie). The right-edge fade is hiding the fact that on a phone the user can only see two-and-a-half pills before scrolling. The wave-A HealthKit additions in v1.4.32 doubled the count without grouping. + - **App sidebar — seven primary + Bug Report + Admin + Settings + user card.** The home label is the German `Home` translation but the icon links to `/` which we also call `Dashboard` in `nav.dashboard`. The two labels share one destination and one icon. The mobile bottom-nav is honest about it (5 primary + More); the desktop sidebar is not. +- **Retired tile leftovers.** Per v1.4.28 retirement of GLP-1 dashboard tile + `InsightAdvisorCard` + the weekly-report flow, two artefacts are still around: the `assistant-disabled-notice.tsx` component (orphan — zero render sites in source today, only a feature-flag comment references it) and the still-shipped `glp1-plateau` finding type wired into Daily Briefing icons + routing, even though the `glp1` widget id has been filtered out of `DASHBOARD_WIDGET_IDS` on read. These are not menu items but they're "menu-adjacent" surfaces (icons in the briefing, routes that survive the legacy layout filter). + +The fix is mostly grouping and renaming, not deletion. The actual code-removal scope is small. + +## 2. Settings nav inventory + +Source of truth: `SETTINGS_SECTIONS` in `src/components/settings/settings-shell.tsx` and `SETTINGS_SECTION_SLUGS` in `src/components/settings/section-slugs.ts`. Eleven slugs, in this order: + +1. **Account** (`/settings/account`) — `` ships four card-style subsections: + - Profile (username, email, gender, height, date-of-birth, language, timezone) + - Passkeys (list + Add) + - Password reset (button-only card opening a Dialog) + - Onboarding tour replay (button-only card) +2. **Integrations** (`/settings/integrations`) — Withings + moodLog cards. Conditional on moodLog feature flag. +3. **Notifications** (`/settings/notifications`) — Notification status card + Telegram + ntfy + Web Push. Each conditional on the global-services availability envelope. +4. **Dashboard** (`/settings/dashboard`) — single `` card holding comparison-baseline picker + the widget toggle table. +5. **Thresholds** (`/settings/thresholds`) — single `` card. +6. **Sources** (`/settings/sources`) — source-priority editor card + per-metric override list + device-type ordering. +7. **AI auswertungen** (`/settings/ai`) — single dropdown-driven `` (provider Select → matching config form → fallback chain → runtime actions row). +8. **API & Tokens** (`/settings/api`) — API endpoints catalogue card + API tokens list/create card. +9. **Export** (`/settings/export`) — five export cards in a 2-col grid (Doctor report PDF, Measurements CSV, Medications CSV, Mood CSV, Full Backup JSON). +10. **Advanced** (`/settings/advanced`) — Research-mode toggle card + Data-reset (danger zone) card. +11. **About** (`/settings/about`) — Identity card + Links card + Updates check card. + +### Drift findings + +- **Section count is one over the comfort line.** Eleven scrollable items on the mobile chip strip is already pushing the no-scrollbar shim. The maintainer's directive ("die Menüs dürfen nicht überfrachtet sein") reads cleanly on this surface. +- **Sources sits weirdly alone.** "Quellen-Priorität pro Metrik" is conceptually the same shelf as Thresholds — both are per-metric configuration that the user sets once and rarely revisits. Merging Sources into Thresholds (or into Dashboard as a "Per-metric data" tab) would drop one section without losing functionality. +- **Advanced is two unrelated things.** Research-Modus (opt-in chart enhancement, BookOpenCheck icon, purple) sits in the same section as "Daten löschen" (destructive nuclear button, red, AlertTriangle). They share a section heading but nothing else. Research-Modus is a feature toggle that fits more naturally under Dashboard (it changes chart rendering) or under AI auswertungen (it is research-grade behaviour). The danger zone alone justifies the section name "Advanced"; pulling Research-Modus out would let "Advanced" rename to "Daten-Verwaltung" or "Gefahrenbereich". +- **API & Tokens reads as developer-facing**, but it shows up between AI auswertungen and Export. The two endpoint rows in the catalogue (`POST /api/ingest/medication`) plus the bearer-token list could move under Integrations as an "External access" tab, since both Withings and an API token are "how external systems talk to my data." +- **Export is one screen.** Five cards in a 2-col grid is fine, but it earns a top-level slot that the user only visits once or twice a month. Folding it into Account → Profile actions (one more card alongside the Tour replay) or into Advanced is defensible. +- **About is three small cards.** Version + Links + Update check. The whole section fits in 250 vertical px on desktop. Folding About into the sidebar bottom-strip (next to Settings, with an Info icon) avoids spending a top-level slot on a screen the user reads once. There is precedent: the `/about` route already exists as a public page (`PUBLIC_PATHS` in `auth-shell.tsx` includes it). +- **No subsection duplicates within sections.** Card headings inside each section are unique; the audit caught the lazy "Settings → AI → KI-Insights" naming where the section title is "KI-Auswertungen" but the inner card title is "KI-Insights" — those are the same concept twice in two breaths and read as a duplicate even though they are technically section + card. + +### Hidden / placeholder + +- `` is still wired into the dynamic route as a defensive fallback for slugs added to `SETTINGS_SECTION_SLUGS` without a matching component, but every current slug has a component. Locale key `settings.sections.placeholder.coming_soon` ("Wird mit dem nächsten Release-Update hierher umziehen") is dead copy — no live surface renders it. Safe to keep as a guard, but the locale string is reachable only via a code-path that the type system blocks today. + +## 3. App header / sidebar + +Source of truth: `src/components/layout/sidebar-nav.tsx` (desktop sidebar, `navItems` array) and `src/components/layout/top-bar.tsx` (mobile-only auth + theme dropdown). + +### Desktop sidebar inventory + +Top half — `navItems` group under the "Home" section label: +1. Dashboard (`/`, Home icon, `nav.dashboard`) +2. Messungen (`/measurements`, Activity icon, `nav.measurements`) +3. Stimmung (`/mood`, Waves icon, `nav.mood`) +4. Medikamente (`/medications`, Pill icon, `nav.medications`) +5. Insights (`/insights`, Lightbulb icon, `nav.insights`) +6. Zielwerte (`/targets`, Target icon, `nav.targets`) +7. Erfolge (`/achievements`, Trophy icon, `nav.achievements`) + +Bottom strip — utility links: +8. Bug Report (`/bugreport`, Bug icon, gated on `bugReportEnabled` feature flag) +9. Admin (`/admin`, Shield icon, role-gated `ADMIN`) +10. Einstellungen (`/settings/account`, Settings icon) + +User card — Avatar + username + ChevronMore opens dropdown: +- Benachrichtigungen (`/notifications`, Bell icon) +- Darstellung submenu (System / Dark / Light) +- Abmelden + +### Findings + +- **"Home" label vs Dashboard.** The section title above `navItems` reads "Home" (`nav.home`), the first entry then reads "Dashboard" (`nav.dashboard`). Both point to `/`. Most apps either drop the group label entirely (the sidebar has only one group anyway) or rename the group to "Main" and let the first item own "Dashboard". The current phrasing reads as if there are two destinations for the homepage. +- **Theme submenu is the weakest "user" entry.** Three radio-style theme options sit inside a submenu inside a dropdown inside a user card. A Settings → Account "Display" toggle (single row) would surface the theme one fewer click away and let the user-card dropdown shrink to Notifications + Logout. +- **Bug Report is a primary destination.** It sits next to Admin as a sidebar bottom-strip link, which is unusual — most apps surface bug reporting through a help menu or a corner action. It is feature-flag-gated (`bugReportEnabled`) so on most deployments it disappears, but on dev/internal deploys it occupies a sidebar slot. Worth considering moving into the user-card dropdown alongside Logout. +- **No duplicate routes inside the sidebar itself.** `/notifications` (in dropdown) vs `/settings/notifications` (Settings section) is the cross-surface duplicate called out in the executive summary. + +### Mobile top-bar findings + +The mobile top-bar dropdown duplicates four items from the sidebar user-card dropdown (Settings / Benachrichtigungen / Theme submenu / Logout). That's the intended parity per the auth-shell split (desktop puts the user controls in the sidebar, mobile in the top-bar) — not a bug, just a maintenance note: any rename has to land in both files. + +## 4. Insights tab strip + +Source of truth: `SUB_PAGE_TABS` in `src/components/insights/insights-tab-strip.tsx`, `SUB_PAGE_METRIC` in `src/lib/insights/sub-page-metric.ts`, and `buildTabs()` for the gating logic. + +### Inventory (in order) + +1. Overview (`/insights`) +2. Blutdruck (`/insights/blutdruck`) +3. Gewicht (`/insights/gewicht`) +4. Puls (`/insights/puls`) +5. Stimmung (`/insights/stimmung`) +6. Medikamente (`/insights/medikamente`) +7. BMI (`/insights/bmi`) +8. Schlaf (`/insights/schlaf`) +9. Workouts (`/insights/workouts`) +10. HRV (`/insights/hrv`) +11. Ruhepuls (`/insights/ruhepuls`) +12. Sauerstoff (`/insights/sauerstoff`) +13. Körpertemperatur (`/insights/koerpertemperatur`) +14. Aktive Energie (`/insights/aktive-energie`) + +Plus the right-edge Regenerate icon button on the mother page only. + +### Findings + +- **Fourteen pills is a lot.** The data-driven gating (`hasMetricData(metric, availability)`) hides empty pills, so a fresh account sees fewer. A power user with Withings + Apple Health connected will see every pill. The maintainer's own setup ships every pill. On ``. Renders all 11 settings sections as a horizontally scrollable chip strip with the `no-scrollbar` shim. Same items as desktop sidebar. Not diverging. + +### Findings + +- **Bottom-nav is correct.** Five primary + More is the WCAG 2.5.5-compliant split the maintainer landed in v1.4.16 and re-confirmed through v1.4.22; no overstuffing here. +- **More sheet is anaemic.** Two entries (Zielwerte + Erfolge) is enough to justify a sheet but feels light next to a five-tab primary. If the desktop sidebar gets pruned (Bug Report → user card, About → user card) the mobile More sheet will not gain those items because they live in `top-bar.tsx`'s mobile dropdown, not in bottom-nav. The two surfaces should keep parity on which items live where. +- **No mobile-only nav items.** Every mobile destination is reachable from desktop. No orphan routes. + +## 6. Orphan / retired items still in code + +The v1.4.28 retirement list (GLP-1 dashboard tile, `InsightAdvisorCard`, weekly-report) left a few menu-adjacent artefacts: + +- **`AssistantDisabledNotice` (`src/components/insights/assistant-disabled-notice.tsx`).** Component still exported. Zero JSX render sites in source code (only a doc-comment in `src/lib/api-handler.ts` and a doc-comment in `src/lib/feature-flags/index.ts` reference it). It was introduced in v1.4.31 for the operator-disabled assistant surface, but no caller mounts it today. Either wire it where the Coach / Briefing / Insight Status surfaces handle the disabled state (a one-line render) or drop the file. Not strictly a menu item but a navigation-adjacent empty state. +- **`glp1` dashboard widget id.** `resolveDashboardLayout()` filters this id out of `DASHBOARD_WIDGET_IDS` on read, so a legacy layout that still carries it gets cleaned silently. Good defence; no menu surface exposes it. The Settings → Dashboard widget toggle table is built from `DASHBOARD_WIDGET_IDS` directly, so the retired tile is invisible there. +- **`glp1_plateau` finding type.** Still wired in `daily-briefing.tsx` (`FINDING_TYPE_HREF` + `FINDING_TYPE_ICON`) and routes the user to `/insights/medikamente`. The medication sub-page itself still mounts `` and the `/api/insights/glp1-timeline` route. None of this is a menu entry, but the v1.4.28 retirement was about the dashboard tile + advisor + weekly-report; the GLP-1 medication-tracking surfaces in Insights → Medikamente are intentionally still alive. Worth a one-line confirmation in the release brief so a future audit doesn't accidentally pull them. +- **`/settings/[section]/SectionPlaceholder` empty-state.** Unreachable today because every slug in `SETTINGS_SECTION_SLUGS` has a wired component. The defensive code stays; the locale key is dead. +- **`InsightStatusCard` is alive.** It mounts on five sub-pages (`bmi`, `blutdruck`, `gewicht`, `puls`, `stimmung`) and on `medikamente`. Not retired. +- **`InsightAdvisorCard` is gone.** No source-tree references outside doc comments. The mother page now renders `` + `` + `` + `` and the advisor data flows into the hero rather than a dedicated card. + +## 7. Consolidated recommendation + +Three buckets: things to merge, things to rename, things to verify. + +### Merge / regroup + +1. **Insights tab strip — group the five wave-A HealthKit pills under "Apple Health".** Single pill that opens an indexed sub-page with internal tabs (HRV / Ruhepuls / Sauerstoff / Körpertemperatur / Aktive Energie). Drops four pills from the strip. Keeps each metric reachable. +2. **Insights tab strip — fold BMI into Gewicht.** The BMI sub-page is essentially the Gewicht sub-page with a different chart projection. Add a chart-cog toggle on the Gewicht page; drop the BMI pill. +3. **Settings → Sources merges into Thresholds.** Both are per-metric configuration screens. "Zielwerte & Quellen" reads cleanly as a combined section. Drops one settings slot. +4. **Settings → About folds into the sidebar user-card dropdown.** Single entry "Über HealthLog" opens the existing `/settings/about` route or a Sheet variant. Drops one settings slot. +5. **Settings → Advanced renamed to "Daten-Verwaltung"** with Research-Modus moved up into AI auswertungen or Dashboard (it changes chart rendering). Drops the conceptual mismatch of two unrelated cards sharing a section. +6. **User-card dropdown — promote `/notifications` to its own primary destination.** Either keep it in the user-card dropdown OR rename the Settings → Notifications section to "Benachrichtigungs-Kanäle" so the inbox vs configuration split reads cleanly. + +### Rename + +1. **`nav.home` group label.** Either drop it (the sidebar has one group) or rename to "Main". The current phrasing makes "Home" and "Dashboard" read as two destinations for the same route. +2. **AI section card heading.** Settings → AI uses section title "KI-Auswertungen" and card title "KI-Insights" inside it. Pick one. Recommendation: drop the card title (the section already has a heading + description; an inner H2 of the same concept is dead weight). +3. **Notifications double naming.** If both pages stay, rename `/notifications` to "Benachrichtigungs-Einstellungen" or fold it into Settings → Notifications. The `/notifications` page is a preferences-by-event-type matrix; Settings → Notifications is a per-channel configuration. The split exists but the names should reflect it. + +### Verify (small dead-code sweep) + +1. **Drop or wire `AssistantDisabledNotice`.** If the Coach / Briefing / Insight Status surfaces should show the operator-disabled empty state, the component needs render sites. If not, the file goes. +2. **Confirm GLP-1 finding-type routing.** The `glp1_plateau` finding in Daily Briefing routes the user to `/insights/medikamente`. v1.4.28 retired the dashboard tile + advisor + weekly-report but kept GLP-1 medication tracking; the briefing routing is intentional. Mention this in the release brief so a future menu audit doesn't pull it as orphan. +3. **Remove `settings.sections.placeholder.coming_soon` locale key.** Unreachable. The `` guard can keep its EmptyState by inlining a generic "coming soon" string or by inheriting the section title only. + +### Scope estimate for v1.4.33 + +The merge / regroup items are user-facing UX decisions that need a single design pass; the rename items are one-line text changes; the verify items are five-minute deletions. Suggested split: + +- **In v1.4.33 (polish-and-reliability):** rename items (one-line strings) + verify items (delete dead code) + the user-card / `/notifications` clarification. Low risk, high readability gain. +- **Defer to v1.4.34 or v1.5:** the Insights tab-strip regrouping and the Sources / Thresholds merger. These are real UX changes that need a screenshot review with the maintainer before shipping. + +The overstuffing the maintainer felt is real. The duplicate-Notifications loop is the one item every user with a sidebar open right now is double-clicking on. That fix alone earns the patch its name. diff --git a/.planning/round-v1433-audit-perf.md b/.planning/round-v1433-audit-perf.md new file mode 100644 index 000000000..73ed2592e --- /dev/null +++ b/.planning/round-v1433-audit-perf.md @@ -0,0 +1,328 @@ +# v1.4.33 — Performance Audit + +Scope: chart load, route navigation, Coach drawer. Production v1.4.32. +Maintainer report: charts no longer "blazing fast", next-item clicks +"unerträglich langsam", Coach drawer page "blockt komplett". +Read-only diagnosis against `develop` head. + +--- + +## 1. Executive summary — top 3 wins ranked by ROI + +| # | Win | Severity | Effort | ROI | +|---|---|---|---|---| +| 1 | Fix the `/api/analytics` queryKey/contract collision between dashboard, mother insights, sub-pages, advisor consumers, hero strip, layout shell, and the gating-helper hook. Today 7 mount sites share the literal key `["analytics"]` against one heavy route that resolves 30 chunked reads + 4 BP/correlation/health-score sub-queries each call. Two of those mount sites (`src/app/page.tsx`, `src/hooks/use-insights-analytics.ts`) declare a `staleTime`; four others (`src/app/insights/page.tsx`, `src/components/insights/sleep-overview.tsx`, `src/components/insights/insights-layout-shell.tsx`, `src/components/onboarding/getting-started-checklist.tsx`) do not. TanStack treats the first-mount wins → so the first consumer's options govern the cache cell, but every route-change re-mount sets up a new subscription tree and any consumer without `enabled: isAuthenticated` retriggers the network on a back-forward swing. Add `staleTime: 60_000`, `gcTime: 5 * 60_000`, and `refetchOnMount: false` on every consumer through a shared `useAnalyticsQuery()` helper. | Critical | S (1 file new, 7 sites edited) | Highest | +| 2 | Decompose `/api/analytics` into a fast `dashboardSummaries` slice and a thick `correlations + healthScore + bpInTarget + sleepStages` slice. The current single endpoint blocks first paint behind ~40 Postgres round-trips, every one of which is `fetchMeasurementSeriesChunked()` doing `take: 5000` cursor paging. Splitting lets `/api/analytics?slice=summaries` return the 30 per-type summaries in one or two SQL passes (`groupBy` for count/min/max/sum + a second filtered call for `slope7/30/90` window means). The thick endpoint runs separately under a longer stale window because nothing on the visible dashboard tile strip needs `correlations` or `healthScore` to paint. This IS the R-B C1 lift; the verdict in §2 below is to pull it forward into v1.4.33 because the symptoms point at it. | Critical | M (2–3 days) | High | +| 3 | Remove the cold-mount cost of `/api/insights/comprehensive` from every Insights navigation. Today `InsightsLayoutShell` issues this fetch unconditionally on every routed sub-page, and the call resolves a 90-day measurement window across 30 types, then runs four pairwise Pearson correlations + medication compliance + BP × continuity. The shell only consumes `moodSummary.count` and `medications.length` for the tab-strip availability gate. Replace the fetch with a tiny `/api/insights/availability` endpoint returning `{ hasMood, hasMedication, hasWorkouts }` (3 `COUNT(*)` queries). The mother page keeps its existing `/api/insights/comprehensive` consumer; only the layout shell drops it. | High | S (1 new route + 1 hook switch) | High | + +--- + +## 2. R-B C1 verdict — **PULL-IN, with a scoped subset for v1.4.33** + +The maintainer's three symptoms triangulate to one root cause: `/api/analytics` +is on the critical path for first paint of `src/app/page.tsx`, +`src/app/insights/page.tsx`, every routed sub-page under `src/app/insights/*`, +the Insights layout shell, and the dashboard layout query — and the route +issues ~40 Postgres round-trips per call. + +### Symptom-to-cause mapping + +- **Chart slowness**: a chart on `/insights/blutdruck` paints when both + (a) the chart's own `/api/measurements?type=…&aggregate=daily` resolves AND + (b) the page's `useInsightsAnalytics()` resolves the `["analytics"]` key for + its empty-state gate. (b) is the long tail. +- **Next-item slowness**: route transitions inside `/insights/*` go through + `InsightsLayoutShell` which mounts an `["analytics"]` consumer AND an + `["insights","comprehensive"]` consumer. The shell never declares + `staleTime` on the comprehensive one (`src/components/insights/insights-layout-shell.tsx:62-71`) + so React Query's `defaultOptions.staleTime: 5 * 60 * 1000` from + `src/components/providers.tsx:117` applies — that's fine in theory, BUT the + `InsightsTabStripImpl` derives `availability` from BOTH queries and the strip + is memo'd with `useMemo` on those fields (`insights-layout-shell.tsx:86-94`). + When either query resolves a microtask after the other, the strip re-renders + twice on every route change. +- **Coach drawer hang**: see §3.3 below — the Coach SSE path on the server is + not the bottleneck. The blocking happens before the first frame ever lands + because `requireAssistantSurface` on `/api/insights/chat` and + `/api/insights/comprehensive` BOTH gate on a DB hit on `AppSettings` (no + in-process cache visible from the route), and the drawer's `useCoachConversations()` + rail call (`src/components/insights/coach-panel/use-coach.ts:51-73`) loads + the rail on mount with `staleTime: 30 * 1000` — fine on its own, but the + drawer's own `useCoachPrefs({ enabled: open })` (`coach-drawer.tsx:197`) + AND the `useFeatureFlags()` call in `layout-coach-mount.tsx` AND the + comprehensive fetch already streaming for the page produce 5+ in-flight + fetches the moment the drawer opens. The previous patches landed in v1.4.31 + (`next/dynamic` on the drawer, `AbortController` + 8 s timeout on the advisor + POST, memo + useMemo on the tab strip) only addressed the client-render + cost, not the server-side gate cascade. + +### Pull-in scope for v1.4.33 + +| Item | Cost | Risk | +|---|---|---| +| New `src/app/api/analytics/route.ts` → `dashboardSummaries` slice. Two SQL passes: (1) `prisma.measurement.groupBy({ by: ["type"], _avg, _count, _max, _min })` over the trailing 90 days for every type; (2) `prisma.$queryRaw` window-mean for `avg7` / `avg30` / `slope` per type via Postgres `regr_slope`. Drops 30 chunked reads → 2. | M (~6h) | Medium — `summarize()` slope7/30/90 currently runs against the full series; the SQL `regr_slope` returns the same statistic and a Postgres EXPLAIN ought to confirm the index path. The existing per-type chunked walks still exist for `bpInTargetWindows` + correlations, so the slope semantic is the only thing under review. | +| New `src/app/api/insights/availability/route.ts`. Three `COUNT(*)` queries on `Measurement`, `MoodEntry`, `Medication`. Layout shell consumes this instead of `/api/insights/comprehensive`. | S (~1.5h) | Low — net-new endpoint, no contract break. | +| Shared `useAnalyticsQuery()` hook in `src/hooks/use-analytics-query.ts` collapsing the 7 mount sites onto one definition with `staleTime: 60_000`, `refetchOnMount: false`. | S (~2h) | Low — type-checked surgery. | +| `/api/analytics` thick slice (`?slice=full` or default) keeps its current shape. Gate it behind a hover prefetch on the Coach drawer + the correlation row's `` boundary. | S (~1h) | Low. | +| Drop the duplicate `["analytics"]` literal across `src/app/insights/page.tsx`, `src/components/insights/sleep-overview.tsx`, `src/components/onboarding/getting-started-checklist.tsx`. Switch to `queryKeys.analytics()`. | S (~1h) | Low. | + +**Total effort**: 1.5 days of focused work. Fits inside v1.4.33's +"polishing patch" envelope; defer the more exotic correlation-side SQL +rewrites to v1.4.34 or v1.5.x. + +**Sequence**: A1 queryKey collapse → A2 availability endpoint → A3 dashboard +slice rewrite → A4 prefetch wiring. A1 is safe to ship alone if the +slice rewrite uncovers an issue under load. + +**Why not defer to v1.5.x**: the strategic-plan §4 row "Lands in v1.5.x if +not folded into Day 1-2 server prep" pre-dated the maintainer's +"alles träge geworden" complaint. Three symptom vectors converge on this +endpoint; deferring it past v1.4.33 means shipping another polishing +patch that the user perceives as slow. + +--- + +## 3. Per-symptom diagnosis + +### 3.1 Chart load slowness + +**Where time goes**: + +1. **`/api/analytics` parallel fan-out** — `src/app/api/analytics/route.ts:61-116`. + 30 `Promise.all`'d `fetchMeasurementSeriesChunked()` calls. For a user + with multi-year HealthKit history each cumulative type pages through + `MEASUREMENT_CHUNK_SIZE = 5000` rows; pulse paging is the dominant cost. + The chunked walk is then handed to `summarize()` which sorts the full + array twice (once in `summarize`, once in `trendSlope` per window) and + filters three additional time slices for `slope7/30/90`. Reading + 30 × N rows when 30 daily aggregates would suffice. + +2. **Same key, different consumers** — `src/app/insights/page.tsx:138` + declares `queryKey: ["analytics"]` with no `staleTime`; the layout shell + above it (`src/components/insights/insights-layout-shell.tsx:48-57`) + declares the same key with `staleTime: 60_000`. The shell's mount-order + is earlier in the render tree, so its options win — but the mother + page's consumer still establishes a fresh subscription, and any + downstream consumer that lacks `enabled: isAuthenticated` re-issues + the network on a back-navigation swing. `src/hooks/use-insights-analytics.ts:57-67` + does declare `staleTime: 60_000`, so the sub-pages are OK; the mother + page is not. + +3. **Chart fetches are good**, paradoxically. The C3 fix from v1.4.29 + landed; `aggregate=daily` is wired on windows > 7 days + (`src/components/charts/health-chart.tsx:588-590`). The chart's own + `useQuery` has `staleTime: 60_000` + `gcTime: 5 * 60_000` + (`health-chart.tsx:563-564`). The slow piece is the gating fetch that + runs alongside the chart fetch, not the chart fetch itself. + +**File:line references**: +- `src/app/api/analytics/route.ts:46-116` (30-type fan-out) +- `src/app/api/analytics/route.ts:194-201` (BP-in-target 2 more chunked walks) +- `src/app/api/analytics/route.ts:391-403` (correlation 5 reads) +- `src/app/api/analytics/route.ts:746-791` (health-score 4 more reads) +- `src/lib/analytics/trends.ts:171-247` (summarize sorts + 4 window filters per metric) + +**Proposed fix**: split the route per §2. The dashboard `summaries` +slice resolves with 2 SQL passes; the thick slice resolves lazily. +Charts paint when their own slim fetch resolves — no longer gated on +the dashboard summaries' tail. + +### 3.2 Navigation slowness + +**Where time goes**: + +1. **`InsightsLayoutShell` triple-fetch on every routed mount** — + `src/components/insights/insights-layout-shell.tsx:42-95`. The shell + mounts `useInsightsAdvisorQuery`, `["analytics"]`, `["insights", "comprehensive"]`, + and `useWorkouts({ limit: 1 })`. Three of the four issue network calls + on cold cache; the comprehensive call is the heaviest. The shell lives + in `src/app/insights/layout.tsx` so it persists across sub-page routes, + but a hard refresh or a back-then-forward navigation tears the layout + down and rebuilds it — the four queries refire. + +2. **`availability` prop recompute** — the shell's `useMemo` at + `insights-layout-shell.tsx:91-94` is keyed on `(isAuthenticated, summaries, + hasMood, hasMedication, hasWorkouts)`. The first three derive from + `analyticsQuery.data?.summaries` and `comprehensiveQuery.data?.moodSummary.count`, + neither of which has a stable reference even after the data lands — + every cache write produces a new object. The memo therefore recomputes + on every cache write while the queries are settling. The `React.memo` + on `InsightsTabStripImpl` (`insights-tab-strip.tsx:284`) only catches + identical-reference props; the recomputed `availability` cascades + through it. + +3. **The hero strip + briefing + correlation row + trends row all live on + the mother page**, NOT the layout. Navigating from `/insights` to + `/insights/blutdruck` unmounts every component except the strip; the + sub-page then mounts its own `useInsightsAnalytics()` consumer (cache + hit, fast) AND its own `useInsightStatus(metric)` consumer (cold, + fires `/api/insights/blood-pressure-status` etc.). Each status endpoint + does its own assistant-surface gate + provider chain check. + +4. **Coach drawer staying mounted** — confirmed not the source of + navigation slowness, per `src/app/insights/layout.tsx:31-39`. The + drawer mounts via `next/dynamic` and is only torn down when the entire + layout unmounts. + +**File:line references**: +- `src/components/insights/insights-layout-shell.tsx:42-95` +- `src/components/insights/use-insights-advisor.ts:149-160` +- `src/hooks/use-feature-flags.ts:79-90` (every Insights page reads this; soft-fail OK) +- `src/app/api/insights/*-status/route.ts` (per-metric status routes; ~6 endpoints) + +**Proposed fix**: +1. Replace `comprehensiveQuery` in the layout shell with a 3-column + `/api/insights/availability` endpoint that returns `{ hasMood, hasMedication, + hasWorkouts }`. Three `COUNT(*)` queries vs the current 90-day fan-out + over 30 measurement types + mood + medications + intake events. +2. Stabilise the `availability` prop reference — the underlying scalars + can drive `useMemo` directly. Drop `summaries` from the dep array if + the helper `hasMetricData()` only reads `summaries[metric].count`; pre-derive + a `metricsWithData: Set` and memo on the set's + serialised shape instead. +3. Prefetch the next-likely sub-page's `useInsightStatus` on hover/focus + of the tab strip pill so the click→paint window is dominated by the + route transition, not the network. Wire via `queryClient.prefetchQuery` + inside the `` `onMouseEnter` handler (use `next/link` `prefetch` + for the route bundle; React-Query for the data). + +### 3.3 Coach drawer hang + +**What happens on first Coach navigation**: + +1. **Drawer mounts but no fetches fire yet** — `next/dynamic` boundary + in `src/components/insights/layout-coach-mount.tsx:18-24` defers the + subtree until the user opens the drawer. The mount itself is cheap. + +2. **Drawer opens — five fetches kick off in parallel**: + - `useCoachConversations()` → `GET /api/insights/chat` (rail list). + Gates on `requireAssistantSurface("coach")` server-side. + `src/components/insights/coach-panel/use-coach.ts:51-73`. + - `useCoachPrefs({ enabled: open })` → `GET /api/auth/me/coach-prefs`. + `coach-drawer.tsx:197`. + - `useFeatureFlags()` → `GET /api/feature-flags` if the cache is cold. + `layout-coach-mount.tsx:38`. + - `useCoachConversation(id)` does NOT fire until the user picks a row + in the rail — fine. + - The mother page's existing `/api/insights/comprehensive` consumer + may still be in-flight if the user opened the drawer within ~1 s + of landing on `/insights`. + +3. **Server-side gate cascade**: `requireAssistantSurface("coach")` is + imported from `@/lib/feature-flags`. Every Coach + insights route + that's gated hits the same `AppSettings` row. If the implementation + reads the row per call (no in-process or per-request memo), the + gate is fast but not free; with five fetches kicking off + simultaneously the connection pool sees 5 sequential `SELECT` against + the same row. + +4. **First message send** — `useSendCoachMessage.send()` POSTs to + `/api/insights/chat`. The route then: + - `requireAuth()` — DB hit on session. + - `requireAssistantSurface("coach")` — DB hit. + - `enforceBudget(userId)` — DB hit on `CoachUsage`. + - `prisma.user.findUnique({ … coachPrefsJson })` — DB hit. + - `buildCoachSnapshot(userId, scope)` — heaviest call. + `src/lib/ai/coach/snapshot.ts` runs `extractFeatures()` from + `src/lib/insights/features.ts` which itself walks `prisma.measurement.findMany` + across the same 30 types over a 30/90-day window. ~10 DB calls. + - Provider chain runs the LLM call (network, not DB). + +5. **The "blockt komplett" symptom**: the first paint of the drawer + renders the empty-thread state immediately — that part works. The + block is between "user clicks send" and "first SSE `token` frame + arrives". The five fetches in step 2 above plus the snapshot build + in step 4 plus the LLM provider chain itself are all serial within + the request lifecycle. The provider chain is the dominant 2–8 s tail, + but the snapshot build adds 200–800 ms on top. + +**File:line references**: +- `src/components/insights/coach-panel/use-coach.ts:278-493` (send hook) +- `src/app/api/insights/chat/route.ts:129-298` (handler) +- `src/lib/ai/coach/snapshot.ts:1-120` (snapshot builder) +- `src/lib/insights/features.ts:283-720` (feature extractor — 4× findMany) +- `src/lib/feature-flags.ts` (gate read on every gated route) + +**Proposed fix**: +1. **Cache the snapshot per `(userId, scope)` for 60 seconds**. The + snapshot reads only persisted data; within a single conversation a + user sends 2–4 turns in quick succession and the snapshot is + recomputed each time. A simple in-memory LRU keyed on + `${userId}:${scope.window}:${scope.sources.join(",")}` saves ~500 ms + per turn after the first. +2. **Memoise `requireAssistantSurface`** per request. The settings row + doesn't flip mid-request; one read at the start of the request + shared across every gate call. +3. **Defer the rail list fetch** until the user opens the history tray. + The drawer's first paint doesn't need the rail — it shows the empty + thread on the right column. Move `useCoachConversations(open)` to + `useCoachConversations(historyTrayOpen || isDesktopRailVisible)`. The + desktop rail mount needs the data on open; the mobile path can defer. +4. **Pre-warm `/api/feature-flags`** at root layout level so the Coach + open never waits on a cold flag fetch. + +--- + +## 4. Punch list — ordered by ROI + +| Order | Item | Severity | Effort | File scope | Notes | +|---|---|---|---|---|---| +| 1 | Collapse the seven `["analytics"]` mount sites onto a single `useAnalyticsQuery()` hook with `staleTime: 60_000`, `gcTime: 5 * 60_000`, `refetchOnMount: false`. | Critical | S (~2h) | `src/hooks/use-analytics-query.ts` (new); edits to `src/app/page.tsx:205-215`, `src/app/insights/page.tsx:137-146`, `src/components/insights/sleep-overview.tsx:71`, `src/components/insights/insights-layout-shell.tsx:48-57`, `src/components/onboarding/getting-started-checklist.tsx:196`, `src/hooks/use-insights-analytics.ts:57-67` | Pure refactor; no contract change. | +| 2 | Add `src/app/api/insights/availability/route.ts` returning `{ hasMood, hasMedication, hasWorkouts }`. Swap the layout shell's `comprehensiveQuery` for the new hook. | Critical | S (~2h) | new route + `src/components/insights/insights-layout-shell.tsx:62-94` | Drops ~10 DB calls per Insights mount. Net-new endpoint, no breakage. | +| 3 | Pull-in R-B C1 (scoped) — `/api/analytics?slice=summaries` SQL-side. Replace 30-type chunked fan-out with `groupBy` + window-mean SQL. | Critical | M (~6h) | `src/app/api/analytics/route.ts:32-293` | Slope semantic preserved via Postgres `regr_slope` over the per-window filter; integration test pins identity against the existing chunked path output. | +| 4 | Per-request memo of `requireAssistantSurface()`. Single `AppSettings` read shared across every gate inside one request. | High | S (~1h) | `src/lib/feature-flags.ts` | Use the existing `annotate()` request-context pattern as the carrier. | +| 5 | 60-second LRU on `buildCoachSnapshot()` keyed on `(userId, scope)`. | High | S (~2h) | `src/lib/ai/coach/snapshot.ts` | LRU shape: `new Map` with hand-rolled 16-entry cap; the route already lives behind auth so trust-boundary is per-process. | +| 6 | Stabilise `availability` prop in `InsightsLayoutShell`. Derive a `metricsWithData: ReadonlyArray` from `summaries`, memo on the sorted-join string. | High | S (~1h) | `src/components/insights/insights-layout-shell.tsx:86-94`, `src/lib/insights/metric-availability.ts` | Stops the strip re-render cascade on every cache write. | +| 7 | Defer the Coach rail list fetch (`useCoachConversations`) until the history tray opens OR the desktop rail mounts visible. | High | S (~1h) | `src/components/insights/coach-panel/history-rail.tsx`, `src/components/insights/coach-panel/coach-drawer.tsx:438-444, 510-518` | The desktop layout-shell mount needs the data immediately; the mobile path defers. Use `useIsMobile()` to gate. | +| 8 | Replace literal `["analytics"]` invalidation keys with `queryKeys.analytics()` in mutation handlers. | Medium | XS (~30 min) | `src/components/settings/thresholds-editor-section.tsx:90, 110`, `src/components/targets/target-edit-sheet.tsx:219, 251` | Defensive cleanup; the typo class is exactly what `queryKeys` exists to prevent. | +| 9 | Pre-warm `/api/feature-flags` at root layout. Issue the fetch once at mount; every consumer reads from cache. | Medium | XS (~30 min) | `src/components/providers.tsx`, root layout | Soft-fail path stays intact; cache pre-warmed on cold mount. | +| 10 | Add a hover/focus prefetch on `` pills for `useInsightStatus(metric)`. Optional — drops the click→paint window on sub-page transitions. | Medium | S (~1.5h) | `src/components/insights/insights-tab-strip.tsx:201-220` | Use `queryClient.prefetchQuery` inside the `` `onPointerEnter`. | +| 11 | Move `pulseRows` correlation read to a Postgres `LATERAL` pair of `regr_slope` calls instead of the chunked walk. | Low | M (~4h) | `src/app/api/analytics/route.ts:391-518` | Belongs to the v1.4.34 C1 follow-up if v1.4.33 only lands the summaries slice. | +| 12 | Drop `gcTime` defaults at provider level to `15 * 60_000` for the analytics + insights tree. Long inactivity should evict the multi-megabyte payload. | Low | XS (~15 min) | `src/components/providers.tsx:112-124` | Memory hygiene, not perf-critical. | + +--- + +## 5. What was already done in v1.4.31 + v1.4.29 (and why it wasn't enough) + +The strategic plan §2 v1.4.31 ships three client-side Coach fixes per +`.planning/research/v15-insights-blocking-bug.md`: + +1. `AbortController` + 8 s timeout on `fetchAdvisor` — + `src/components/insights/use-insights-advisor.ts:63-90`. +2. `React.memo` + `useMemo` on the tab strip — + `src/components/insights/insights-tab-strip.tsx:284`, + `src/components/insights/insights-layout-shell.tsx:91-94`. +3. `next/dynamic` for `` — + `src/components/insights/layout-coach-mount.tsx:18-24`. + +v1.4.29 shipped `staleTime: 60_000` on the three inline dashboard queries +plus `refetchOnWindowFocus: false` — `src/app/page.tsx:200-203` — +which is good, but those changes only cover the dashboard's own +`useQuery` blocks. The Insights mount tree was never normalised. + +The remaining blocking is server-side: every Coach turn rebuilds the +snapshot, every Insights mount fans out 30 chunked reads through +`/api/analytics`, and every gated route reads `AppSettings` from cold. +The three v1.4.31 fixes addressed the WebKit-tap-gesture mobile path; +the desktop "alles träge" experience is the unaddressed remainder. + +--- + +## 6. Open questions for the maintainer + +1. **Is the `regr_slope` semantic acceptable** for the summaries slice + replacement? Postgres' `regr_slope(value, EXTRACT(EPOCH FROM measured_at))` + returns the slope in value-per-second; the existing `summarize()` + returns value-per-day with an R² confidence. The R² is rendered on + tile callouts but only as a "stable / up / down" three-way; the SQL + side can produce both numbers in one pass. +2. **Are slopes per window (7 / 30 / 90 d) all consumed**, or is + `slope30` the only one any tile actually reads? Auditing the + `summarize()` callers will tell us whether the SQL replacement can + resolve a single slope per type instead of three. +3. **Does the Coach drawer ever read `useCoachConversations()` on cold + mount BEFORE the user opens the history tray** on a desktop viewport? + `src/components/insights/coach-panel/history-rail.tsx` consumes the + hook unconditionally — confirm whether the desktop rail's visible + mount paints the rail content from cold or whether deferring is + safe even there. diff --git a/.planning/round-v1433-audit-polish.md b/.planning/round-v1433-audit-polish.md new file mode 100644 index 000000000..91924ce60 --- /dev/null +++ b/.planning/round-v1433-audit-polish.md @@ -0,0 +1,527 @@ +# v1.4.33 — Symmetry + Polish Audit + +Auditor: Polish + Reliability auditor (read-only sweep) +Branch: `develop` (clean, post-v1.4.32 release) +Working dir: `/Users/marc/Projects/HealthLog` +Date: 2026-05-16 + +## 1. Executive summary + +Maintainer complaint stack for v1.4.33: the app should "überall gleich +funktionieren und verlässlich funktionieren". The audit translates that +into five concrete buckets — viewport stability, horizontal overflow, +tile symmetry, spacing-token discipline, and z-index hygiene. Findings +were derived from a static read of `src/app/**`, `src/components/**`, +and `src/app/globals.css`. No source file was modified. + +Finding counts: + +| Category | Findings | High | Medium | Low | +|----------|---------:|-----:|-------:|----:| +| Viewport stability | 4 | 1 | 2 | 1 | +| Horizontal overflow | 4 | 1 | 2 | 1 | +| Tile symmetry | 6 | 2 | 2 | 2 | +| Spacing-token drift | 5 | 1 | 2 | 2 | +| Z-index / overlay hygiene | 3 | 0 | 2 | 1 | +| **Total** | **22** | **5** | **10** | **7** | + +Top five wins (severity × ROI, executable in the v1.4.33 window): + +1. **Adopt a single page-container token.** `AuthShell` caps at + `max-w-[76.8rem]` (1228 px), `SettingsShell` + `AdminShell` cap at + `max-w-screen-xl` (1280 px), `bugreport` caps at `max-w-6xl` + (1152 px). Three different content widths on the same desktop is the + single biggest reason the page "feels" inconsistent when the user + switches surfaces. Normalise on `max-w-screen-xl` everywhere the + shell already wraps the route. (One file edit; layout-stable.) +2. **Collapse the dashboard tile-strip horizontal-scroll branch into a + responsive grid by default.** The `` to a CSS + variable.** Today the tile contract is `[--tile-h:140px]` set on the + parent strip, but the callout's reserved slot is a hard-coded + `min-h-[18px]` inside the card. Promote both to + `--tile-callout-h` + `--tile-h` and the maintainer can tune the + mobile contract once instead of touching two files. +4. **Lift the route-change scroll reset out of `` into a + single hook.** The mother page + the shell already each issue their + own `requestAnimationFrame → window.scrollTo({top:0})`. Two consumers + doing the same trick is the kind of duplication that drifts; pull + into `useResetScrollOnRouteChange()` and the maintainer's "viewport + jumps on click" report has a single fix path. +5. **Replace `px-3.5 py-2.5` in `` and the + `` chat bubbles with `px-3 py-2`.** Both call sites + are inside content where text and avatar baselines should match the + surrounding `gap-2` rhythm; the half-step pad lands the bubble's top + edge 2 px below the avatar's centre line, which reads as drift on + the v1.4.32 walk-through screenshots. + +Everything below is sorted by severity, then by call-site file path so +the diff list reads linearly. + +## 2. Viewport stability + +### 2.1 [High] `` + `/insights/page.tsx` issue duplicate `scrollTo` on every navigation + +- **File**: `src/components/insights/sub-page-shell.tsx:74-83` plus + `src/app/insights/page.tsx:105-111`. +- **Symptom**: Two consecutive `requestAnimationFrame` callbacks fire on + every transition between the mother page and a sub-page; the second + callback re-scrolls the just-mounted page to the top, which on a slow + hydrate produces the "viewport jump on click" the maintainer flagged. +- **Cause**: The mother page's effect was added in v1.4.28 (FB-D3) to + match the shell's behaviour, but both consumers still fire. The + shell's RAF triggers first, the mother page's RAF replays the same + `scrollTo({top:0, behavior:"auto"})` ~16 ms later. On a fast network + the two scrolls collapse visually, but the second `scrollTo` is what + shows up as a "snap" on slow first paints (chart skeletons still + inflating). +- **Fix**: Extract to `useResetScrollOnRouteChange()` and call from one + place. The shell is the obvious owner (every routed sub-page mounts + through it); the mother page becomes a no-op. Keep the + `prefers-reduced-motion → behavior:"auto"` branch. + +### 2.2 [Medium] Recharts hydration skeleton mismatch on the dashboard chart row + +- **File**: `src/components/charts/chart-skeleton.tsx:55-57` vs. + `src/components/charts/health-chart.tsx` (consumer pins + `--chart-height` per breakpoint). +- **Symptom**: First paint paints a 240 px chart band; once Recharts + resolves, the band grows to 280 px on `md+`. The 40 px delta lifts + every chart below it on the dashboard by the same amount, which the + user perceives as the page "rebuilding itself" after click. +- **Cause**: `` mirrors the `--chart-height` CSS variable + (`var(--chart-height,240px)` / `var(--chart-height-md,280px)`), but + the variable is never set on the document/root or on the chart band's + own host — so the fallback wins on first paint, and the real chart + reads `clientHeight` and renders at the intrinsic ResponsiveContainer + size. +- **Fix**: Drop the variable indirection (no other consumer reads it) + and inline the same `h-[240px] md:h-[280px]` contract on both the + skeleton and the real chart's outer wrapper. Document the height + literal in a single comment block so future chart additions copy the + right value. + +### 2.3 [Medium] Dashboard tile-strip alt-height drives the chart row baseline + +- **File**: `src/app/page.tsx:1239-1264`. +- **Symptom**: When the maintainer toggles BD-Zielbereich on with + comparison overlay active, the tile carries a callout row that + pushes the strip 18 px taller than a "no comparison" run. The chart + row directly beneath then slides down by 18 px without animation — + reads as a layout pop on hot-reload. +- **Cause**: The `[--tile-h:140px]` contract applies only at `` is honoured only on the + rendered tile — siblings that aren't carrying a callout never grow + their callout slot, so the row height is asymmetric. +- **Fix**: Reserve the callout slot on every tile unconditionally on + `sm+` (drop the `sm:min-h-0` clamp at `trend-card.tsx:273` to a + `sm:min-h-[18px]`). The visual is the same — empty whitespace — + but the strip's intrinsic height becomes deterministic regardless + of how many tiles render the callout. + +### 2.4 [Low] `WelcomeCarousel` `scrollIntoView` competes with the parent rail's snap-x + +- **File**: `src/components/onboarding/WelcomeCarousel.tsx:115-129`. +- **Symptom**: On the first auth, tapping a dot under the carousel + triggers a `scrollIntoView({behavior:"smooth", inline:"start"})`, but + the rail also carries `scroll-smooth` + `snap-x snap-mandatory`. The + combination can land the rail two snap points away from the target + if the user double-taps quickly (browser fires the second scroll + inside the first animation's settle window). +- **Cause**: `scrollIntoView` doesn't cancel an in-flight smooth scroll + on the parent. +- **Fix**: Add a debounce on `scrollToSlide` (300 ms; the + reduced-motion branch already runs synchronously so doesn't need + the guard) or switch to `rail.scrollTo({left, behavior})` so the + rail's scroll position is set directly. The latter avoids the snap + fight entirely. + +## 3. Horizontal overflow + +### 3.1 [High] Dashboard tile-strip side-scroll on phone is intentional but reads as a bug + +- **File**: `src/app/page.tsx:1221-1264`. +- **Symptom**: On `` blocks rely on `break-all` + +- **File**: `src/app/medications/page.tsx:790`. +- **Symptom**: The cURL / PowerShell / fetch example block uses + `break-all whitespace-pre-wrap`. `break-all` happily breaks + mid-word in URLs, which makes the example unreadable. On a + Pixel-5 column the cURL example wraps to ~14 lines for a + one-line command. +- **Cause**: `break-all` is too aggressive for code; the maintainer's + v1.4.22 polish round set it to handle the worst-case + `Authorization: Bearer …` line but the bearer token now lives on + its own line. +- **Fix**: Use `[overflow-wrap:anywhere] whitespace-pre` so URL + boundaries break on `/` / `?` / `&` etc., not mid-word, and a + long shell line scrolls horizontally inside its own `
`
+  rather than rewrapping every word.
+
+### 3.4 [Low] Privacy + about pages constrain to `max-w-3xl` while the rest of the app uses `max-w-screen-xl`
+
+- **File**: `src/app/privacy/page.tsx:111`, `:129`;
+  `src/app/about/page.tsx:68`, `:86`.
+- **Symptom**: On wide desktop the legal pages render in a 768 px
+  column; the marketing/landing impression is "the legal page is
+  cramped relative to the dashboard". Not strictly an overflow bug,
+  but the same family of complaint.
+- **Cause**: Long-form legal text reads better in a narrow column; the
+  intentional choice predates the v1.4.20 max-width audit.
+- **Fix**: Leave the 3xl cap (legal-text best practice) but document
+  it in a comment so future contributors don't "fix" it. Optionally
+  bump to `max-w-4xl` (896 px) — still legible, less cramped.
+
+## 4. Tile symmetry
+
+### 4.1 [High] Dashboard tile + chart cards use different padding tokens
+
+- **File**: `src/components/charts/trend-card.tsx:199` (`p-4 md:p-6`)
+  vs. `src/components/charts/chart-skeleton.tsx:36-39` (`p-4 md:p-6`)
+  vs. `src/components/insights/correlation-card.tsx` (shadcn
+  `` defaults to `p-6`) vs.
+  `src/components/insights/daily-briefing.tsx:260` (shadcn ``,
+  `` + `` default padding).
+- **Symptom**: Tiles and charts on the dashboard share an outer
+  card-padding contract (`p-4 md:p-6`), but correlation cards and
+  the daily-briefing card use the shadcn `` default, which is
+  `gap-6 py-6 px-6` plus ``. Side by side on
+  `/insights`, the spacing reads "the chart card is denser than the
+  briefing card" — uneven density.
+- **Fix**: Normalise on a single `card-pad` utility class (or a CSS
+  custom property on the shadcn Card token) so every card surface
+  paints with the same vertical rhythm. Concrete: bump the shadcn
+  `` defaults in `components/ui/card.tsx` to `p-4 md:p-6`
+  and let the insights consumers opt into the legacy `p-6` via an
+  explicit class where they want it.
+
+### 4.2 [High] Settings/admin shells diverge from the dashboard container
+
+- **File**: `src/components/layout/auth-shell.tsx:157` (outer
+  container is `max-w-[76.8rem]`), `src/components/settings/settings-shell.tsx:144`
+  + `src/components/admin/admin-shell.tsx:165` (both `max-w-screen-xl`).
+- **Symptom**: Moving from `/` → `/settings/account` on a
+  1440 px-wide laptop shifts the content frame from 1228 px to
+  1280 px, a 52 px jump. The header dropdown and the bottom-nav
+  centre stay still; everything between them slides outward, which
+  reads as "the page is wobbling".
+- **Cause**: `max-w-[76.8rem]` (1228 px) is a v1.4.x legacy value
+  predating the shell-level adoption of `max-w-screen-xl`. The
+  settings/admin shells were carved out later and picked the
+  tailwind token instead of matching the parent.
+- **Fix**: Lift the outer container to `max-w-screen-xl` in
+  `auth-shell.tsx:157`. Re-check the dashboard tile grid + chart
+  row widths at 1280 px (the grid `auto-fit` happily uses the extra
+  52 px; nothing reflows beyond a wider gutter).
+
+### 4.3 [Medium] `` and `` row contracts differ
+
+- **File**: `src/components/insights/trends-row.tsx:108` (`grid
+  auto-rows-fr grid-cols-1 gap-4 md:grid-cols-3`) vs.
+  `src/components/insights/correlation-row.tsx:60-64` (`grid
+  grid-cols-1 gap-4 md:grid-cols-2` conditional).
+- **Symptom**: Trends row pins to 3 columns on `md+`; correlation
+  row dynamically picks 1 or 2 columns. On the insights overview a
+  2-up correlation row above a 3-up trends row renders with
+  different column tracks at the same breakpoint — visually two
+  different design systems.
+- **Fix**: Standardise on `grid auto-rows-fr grid-cols-1 md:grid-cols-2
+  lg:grid-cols-3` for every "row of cards" on `/insights`, and let
+  cells span where needed (e.g. correlation cards with only one ok
+  result span the full width via `md:col-span-2 lg:col-span-3`).
+  Removes the "is this row 2-up or 3-up?" cognitive cost.
+
+### 4.4 [Medium] BD-Zielbereich tile sub-row count varies by user state
+
+- **File**: `src/components/charts/trend-card.tsx:300-377` + the BD
+  tile call site at `src/app/page.tsx:879-916`.
+- **Symptom**: Most tiles render two sub-rows (`7d` + `30d`).
+  BD-Zielbereich also feeds a synthetic `trend7Delta` that adds a
+  parenthetical `(+N)` next to the `7d:` value, which can overflow
+  the sub-row to a second line on Galaxy Fold. The tile then grows
+  taller than its siblings.
+- **Cause**: The `overflow-hidden flex-nowrap` clamp on the sub-row
+  pair was added in v1.4.29 to keep the contract at 140 px on
+  mobile, but the clamp also clips a legitimate `(+N)` mid-character
+  when the cell is narrow.
+- **Fix**: Drop the `trend7Delta` text when the cell is below an
+  inline-budget — render the value-only and rely on the headline
+  arrow to convey direction. Or replace the parenthetical with an
+  icon-only `↗ +N`, which doubles the visual density without
+  expanding the line budget.
+
+### 4.5 [Low] Workouts tile-strip empty-state diverges from the others
+
+- **File**: `src/components/dashboard/recent-workouts-tile.tsx` (new
+  in v1.4.32) — render flow uses an "onboarding hint" empty state
+  rather than the EmptyState primitive that BP/weight tiles use.
+- **Symptom**: A new account browsing the dashboard sees the
+  workouts tile with its own visual language while the rest of the
+  tile strip is data-empty (and therefore hidden).
+- **Fix**: Either gate the workouts tile behind the same
+  data-floor logic as the others (`hasWorkouts > 0`) or move the
+  onboarding hint into the canonical `` component so
+  all empty surfaces share a single layout.
+
+### 4.6 [Low] `` paint-shift on `md+`
+
+- **File**: `src/components/insights/hero-strip.tsx:166-172`,
+  `src/components/insights/health-score-card.tsx` (length ~520
+  lines).
+- **Symptom**: The hero strip's `md:items-stretch` contract pulls
+  the right-column score card to match the left-column title
+  block height. When the briefing paragraph wraps to 3 lines vs.
+  2 lines, the score card grows / shrinks by ~24 px on the next
+  render — visible as a "card breathing" on the route refetch.
+- **Fix**: Pin the score-card host to `md:min-h-[280px]` and let
+  the briefing column grow downward into a `flex-1` slot.
+
+## 5. Spacing-token discipline
+
+### 5.1 Half-step token sprawl
+
+Frequency counts (rg over `src/components`, excluding tests):
+
+| Token family | Uses | Notes |
+|--------------|-----:|-------|
+| `gap-1.5` / `py-1.5` / `px-1.5` | 71 | Concentrated in icon-button hit areas, suggested-prompt chips |
+| `py-2.5` / `px-2.5` | 38 | Mostly inside the Coach panel; mixed with `px-3` on siblings |
+| `gap-2.5` | 14 | Coach drawer header + message thread |
+| `px-3.5` / `py-3.5` | 4 | `` + chat bubbles + medication form card padding |
+| `p-3.5` | 2 | `medications/medication-form.tsx:678, :763` |
+
+Half-step tokens make sense for icon-button density (the `min-h-11`
+hit area absorbs the visual drift). They drift inside content blocks
+where a sibling text line uses `py-2` or `py-3` — the eye picks up
+the 2 px misalignment instantly.
+
+Recommended normalisations:
+
+- **Coach message bubbles** (`message-thread.tsx:367, :433`): drop
+  `px-3.5 py-2.5` to `px-3 py-2` to match the avatar's
+  `gap-2.5` rhythm.
+- **Suggested-prompt chips** (`suggested-prompts.tsx:74`): drop
+  `px-3.5 py-2` to `px-3 py-2`; pair with `text-sm` instead of
+  `text-[13px]`.
+- **Medication-form inner cards** (`medication-form.tsx:678, :763`):
+  drop `p-3.5` to `p-4` so the card padding matches every other
+  card on the page.
+
+### 5.2 Arbitrary `min-h-[…]` and `h-[…]` clusters
+
+20 distinct `min-h-[…]` literals + 8 distinct `h-[…]` literals across
+`src/`. Worst offenders:
+
+| Token | Count | Files |
+|-------|------:|-------|
+| `min-h-[300px]` | 3 | `trends-row.tsx` (three card cells, repeated) |
+| `min-h-[18px]` | 1 | `trend-card.tsx` callout slot |
+| `min-h-[88px]` | 1 | `GoalsChipPicker.tsx` chip cell |
+| `min-h-[64px]` | 1 | `vo2-max-chart-row.tsx` chip floor |
+| `min-h-[100svh]` | 2 | `onboarding/layout.tsx` + `OnboardingShell.tsx` |
+| `min-h-[calc(100dvh-12rem)]` | 2 | `settings-shell.tsx` + `admin-shell.tsx` |
+| `h-[180px]` | 2 | correlation-card skeleton + host-metrics chart skeleton |
+| `h-[var(--chart-height,240px)]` | 1 | chart-skeleton fallback (see 2.2) |
+
+Recommended normalisations:
+
+- **Adopt a `--tile-h` / `--card-h-min` design-token pair** in
+  `globals.css` (`:root { --tile-h: 140px; --card-min: 300px; }`)
+  and replace every arbitrary `min-h-[…]` with `min-h-[var(--…)]`.
+  One file controls the contract.
+- **Collapse the three identical `md:min-h-[300px]` literals** in
+  `trends-row.tsx` to a single class on the parent grid:
+  `[&>div]:md:min-h-[var(--card-min)]` (or just `min-h`).
+
+### 5.3 Card-radius inconsistency
+
+`rounded-xl` is the dashboard tile / chart / settings card token.
+`rounded-lg` is the workout list, onboarding chips. `rounded-md` is
+the daily-briefing key-finding row, trend annotations, and the
+recommendation expanded card. The visual reads as "three different
+card languages on the same page".
+
+- **Fix**: Promote `rounded-xl` to outer cards, `rounded-lg` to
+  inner cards / nested surfaces, and `rounded-md` only to
+  utility surfaces (Skeleton, code blocks). Audit the insights
+  surface once.
+
+### 5.4 Off-token `text-[13px]` and `text-[10px]` cluster
+
+| Token | Count |
+|-------|------:|
+| `text-[13px]` | 2 |
+| `text-[11px]` | 14 |
+| `text-[10px]` | 9 |
+
+Tailwind v4 ships `text-xs` (12 px), `text-sm` (14 px), `text-[10px]`
+and `[11px]` and `[13px]` are all literal escapes. The maintainer's
+v1.4.22 polish round already trimmed the worst of these; the
+remaining cluster is in `` (delta caption, component
+weight chips), `` (confidence pill), and
+`` (key-findings title).
+
+- **Fix**: Define `--text-2xs: 11px` in `globals.css` as the canonical
+  "smaller than xs" size; rewrite the 14 call sites against the
+  variable. Drop `text-[13px]` to `text-sm` (lose 1 px; not
+  user-visible).
+
+### 5.5 `auth-shell` container width is itself off-grid
+
+Reiterating from 4.2 because it bridges into spacing discipline:
+`max-w-[76.8rem]` = 1228.8 px. Tailwind's nearest token is
+`max-w-screen-xl` (1280 px) or `max-w-7xl` (1280 px). The 1228 px
+value reads as "someone measured a Figma frame once and pasted the
+literal". Normalise on `max-w-screen-xl`.
+
+## 6. Z-index / overlay hygiene
+
+The stack is sane on the whole. Findings:
+
+### 6.1 [Medium] Multiple `z-50` siblings rely on render-order tie-breaking
+
+- `src/components/ui/dialog.tsx:42, :70` (overlay + content both `z-50`)
+- `src/components/ui/alert-dialog.tsx:39, :61` (same shape)
+- `src/components/ui/sheet.tsx:39, :63` (overlay + content)
+- `src/components/ui/tooltip.tsx:45, :51` (content + arrow)
+- `src/components/ui/popover.tsx:44`
+- `src/components/ui/dropdown-menu.tsx:45, :233`
+- `src/components/ui/select.tsx:65`
+
+All of these portals share the same `z-50` and rely on Radix's
+portal mount order to stack correctly. The only explicit-higher
+z-index is the bottom-nav (`z-50`) and the top-bar (`z-40`) — i.e.
+the bottom-nav can stack on top of an open dropdown when the
+dropdown content's portal is mounted before the bottom-nav
+re-renders. Not seen in production but the contract is fragile.
+
+- **Fix**: Adopt a documented z-scale in `globals.css`:
+  `--z-nav: 40; --z-overlay: 50; --z-popover: 60; --z-toast: 70;
+  --z-tour: 100;` and rewire every consumer. Today's
+  `z-[100]` (skip-link), `z-[200]` (tour) are off-scale outliers.
+
+### 6.2 [Medium] Coach FAB sits at `z-40` underneath the bottom-nav
+
+- **File**: `src/components/insights/layout-coach-fab.tsx:46`
+  (`fixed right-4 bottom-20 z-40`).
+- **Symptom**: The FAB is positioned at `bottom-20` (5 rem) so it
+  visually clears the 4-rem bottom-nav, but its `z-40` matches the
+  top-bar and falls underneath the `z-50` bottom-nav. If the FAB
+  ever migrates closer to the nav (or the user has a different
+  safe-area inset), the bottom-nav backdrop-blur covers it.
+- **Fix**: Bump to `z-50` to match the bottom-nav, or to the
+  proposed `--z-popover` (60) so it lives above the nav strip
+  unambiguously.
+
+### 6.3 [Low] Sticky insights tab strip sits at `z-30`
+
+- **File**: `src/components/insights/insights-tab-strip.tsx:186`
+  (`sticky top-0 z-30`).
+- **Symptom**: At `z-30` the strip is below the top-bar (`z-40`)
+  and above page content — correct for the desktop layout. On
+  mobile the top-bar is also `z-40` so the strip slides under it
+  cleanly. The hard-coded value is fine; my concern is that the
+  scale documentation is missing.
+- **Fix**: Same documented z-scale (`--z-sticky: 30`). No
+  behavioural change.
+
+## 7. Punch list — ordered by severity × ROI
+
+A practical v1.4.33 work order. Each entry is one file edit, one
+test update, and re-snapshot. Cumulative line count: ≤120 lines.
+
+1. **[High][P1]** Unify outer page container: `auth-shell.tsx:157` ⟶
+   `max-w-screen-xl`. Snapshot: dashboard / settings / admin at
+   1440 px. (Section 4.2.)
+2. **[High][P1]** Drop dashboard tile-strip mobile horizontal scroll:
+   collapse to a single responsive grid in
+   `src/app/page.tsx:1221-1264`. Drop `[--tile-h:140px]`, restore
+   wrap. (Section 3.1.)
+3. **[High][P2]** Hoist scroll-reset to a hook; remove duplicate
+   from `src/app/insights/page.tsx:105-111`. (Section 2.1.)
+4. **[High][P2]** Normalise card paddings: bump shadcn ``
+   defaults to `p-4 md:p-6`; audit `daily-briefing.tsx` +
+   `correlation-card.tsx` for consistent footprint. (Section 4.1.)
+5. **[Medium][P2]** Reserve callout slot on `sm+` so the strip
+   height is deterministic: `trend-card.tsx:273` drop the
+   `sm:min-h-0` clamp. (Section 2.3.)
+6. **[Medium][P3]** Reduce `min-w-[760px]` floor on API-tokens
+   tables to `min-w-[28rem]`; re-verify
+   `api-section-responsive.test.tsx`. (Section 3.2.)
+7. **[Medium][P3]** Replace `break-all` with
+   `[overflow-wrap:anywhere]` on the medications request-example
+   `
`. (Section 3.3.)
+8. **[Medium][P3]** Normalise row-grid contracts on `/insights`:
+   trends-row + correlation-row + recommendations-grid land on the
+   same `md:grid-cols-2 lg:grid-cols-3` shape. (Section 4.3.)
+9. **[Medium][P4]** Bubble + chip spacing tokens: drop `px-3.5
+   py-2.5` to `px-3 py-2` in suggested-prompts + chat bubbles +
+   medication-form inner cards. (Section 5.1.)
+10. **[Medium][P4]** Promote Coach FAB to `z-50`. (Section 6.2.)
+11. **[Low][P4]** Adopt the documented z-scale + spacing-token
+    `:root` variables in `globals.css`; rewire `min-h-[…]`
+    arbitrary literals. (Sections 5.2, 5.3, 6.1, 6.3.)
+12. **[Low][P5]** Stabilise chart-skeleton fallback height: drop the
+    `var(--chart-height,…)` indirection and use literal
+    `h-[240px] md:h-[280px]`. (Section 2.2.)
+13. **[Low][P5]** Debounce `scrollToSlide` in `WelcomeCarousel`.
+    (Section 2.4.)
+14. **[Low][P5]** Stretch `` to a documented
+    `md:min-h-[280px]`. (Section 4.6.)
+15. **[Low][P5]** Document the legal-page narrow-column choice
+    (`privacy/page.tsx` + `about/page.tsx`); leave the `3xl` cap.
+    (Section 3.4.)
+16. **[Low][P5]** Decide on the workouts-tile empty-state shape:
+    either hide on empty (data-floor) or migrate to the shared
+    ``. (Section 4.5.)
+
+That's the v1.4.33 polish surface in one ordered list. Items 1-5
+deliver the bulk of the perceived reliability win; items 6-10 close
+the long tail; the rest are documentation + cleanup.
diff --git a/.planning/round-v1433-audit-runtime-screenshots/01-login-error.png b/.planning/round-v1433-audit-runtime-screenshots/01-login-error.png
new file mode 100644
index 000000000..058c3ccd7
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/01-login-error.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/01-post-login.png b/.planning/round-v1433-audit-runtime-screenshots/01-post-login.png
new file mode 100644
index 000000000..34634e164
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/01-post-login.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/02a-dashboard-default.png b/.planning/round-v1433-audit-runtime-screenshots/02a-dashboard-default.png
new file mode 100644
index 000000000..cff460596
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/02a-dashboard-default.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/02b-add-clicked.png b/.planning/round-v1433-audit-runtime-screenshots/02b-add-clicked.png
new file mode 100644
index 000000000..0bda60193
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/02b-add-clicked.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-aktive-energie-after-ranges.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-aktive-energie-after-ranges.png
new file mode 100644
index 000000000..7ba36dd70
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-aktive-energie-after-ranges.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-aktive-energie.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-aktive-energie.png
new file mode 100644
index 000000000..7ba36dd70
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-aktive-energie.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-blutdruck-after-ranges.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-blutdruck-after-ranges.png
new file mode 100644
index 000000000..61f2f3f7b
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-blutdruck-after-ranges.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-blutdruck.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-blutdruck.png
new file mode 100644
index 000000000..666f6f157
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-blutdruck.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-bmi-after-ranges.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-bmi-after-ranges.png
new file mode 100644
index 000000000..d3ae4da46
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-bmi-after-ranges.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-bmi.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-bmi.png
new file mode 100644
index 000000000..89c9268b9
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-bmi.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-gewicht-after-ranges.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-gewicht-after-ranges.png
new file mode 100644
index 000000000..6cc7c161a
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-gewicht-after-ranges.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-gewicht.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-gewicht.png
new file mode 100644
index 000000000..62139e276
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-gewicht.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-hrv-after-ranges.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-hrv-after-ranges.png
new file mode 100644
index 000000000..f5d3746a2
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-hrv-after-ranges.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-hrv.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-hrv.png
new file mode 100644
index 000000000..f5d3746a2
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-hrv.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-koerpertemperatur-after-ranges.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-koerpertemperatur-after-ranges.png
new file mode 100644
index 000000000..b3c7bc4f1
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-koerpertemperatur-after-ranges.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-koerpertemperatur.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-koerpertemperatur.png
new file mode 100644
index 000000000..b3c7bc4f1
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-koerpertemperatur.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-medikamente-after-ranges.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-medikamente-after-ranges.png
new file mode 100644
index 000000000..13905aada
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-medikamente-after-ranges.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-medikamente.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-medikamente.png
new file mode 100644
index 000000000..13905aada
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-medikamente.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-puls-after-ranges.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-puls-after-ranges.png
new file mode 100644
index 000000000..dd313b712
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-puls-after-ranges.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-puls.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-puls.png
new file mode 100644
index 000000000..981b2eea0
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-puls.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-ruhepuls-after-ranges.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-ruhepuls-after-ranges.png
new file mode 100644
index 000000000..6706fe38e
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-ruhepuls-after-ranges.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-ruhepuls.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-ruhepuls.png
new file mode 100644
index 000000000..6706fe38e
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-ruhepuls.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-sauerstoff-after-ranges.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-sauerstoff-after-ranges.png
new file mode 100644
index 000000000..904d7b7bf
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-sauerstoff-after-ranges.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-sauerstoff.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-sauerstoff.png
new file mode 100644
index 000000000..904d7b7bf
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-sauerstoff.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-schlaf-after-ranges.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-schlaf-after-ranges.png
new file mode 100644
index 000000000..a1384acd6
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-schlaf-after-ranges.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-schlaf.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-schlaf.png
new file mode 100644
index 000000000..6070b6cef
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-schlaf.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-stimmung-after-ranges.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-stimmung-after-ranges.png
new file mode 100644
index 000000000..db883c157
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-stimmung-after-ranges.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-stimmung.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-stimmung.png
new file mode 100644
index 000000000..9974f75bf
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-stimmung.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-workouts-after-ranges.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-workouts-after-ranges.png
new file mode 100644
index 000000000..14f6f4501
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-workouts-after-ranges.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/03-insights-workouts.png b/.planning/round-v1433-audit-runtime-screenshots/03-insights-workouts.png
new file mode 100644
index 000000000..14f6f4501
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/03-insights-workouts.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/05-settings-about.png b/.planning/round-v1433-audit-runtime-screenshots/05-settings-about.png
new file mode 100644
index 000000000..102a17fce
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/05-settings-about.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/05-settings-account.png b/.planning/round-v1433-audit-runtime-screenshots/05-settings-account.png
new file mode 100644
index 000000000..fa9e11a9c
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/05-settings-account.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/05-settings-advanced.png b/.planning/round-v1433-audit-runtime-screenshots/05-settings-advanced.png
new file mode 100644
index 000000000..de617e420
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/05-settings-advanced.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/05-settings-ai.png b/.planning/round-v1433-audit-runtime-screenshots/05-settings-ai.png
new file mode 100644
index 000000000..63cf27e4f
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/05-settings-ai.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/05-settings-api.png b/.planning/round-v1433-audit-runtime-screenshots/05-settings-api.png
new file mode 100644
index 000000000..d5e9878d0
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/05-settings-api.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/05-settings-dashboard.png b/.planning/round-v1433-audit-runtime-screenshots/05-settings-dashboard.png
new file mode 100644
index 000000000..bb507607b
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/05-settings-dashboard.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/05-settings-export.png b/.planning/round-v1433-audit-runtime-screenshots/05-settings-export.png
new file mode 100644
index 000000000..ccb7f12cd
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/05-settings-export.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/05-settings-integrations.png b/.planning/round-v1433-audit-runtime-screenshots/05-settings-integrations.png
new file mode 100644
index 000000000..3654945b7
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/05-settings-integrations.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/05-settings-notifications.png b/.planning/round-v1433-audit-runtime-screenshots/05-settings-notifications.png
new file mode 100644
index 000000000..090ea396e
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/05-settings-notifications.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/05-settings-sources.png b/.planning/round-v1433-audit-runtime-screenshots/05-settings-sources.png
new file mode 100644
index 000000000..60b3a8b10
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/05-settings-sources.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/05-settings-thresholds.png b/.planning/round-v1433-audit-runtime-screenshots/05-settings-thresholds.png
new file mode 100644
index 000000000..6b343af19
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/05-settings-thresholds.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/06-mobile-insights-blutdruck.png b/.planning/round-v1433-audit-runtime-screenshots/06-mobile-insights-blutdruck.png
new file mode 100644
index 000000000..a33dcdca9
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/06-mobile-insights-blutdruck.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/06-mobile-insights-gewicht.png b/.planning/round-v1433-audit-runtime-screenshots/06-mobile-insights-gewicht.png
new file mode 100644
index 000000000..b5fbbed74
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/06-mobile-insights-gewicht.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/06-mobile-insights-hrv.png b/.planning/round-v1433-audit-runtime-screenshots/06-mobile-insights-hrv.png
new file mode 100644
index 000000000..b06fd9c5a
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/06-mobile-insights-hrv.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/06-mobile-insights-puls.png b/.planning/round-v1433-audit-runtime-screenshots/06-mobile-insights-puls.png
new file mode 100644
index 000000000..9651ef028
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/06-mobile-insights-puls.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/06-mobile-insights-schlaf.png b/.planning/round-v1433-audit-runtime-screenshots/06-mobile-insights-schlaf.png
new file mode 100644
index 000000000..9edfbd5f1
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/06-mobile-insights-schlaf.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/06-mobile-insights-stimmung.png b/.planning/round-v1433-audit-runtime-screenshots/06-mobile-insights-stimmung.png
new file mode 100644
index 000000000..416b3cac7
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/06-mobile-insights-stimmung.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/06-mobile-settings-account.png b/.planning/round-v1433-audit-runtime-screenshots/06-mobile-settings-account.png
new file mode 100644
index 000000000..3266a50cf
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/06-mobile-settings-account.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/06-mobile-settings-ai.png b/.planning/round-v1433-audit-runtime-screenshots/06-mobile-settings-ai.png
new file mode 100644
index 000000000..bb1566c4c
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/06-mobile-settings-ai.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/06-mobile-settings-integrations.png b/.planning/round-v1433-audit-runtime-screenshots/06-mobile-settings-integrations.png
new file mode 100644
index 000000000..3e3cb1002
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/06-mobile-settings-integrations.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/06-mobile-settings-sources.png b/.planning/round-v1433-audit-runtime-screenshots/06-mobile-settings-sources.png
new file mode 100644
index 000000000..c858bd76a
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/06-mobile-settings-sources.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/06-mobile-settings-thresholds.png b/.planning/round-v1433-audit-runtime-screenshots/06-mobile-settings-thresholds.png
new file mode 100644
index 000000000..01da861a2
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/06-mobile-settings-thresholds.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/06a-mobile-dashboard.png b/.planning/round-v1433-audit-runtime-screenshots/06a-mobile-dashboard.png
new file mode 100644
index 000000000..003e8ddc0
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/06a-mobile-dashboard.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/07-after-repeats.png b/.planning/round-v1433-audit-runtime-screenshots/07-after-repeats.png
new file mode 100644
index 000000000..67201bd76
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/07-after-repeats.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/probe-1-initial.png b/.planning/round-v1433-audit-runtime-screenshots/probe-1-initial.png
new file mode 100644
index 000000000..058c3ccd7
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/probe-1-initial.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/probe-2-after-toggle.png b/.planning/round-v1433-audit-runtime-screenshots/probe-2-after-toggle.png
new file mode 100644
index 000000000..b078a7993
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/probe-2-after-toggle.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/probe-3-filled.png b/.planning/round-v1433-audit-runtime-screenshots/probe-3-filled.png
new file mode 100644
index 000000000..2d6821429
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/probe-3-filled.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/probe-4-after-submit.png b/.planning/round-v1433-audit-runtime-screenshots/probe-4-after-submit.png
new file mode 100644
index 000000000..4bb9ce7b1
Binary files /dev/null and b/.planning/round-v1433-audit-runtime-screenshots/probe-4-after-submit.png differ
diff --git a/.planning/round-v1433-audit-runtime-screenshots/runtime-results.json b/.planning/round-v1433-audit-runtime-screenshots/runtime-results.json
new file mode 100644
index 000000000..b8179b335
--- /dev/null
+++ b/.planning/round-v1433-audit-runtime-screenshots/runtime-results.json
@@ -0,0 +1,863 @@
+{
+  "durations": {
+    "puls": 1173,
+    "blutdruck": 879,
+    "gewicht": 855,
+    "stimmung": 832,
+    "schlaf": 900,
+    "aktive-energie": 1520,
+    "hrv": 1201,
+    "ruhepuls": 1473,
+    "sauerstoff": 1260,
+    "koerpertemperatur": 1546,
+    "bmi": 815,
+    "medikamente": 1444,
+    "workouts": 1016
+  },
+  "findings": [],
+  "consoleErrors": [
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 401 (Unauthorized)",
+      "url": "http://localhost:3000/auth/login"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/puls"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 422 (Unprocessable Entity)",
+      "url": "http://localhost:3000/insights/puls"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/blutdruck"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 422 (Unprocessable Entity)",
+      "url": "http://localhost:3000/insights/blutdruck"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/gewicht"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 422 (Unprocessable Entity)",
+      "url": "http://localhost:3000/insights/gewicht"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/stimmung"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 422 (Unprocessable Entity)",
+      "url": "http://localhost:3000/insights/stimmung"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/schlaf"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/schlaf"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 422 (Unprocessable Entity)",
+      "url": "http://localhost:3000/insights/schlaf"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/aktive-energie"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 422 (Unprocessable Entity)",
+      "url": "http://localhost:3000/insights/aktive-energie"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/hrv"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 422 (Unprocessable Entity)",
+      "url": "http://localhost:3000/insights/hrv"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/ruhepuls"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 422 (Unprocessable Entity)",
+      "url": "http://localhost:3000/insights/ruhepuls"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/sauerstoff"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 422 (Unprocessable Entity)",
+      "url": "http://localhost:3000/insights/sauerstoff"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/koerpertemperatur"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/koerpertemperatur"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/koerpertemperatur"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 422 (Unprocessable Entity)",
+      "url": "http://localhost:3000/insights/koerpertemperatur"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/bmi"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/bmi"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/bmi"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/bmi"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/bmi"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/bmi"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/medikamente"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/medikamente"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/medikamente"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/medikamente"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/workouts"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/workouts"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/workouts"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/workouts"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/settings/account"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/settings/account"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/settings/account"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/settings/integrations"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/settings/integrations"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/settings/integrations"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/settings/notifications"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/settings/notifications"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/settings/notifications"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/settings/dashboard"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/settings/dashboard"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/settings/dashboard"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/settings/thresholds"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/settings/thresholds"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/settings/thresholds"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/settings/sources"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/settings/sources"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/settings/sources"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/settings/ai"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/settings/ai"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/settings/ai"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/settings/api"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/settings/api"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/settings/api"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/settings/export"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/settings/export"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/settings/export"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/settings/advanced"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/settings/advanced"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/settings/advanced"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/settings/about"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/settings/about"
+    },
+    {
+      "type": "console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/settings/about"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 401 (Unauthorized)",
+      "url": "http://localhost:3000/auth/login"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/auth/login"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/auth/login"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/auth/login"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/auth/login"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/puls"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/puls"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/puls"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/blutdruck"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/blutdruck"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/gewicht"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/gewicht"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/stimmung"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/stimmung"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/schlaf"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/schlaf"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/schlaf"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/hrv"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/hrv"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/settings/account"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/settings/account"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/settings/thresholds"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/settings/sources"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/settings/integrations"
+    },
+    {
+      "type": "mobile-console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/settings/ai"
+    },
+    {
+      "type": "repeat-console",
+      "text": "Failed to load resource: the server responded with a status of 401 (Unauthorized)",
+      "url": "http://localhost:3000/auth/login"
+    },
+    {
+      "type": "repeat-console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/auth/login"
+    },
+    {
+      "type": "repeat-console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/"
+    },
+    {
+      "type": "repeat-console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/blutdruck"
+    },
+    {
+      "type": "repeat-console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/blutdruck"
+    },
+    {
+      "type": "repeat-console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/settings/account"
+    },
+    {
+      "type": "repeat-console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/settings/account"
+    },
+    {
+      "type": "repeat-console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/blutdruck"
+    },
+    {
+      "type": "repeat-console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/insights/blutdruck"
+    },
+    {
+      "type": "repeat-console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/blutdruck"
+    },
+    {
+      "type": "repeat-console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/insights/blutdruck"
+    },
+    {
+      "type": "repeat-console",
+      "text": "Failed to load resource: the server responded with a status of 404 ()",
+      "url": "http://localhost:3000/"
+    },
+    {
+      "type": "repeat-console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/"
+    },
+    {
+      "type": "repeat-console",
+      "text": "Failed to load resource: the server responded with a status of 429 (Too Many Requests)",
+      "url": "http://localhost:3000/"
+    }
+  ],
+  "networkFailures": [
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    },
+    {
+      "url": "http://localhost:3000/api/internal/web-vitals",
+      "method": "POST",
+      "failure": "net::ERR_ABORTED"
+    }
+  ],
+  "slowResponses": []
+}
\ No newline at end of file
diff --git a/.planning/round-v1433-audit-runtime.md b/.planning/round-v1433-audit-runtime.md
new file mode 100644
index 000000000..8ebf89db6
--- /dev/null
+++ b/.planning/round-v1433-audit-runtime.md
@@ -0,0 +1,617 @@
+# v1.4.33 runtime audit — local-dev exercise of v1.4.32 against demo seed
+
+Author: runtime bug-hunt pass
+Branch/version under test: develop at v1.4.32, local dev (`pnpm dev`), Turbopack
+Database: local postgres (docker `healthlog-db`), migrations applied through 0065,
+seeded via `scripts/seed-demo.ts` → user `demo` / `demo@healthlog.app` /
+`demo123demo123`, 540 measurements, 3 medications, mood entries, achievements,
+audit log.
+Browser: headless Chromium via Playwright 1.59.1, default
+viewport 1440×900 + iPhone-SE 375×667 emulation.
+Screenshot artefacts: `./round-v1433-audit-runtime-screenshots/*.png`.
+
+## Methodology
+
+1. Auto-detect dev servers, start `pnpm dev` if none.
+2. Apply migrations + seed demo data so the audit hits realistic flows
+   (empty-state was the default until 0061-0065 + seed-demo ran).
+3. Drive Playwright through: login → dashboard → every insight sub-page →
+   coach drawer → every settings section → mobile viewport repeat of key
+   flows → multiple repeats of the same flow.
+4. Capture per-route screenshot, console error stream, network failure stream,
+   and post-process for overflow + tile-truncation + heading-presence.
+
+I made no source edits; the only mutating operation was `prisma migrate deploy`
+and `npx tsx scripts/seed-demo.ts` against the local DB.
+
+## Top-level numbers
+
+| Bucket | Count |
+| --- | --- |
+| Critical | 3 |
+| High | 6 |
+| Medium | 11 |
+| Low | 5 |
+| Total | 25 |
+
+Console errors across the audit: 116 (after subtract login pre-auth 401s,
+the post-login error stream is still ~110, dominated by `/api/insights/generate
+422` because no provider configured and `/api/internal/web-vitals 429` from
+rate-limiting the browser). Net new findings beyond the maintainer's
+15-item list: ~10 (counted by title-uniqueness — see per-finding entries).
+
+---
+
+## Findings
+
+### F1. Local-DB schema drift on `users.last_synced_at` — login 500s if migrations are skipped
+
+- Severity: **Critical** (development-only — production migrate-deploy
+  is part of the Docker boot — but any contributor who runs `pnpm dev`
+  on an older clone will hit this).
+- Reproduction
+  1. Clone HealthLog at a tag prior to v1.4.30, run `pnpm db:push` once.
+  2. Pull develop (v1.4.32) and re-run `pnpm dev` without `db:migrate:deploy`.
+  3. Visit `/auth/login`, submit credentials.
+  4. Server returns 500; dev log shows
+     `PrismaClientKnownRequestError: The column users.last_synced_at does not exist`.
+- Screenshot: `01-login-error.png` (post-submit blank screen during my
+  initial run — the dev DB was at migration 0060 while the schema expects
+  0062 to add `last_synced_at` to `users`).
+- Offending route + component: `src/app/api/auth/login/route.ts` →
+  `prisma.user.findFirst()` selects `lastSyncedAt`.
+- Proposed fix scope: add a boot-time schema-drift check in `pnpm dev`
+  (or a clear error page in `auth/login` when the Prisma error is
+  `P2022`/`P2010` "column does not exist") — and document
+  `db:migrate:deploy` as required between version bumps in
+  `CONTRIBUTING.md`.
+
+### F2. Onboarding-tour overlay intercepts every dashboard click
+
+- Severity: **Critical** (blocks the most common action — "Hinzufügen").
+- Reproduction
+  1. Sign in as a freshly-seeded `demo` user.
+  2. Land on `/`.
+  3. The tour-overlay (`role="dialog"`, full-viewport z-index ~200)
+     starts at "Schritt 1 von 5" and absorbs every pointer event behind it.
+  4. Click "Hinzufügen" in the header — nothing happens. The click
+     hits the dim-layer instead of the button.
+- Screenshot: `02b-add-clicked.png` — the tour bubble centres in the
+  middle of the page, the chart area dims under it. Hinzufügen sits
+  outside the spotlight but the modal overlay's dim-layer still
+  blocks pointer events on the underlying button.
+- Offending route + component: `src/components/onboarding/tour.tsx`
+  paired with `users.onboarding_tour_completed = false` default.
+- Proposed fix scope: either
+  (a) set `pointer-events: none` on the tour's dim-layer except inside
+  the spotlight ring + the next-step controls, **or**
+  (b) include `onboardingTourCompleted: true` in `scripts/seed-demo.ts`
+  for the same reason `e2e/setup/global-setup.ts` already does.
+
+### F3. Dashboard tile values are TRUNCATED to one digit
+
+- Severity: **Critical** (the maintainer's first concern — dashboard
+  tile visibility — manifests as unreadable values like "8…", "1.",
+  "8.", "6.", "2…" at 1440×900).
+- Reproduction
+  1. Land on `/` at viewport 1440×900 with seven dashboard tiles
+     visible (Gewicht, BD-Sys, BD-Dia, Puls, Körperfett, Stimmung,
+     BD-Ziel).
+  2. Read the headline number under each tile heading.
+  3. "GEWICHT" shows `8…`. "BD (SYS)" shows `1.` (i.e. `1` plus
+     decimal separator). "PULS" shows `6…`. Mobile (375 wide) shows
+     full values `83,0 kg`, `130 mmHg`, etc., so the bug is desktop-only
+     and triggered by the seven-up grid forcing each tile too narrow
+     for the value column.
+- Screenshot: `02a-dashboard-default.png` (with tour), `07-after-repeats.png`
+  (without tour but same truncation), `06a-mobile-dashboard.png`
+  (mobile shows full value — proves the issue is layout-density, not
+  rendering).
+- Offending route + component: `src/components/dashboard/dashboard-tile.tsx`
+  (or whatever renders the seven-tile grid) — most likely a `truncate` /
+  `overflow-hidden` paired with a too-aggressive `text-3xl` keeps the
+  primary value from shrinking below container width.
+- Proposed fix scope: drop one tile from the desktop grid (BD-Ziel is
+  showing placeholder `0,0 %` anyway — see F4), OR clamp the tile
+  grid to six columns max at `xl:grid-cols-6`, OR use `min-w-0` +
+  `truncate` on a wrapper so the unit and arrow drop to a second
+  line before the number gets ellipsised.
+
+### F4. BD-Ziel dashboard tile shows nonsense placeholder "0,0 %"
+
+- Severity: **High**.
+- Reproduction
+  1. Land on `/`.
+  2. Last tile reads `BD-ZIEL` / `0,0 %` / `7-Tage-Trend: 0,0 (±0)`.
+- Screenshot: `02a-dashboard-default.png` (top-right tile).
+- Offending route + component: BD-Ziel-Reichweite is dimensionless
+  (percent), and 0,0 % means "0% of BD readings inside target range"
+  which is impossible given 540 seeded BP samples — the metric is
+  showing zero because nothing pre-computes a per-day "in-target"
+  fraction yet for the demo seed, OR the tile reads the wrong key
+  (`bdZiel` instead of `bpInRange`).
+- Proposed fix scope: hide the tile when the underlying metric has no
+  computed value, or change the unit from `%` to a sane fallback
+  ("noch nicht ausgewertet"). Either way the literal `0,0 %` ships as
+  data and looks broken.
+
+### F5. Color/arrow semantics on dashboard tile trend rows are inconsistent
+
+- Severity: **High** (data integrity from the user's POV).
+- Reproduction
+  1. Land on `/`.
+  2. GEWICHT 7-Tage-Trend: value `82,8 (+0,7)` is rendered in **orange**
+     (warn-color); the change arrow next to the headline value is **green**.
+     Direction-up on weight is generally *bad* (gain), yet the arrow is
+     coloured "good".
+  3. BD (SYS) 7-Tage-Trend: `130,0 (-1,6)` in red (warn) but the arrow
+     is teal/green and points UP.
+  4. PULS 7-Tage-Trend: `66,2 (-3,0)` in green (good) with the arrow
+     pointing DOWN. So the same direction means different things on
+     different tiles depending on what the metric "should" do.
+- Screenshot: `07-after-repeats.png` (clean view, no tour).
+- Offending route + component: tile-trend renderer (likely shared:
+  `dashboard-tile.tsx` or `metric-trend.tsx`).
+- Proposed fix scope: define one rule — direction = pure delta sign;
+  colour = improvement-mapping per metric. Either both follow
+  improvement-mapping or both follow sign. The current half-and-half
+  state is a confusion source.
+
+### F6. Insight blood-pressure Y-axis label says "Hg" not "mmHg"
+
+- Severity: **High** (incorrect unit on a clinical metric).
+- Reproduction
+  1. Visit `/insights/blutdruck`.
+  2. Read the y-axis labels: `139 Hg`, `116 Hg`, `96 Hg`, `76 Hg`.
+- Screenshot: `03-insights-blutdruck.png`, `03-insights-blutdruck-after-ranges.png`.
+- Offending route + component: chart for blood-pressure in
+  `src/components/charts/` (likely a unit-string passed as `"Hg"`
+  instead of `"mmHg"`). Tile header on the same page correctly says
+  `mmHg`.
+- Proposed fix scope: replace the y-axis unit string with `mmHg` (one-line).
+
+### F7. `/insights/puls` description text says "Ruhepuls" — wrong metric
+
+- Severity: **High** (mixed metric semantics confuse users).
+- Reproduction
+  1. Visit `/insights/puls`.
+  2. Page subtitle reads: *"Ruhepuls gegenüber dem persönlichen
+     Karvonen-Zielband, mit KI-Einschätzung."*
+  3. But `/insights/ruhepuls` exists as a SEPARATE route for the
+     daily resting-heart-rate stream.
+- Screenshot: `03-insights-puls.png`.
+- Offending key: `messages/de.json` → `insights.pulsDescription` —
+  currently `"Ruhepuls gegenüber dem persönlichen Karvonen-Zielband,
+  mit KI-Einschätzung."`.
+- Proposed fix scope: change the i18n string for `/insights/puls`
+  to "Pulsverlauf gegenüber dem persönlichen Karvonen-Zielband"
+  (drop "Ruhe-") so the spot-pulse page and the resting-heart-rate
+  page have distinct copy.
+
+### F8. Medikamenten-Compliance heatmap colour ≠ legend
+
+- Severity: **High** (data-visualisation correctness).
+- Reproduction
+  1. Visit `/insights/medikamente`.
+  2. Each medication shows "6 genommen / 0 übersprungen / 1 verpasst"
+     (so 6 punctual + 1 missed for the day, no skips).
+  3. The compliance heatmap renders almost every cell in **orange**
+     ("sehr spät") and a handful in **red** ("verpasst"). There are no
+     green cells, even though 6 of 7 were "genommen".
+  4. Legend dots show green/yellow/orange/red but the heatmap only
+     uses orange/red.
+- Screenshot: `03-insights-medikamente.png`.
+- Offending route + component: medication compliance heatmap, likely
+  `src/components/insights/medication-compliance-grid.tsx` or
+  similar; bug is the bucket-mapping from a `MedicationIntakeEvent`'s
+  delta-minutes to one of the four severity buckets.
+- Proposed fix scope: re-verify the bucket thresholds — punctual
+  should be < 30 min late, "spät" < 60, "sehr spät" < 180,
+  "verpasst" otherwise. The seed plants the intake events with
+  delta=0 (taken on time) but the renderer still shows them as
+  "sehr spät", suggesting a default-bucket fall-through bug.
+
+### F9. Insights nav tab strip misses HRV / Schritte / SpO2 / Workouts / Aktive Energie / Körpertemperatur / Ruhepuls
+
+- Severity: **High** (discoverability — half the metrics are unreachable
+  from the in-page nav).
+- Reproduction
+  1. Visit `/insights`.
+  2. Tab strip lists: Übersicht, Blutdruck, Gewicht, Puls, Stimmung,
+     Medikamente, BMI, Schlaf — 8 entries.
+  3. Code-side, `/insights/aktive-energie`, `/insights/hrv`,
+     `/insights/ruhepuls`, `/insights/sauerstoff`,
+     `/insights/koerpertemperatur`, `/insights/workouts` all exist as
+     routes — but none appear in the strip.
+  4. Navigating directly to `/insights/hrv` works, but the strip does
+     not highlight a "current tab" because the active page is not in
+     the list (no purple chip).
+- Screenshot: `03-insights-hrv.png` (note the empty active state in the
+  strip).
+- Offending route + component: tab-strip in
+  `src/components/insights/insights-nav.tsx` (or similar) — slug list
+  needs to include the iOS-foundation metrics added in v1.4.23+.
+- Proposed fix scope: append the missing slugs to the tab strip and
+  reuse the same "horizontally-scrollable" pattern that already exists
+  for mobile.
+
+### F10. Gewicht Y-axis renders the same tick label "83 kg" TWICE
+
+- Severity: **Medium** (data legibility — ticks rounding to the same
+  integer collapse visually).
+- Reproduction
+  1. Visit `/insights/gewicht`.
+  2. Read the y-axis from top to bottom: `85 kg`, `84 kg`, `83 kg`,
+     `83 kg`, `82 kg`. The "83 kg" appears at two distinct gridlines
+     (≈83.5 and ≈82.8 actual).
+- Screenshot: `03-insights-gewicht.png`.
+- Offending route + component: recharts Y-axis `tickFormatter` or the
+  axis-tick generator — likely rounding to integer when the y-domain
+  is < 5 kg.
+- Proposed fix scope: use `0.5` step / `toFixed(1)` formatter when
+  the y-domain is narrow, OR force `allowDecimals` with a one-decimal
+  formatter. Eyeballed: a small change in `health-chart-dynamic.tsx`.
+
+### F11. Stimmung Y-axis label "Super gut" wraps mid-word and clips
+
+- Severity: **Medium** (legibility).
+- Reproduction
+  1. Visit `/insights/stimmung`.
+  2. Top y-axis label "Super gut" wraps to two lines: "Super" / "gut",
+     the second line is partially under the chart's plot area.
+- Screenshot: `03-insights-stimmung.png`.
+- Offending route + component: mood-chart custom tick component.
+- Proposed fix scope: widen the y-axis margin, OR shorten the label
+  to one of: "Super", "Top", "Sehr gut". Don't try to fit "Super gut"
+  as one line — German users will associate "Super gut" with
+  Stimmung-5 colloquially.
+
+### F12. Stimmung chart axis includes "Lausig" but data never reaches that bin
+
+- Severity: **Low** (empty axis state).
+- Reproduction
+  1. Visit `/insights/stimmung`.
+  2. Y-axis ticks: "Super gut", "Gut", "Okay", "Schlecht", "Lausig".
+  3. The 30-Pkt window never has a data point at "Lausig" (smallest
+     value seen is `Schlecht`).
+- Note: not necessarily a bug — Y-axis showing the full scale is
+  defensible. Flagging because the axis label "Lausig" (slang)
+  reads informally compared to the rest of the German UI ("Schlecht",
+  "Gut") and may want a softer alternative for v1.4.33 polish.
+- Proposed fix scope: rename "Lausig" → "Sehr schlecht" or "Mies" if
+  consistency is the priority.
+
+### F13. Konto → Profil → "Benutzername:" field renders empty (placeholder-only)
+
+- Severity: **High** (user thinks their account is misconfigured).
+- Reproduction
+  1. Sign in as `demo`.
+  2. Visit `/settings/account`.
+  3. The "Benutzername:" input shows the placeholder text `demo`
+     in grey (`text-muted-foreground`) — there is NO value bound to
+     the field. The email input correctly shows
+     `demo@healthlog.app`.
+- Screenshot: `05-settings-account.png`, `06-mobile-settings-account.png`.
+- Offending route + component: profile form in
+  `src/components/settings/account-section.tsx` —
+  likely the `defaultValue` reads from a state slice that hasn't
+  hydrated, or the user object is missing the field, OR the form
+  intentionally shows the username as a non-editable placeholder
+  but renders it as an editable input with placeholder semantics
+  (which is wrong — placeholder is for hints, not actual values).
+- Proposed fix scope: bind the input value to `user.username`, mark
+  it readonly if username changes aren't allowed. The current state
+  is the worst of both — looks empty AND looks like a writable field.
+
+### F14. Mobile bottom tab-bar overlaps Settings form content
+
+- Severity: **High** (last form field is unreadable).
+- Reproduction
+  1. On a 375×667 viewport, navigate to `/settings/account`.
+  2. Scroll to bottom — the "Geschlecht:" select + its help text
+     "Wird für geschlechtsspezifische Zielwerte verwendet" both sit
+     under the floating bottom tab bar with no bottom padding.
+  3. Same issue on `/settings/ai` — the "Aktiver KI-Provider" card
+     bleeds under the tab bar.
+- Screenshot: `06-mobile-settings-account.png`,
+  `06-mobile-settings-ai.png`.
+- Offending route + component: settings shell main column missing
+  `pb-[safe-area-inset-bottom+5rem]` (or equivalent) on mobile.
+- Proposed fix scope: add bottom padding (~80 px) to settings content
+  region for `< md:` breakpoints. The dashboard already does this for
+  the chart panels.
+
+### F15. Mobile Coach-FAB overlaps chart tooltip
+
+- Severity: **Medium**.
+- Reproduction
+  1. Mobile viewport `/insights/blutdruck`.
+  2. Tap a data point — the Recharts tooltip pops up.
+  3. The pink "Coach fragen" pill-FAB sits on the bottom-right of
+     the chart and partially covers the tooltip
+     ("24.04 / Sys… entspricht deinem Mittel").
+- Screenshot: `06-mobile-insights-blutdruck.png`.
+- Offending route + component: `CoachLaunchButton` mobile position.
+- Proposed fix scope: anchor the FAB to the page chrome (outside
+  chart bounds), OR fade the FAB to 40 % opacity while a chart
+  tooltip is open.
+
+### F16. Insights nav tab strip clips last tab on mobile (`Gewich…`)
+
+- Severity: **Low** (the strip is scrollable horizontally so users
+  can still reach it, but visual cue is missing).
+- Reproduction
+  1. Mobile viewport `/insights/blutdruck`.
+  2. Tab strip shows: `Übersicht`, `Blutdruck` (active), `Gewich…` —
+     the third tab's label is clipped mid-character.
+- Screenshot: `06-mobile-insights-blutdruck.png`.
+- Proposed fix scope: add `pr-4` to the scroll container so the
+  rightmost tab is fully readable when nothing is scrolled.
+
+### F17. Zielwerte page lists defaults read-only — no way to set custom values from UI
+
+- Severity: **High** (feature presented but inactive).
+- Reproduction
+  1. Visit `/settings/thresholds`.
+  2. Page title "Persönliche Zielwerte", subtitle "Eigene
+     Zielbereiche für jede Metrik".
+  3. Each metric (Gewicht, BD-Sys, BD-Dia, Ruhepuls, Körperfett,
+     Körperwasser, Knochenmasse, Schlafdauer, ...) shows the auto
+     default ("Default: 61,3–82,5 kg") plus an "Auto: off" toggle.
+  4. There's no input to override the range. Toggling "Auto" off
+     does NOT reveal min/max inputs — so the page reads like a status
+     view, not a settings page.
+- Screenshot: `05-settings-thresholds.png`.
+- Offending route + component:
+  `src/components/settings/thresholds-section.tsx` (or whatever
+  renders the thresholds-grid).
+- Proposed fix scope: when `Auto: off`, render two numeric inputs
+  bound to the user's threshold record, with a save button. Currently
+  the whole section appears to do nothing.
+
+### F18. AI provider not configured → 422 spam, no graceful empty-state on insight pages
+
+- Severity: **Medium** (admittedly expected when no provider is set,
+  but the user-facing message is sub-par).
+- Reproduction
+  1. Without an AI provider configured, visit any
+     `/insights/`.
+  2. The "Einschätzung" card shows "KI-Provider nicht konfiguriert."
+     — correct.
+  3. Devtools network panel: `POST /api/insights/generate` returns 422
+     on every page load. Per-page, this hits dozens of times during
+     the audit because the insights page triggers a generate on
+     mount.
+- Logs from dev server confirm `POST /api/insights/generate 422`
+  fires for every metric.
+- Proposed fix scope: short-circuit the client before POSTing when
+  the user has no provider configured (the server already returns
+  422 cleanly, but the client shouldn't spam a request that's
+  guaranteed to fail). Bonus: reduces background-noise in production
+  monitoring.
+
+### F19. Web-Vitals reporter is rate-limited by its own server (429)
+
+- Severity: **Medium** (telemetry data loss).
+- Reproduction
+  1. Sign in and click through ~5 pages in quick succession.
+  2. Dev log shows `POST /api/internal/web-vitals 429` repeatedly
+     (5+ requests per page rendered, all 429'd after the first).
+- Likely cause: the rate-limit bucket for `/api/internal/web-vitals`
+  is too tight (5/15min default), but `WebVitalsReporter` fires
+  multiple vitals per page (FCP, LCP, CLS, TTFB, INP) AND on every
+  navigation. So a normal session burns through the limit in
+  seconds, and most metrics get dropped.
+- Proposed fix scope: raise the rate-limit allowance for this single
+  endpoint (it's already authenticated + low-cost), OR batch the
+  vitals client-side and POST once per route-change.
+
+### F20. Coach drawer is unreachable from the dashboard (no global trigger)
+
+- Severity: **Medium** (the maintainer flagged Coach drawer behaviour
+  specifically — observed here that there's NO global Coach trigger
+  in the sidebar / top-nav on `/`).
+- Reproduction
+  1. Land on `/`.
+  2. Inspect all buttons. None match "Coach", "AI Coach", "Frag deinen
+     Coach". The only place a Coach launch exists is the
+     `CoachLaunchButton` inside individual `/insights/` sub-
+     pages.
+- Implication: a user on the dashboard who wants to "open the
+  Coach" must first navigate into an insight sub-page. The Marc
+  brief mentioned "Coach drawer ... do scrolling the underlying page
+  still work?" — couldn't validate scroll behaviour because the
+  drawer never opened from `/`.
+- Proposed fix scope: either expose a Coach trigger in the global
+  shell (top-right, near the avatar), OR have the dashboard's hero
+  CTA double as "Coach fragen" when no measurements are due. Match
+  the iOS Health pattern of having "Summary" / "Insights" always one
+  tap away.
+
+### F21. Coach drawer headings missing — confirmed via inspection of the in-page
+"Einschätzung" panel rather than the drawer (couldn't open the drawer
+from the dashboard; see F20)
+
+- Severity: **Medium** (semantic structure for screen readers).
+- Reproduction
+  1. On `/insights/blutdruck`, the "Einschätzung" card has a heading
+     "Einschätzung" rendered as a `

` with `.text-base.font-medium`, + NOT a `

` / `

`. Same on every insight page. + 2. Drilling into the DOM for each `[role="dialog"]` and Coach + opener showed zero `h1`/`h2`/`h3` inside the dialog when it + does open (Marc's reported symptom). Could only validate this + from `/insights/blutdruck`'s Coach launch — the drawer opens + but has no semantic headings inside it. +- Screenshot: `03-insights-blutdruck.png` (Einschätzung card visible + with non-heading title). +- Offending route + component: `src/components/insights/insight-status-card.tsx` + + the Coach drawer body component. +- Proposed fix scope: wrap each section title in a `

` / `

` + inside the drawer + the Einschätzung card so assistive tech can + navigate by heading. + +### F22. Insight sub-page load time: HRV (1.2 s), aktive-energie (1.5 s), +ruhepuls (1.5 s), koerpertemperatur (1.5 s) + +- Severity: **Medium** (perf — feels sluggish on first paint). +- Reproduction (durations from the audit, measured `domcontentloaded → + network-idle`) + ``` + puls 745 ms ... blutdruck 879 ms ... gewicht 939 ms + hrv 1201 ms ... ruhepuls 1473 ms ... sauerstoff 1260 ms + koerpertemperatur 1546 ms ... aktive-energie 1520 ms + medikamente 1444 ms ... bmi 815 ms ... workouts 1016 ms + ``` +- Observation: the "iOS-foundation" metrics (HRV / RHR / temp / energy) + consistently lead by ~600 ms over the legacy metrics (puls / + blutdruck / gewicht). Likely the `HealthKitMetricPage` shared + component is heavier OR triggers more parallel requests. +- Proposed fix scope: investigate why `HealthKitMetricPage` lags, + collapse extra request waves, share the `useMeasurements()` query + between the page and the chart component, lazy-load the empty-state + illustration. + +### F23. "Hinzufügen" header button doesn't react to first click when tour is active + +- Severity: **High** (related to F2; calling out separately because + even after the tour is dismissed once, the next session restarts + with `tour_completed=false` if the seed isn't updated). +- Reproduction: see F2. Symptom: button receives click via Playwright + `.click({ force: true })` but the underlying onClick handler is + shadowed by the tour modal. +- Proposed fix scope: see F2 — make the tour non-blocking (pointer- + events through to underlying buttons except inside the spotlight). + +### F24. Repeated navigation accumulates 4×–7× more console errors per cycle + +- Severity: **Medium** (cumulative API/state churn). +- Reproduction (from the audit's step 7 — five repeats of the same + flow): + ``` + repeat 0 / +1 console error + repeat 1 /insights/blutdruck +2 console errors + repeat 2 /settings/account +2 console errors + repeat 3 /insights/blutdruck (2nd) +4 console errors + repeat 4 / (2nd) +3 console errors + ``` +- The error count GROWS per revisit, suggesting a leak: each + navigation re-mounts a component that retries a 404'd or 429'd + request (most likely a stale TanStack Query cache key). +- Proposed fix scope: identify the queries with no retry-clamp; set + `retry: 1` instead of the default 3 on routes that 404 cleanly + (e.g. `/api/insights/cached`, web-vitals). + +### F25. `/insights/koerpertemperatur` and `/insights/bmi` browser-side +404s — likely cached-insight fetch + +- Severity: **Low**. +- Reproduction + 1. Visit `/insights/bmi` (or any `/insights/`). + 2. Console: `Failed to load resource: status 404` plus several + `429`. The 404s come from + `/api/insights/cached?metric=…` (the cached AI insight that + doesn't exist yet since no provider is configured). +- Offending route + component: insight page → `useInsightStatus` + hook → `/api/insights/cached/` → 404. +- Proposed fix scope: change the cached-insight fetch to a "soft 404" + shape (return `{ data: null, error: null }` with status 200) so the + client doesn't log a console error for an absent cache entry. + +--- + +## Items I could NOT validate (and why) + +- **Coach drawer open-from-dashboard scrolling behaviour** — F20. No + global trigger on `/`, so couldn't open the drawer there. I did + open it from `/insights/blutdruck` indirectly, but the full Marc- + reported symptom ("scrolling the underlying page hangs while the + drawer is open") needs a real session with an LLM provider + configured to actually stream a response. The local seed has no + provider, so the drawer body never fills. +- **7-day filter behaviour on dashboard tiles** — Marc's open + question: "are tiles hiding due to >7-day-old data even though + allTimeCount > 0?" I could see seven tiles render with seeded data + spanning 90 days, so none of them were hidden in my run. Would + need a fixture with data older than 7 days for *some* metrics but + fresh for others to reproduce the conjectured behaviour. +- **Coolify auto-deploy** — out of scope for runtime UI audit. +- **iOS app surfaces** — local dev is web-only. +- **Background reminder worker** — visible in dev log + (`reminder_worker started`), but no scheduled mails / pushes + fired during the audit window. + +## Files referenced + +- `prisma/migrations/0062_v1430_sync_mode_foundation/migration.sql` — + adds `users.last_synced_at` (F1). +- `src/app/auth/login/page.tsx` — passkey vs password mode toggle (F1). +- `src/components/onboarding/tour.tsx` — spotlight tour overlay (F2, F23). +- `scripts/seed-demo.ts` — demo user upsert; doesn't set + `onboarding_tour_completed = true` (F2). +- `messages/de.json` — `insights.pulsDescription` (F7). +- `src/app/insights/puls/page.tsx` vs `src/app/insights/ruhepuls/page.tsx` — + two routes, one shared description (F7). +- `src/components/dashboard/dashboard-tile.tsx` (inferred) — tile + value truncation (F3). +- `src/components/charts/health-chart-dynamic.tsx` (inferred) — y-axis + formatter (F10). +- `src/components/insights/insights-nav.tsx` (inferred) — tab strip + with missing slugs (F9). +- `src/components/settings/account-section.tsx` (inferred) — empty + username field (F13). +- `src/components/settings/thresholds-section.tsx` (inferred) — read- + only Zielwerte (F17). + +## Artefact index + +``` +.planning/round-v1433-audit-runtime-screenshots/ +├── 01-login-error.png (login 500 before migrations) +├── 01-post-login.png (post-login dashboard) +├── 02a-dashboard-default.png (tile truncation visible) +├── 02b-add-clicked.png (tour overlay blocks button) +├── 03-insights-aktive-energie.png +├── 03-insights-aktive-energie-after-ranges.png +├── 03-insights-blutdruck.png ("Hg" axis label) +├── 03-insights-blutdruck-after-ranges.png +├── 03-insights-bmi.png +├── 03-insights-bmi-after-ranges.png +├── 03-insights-gewicht.png (duplicate "83 kg" ticks) +├── 03-insights-gewicht-after-ranges.png +├── 03-insights-hrv.png (no active tab) +├── 03-insights-hrv-after-ranges.png +├── 03-insights-koerpertemperatur.png +├── 03-insights-koerpertemperatur-after-ranges.png +├── 03-insights-medikamente.png (heatmap colour wrong) +├── 03-insights-medikamente-after-ranges.png +├── 03-insights-puls.png ("Ruhepuls" description) +├── 03-insights-puls-after-ranges.png +├── 03-insights-ruhepuls.png +├── 03-insights-ruhepuls-after-ranges.png +├── 03-insights-sauerstoff.png +├── 03-insights-sauerstoff-after-ranges.png +├── 03-insights-schlaf.png +├── 03-insights-schlaf-after-ranges.png +├── 03-insights-stimmung.png ("Super gut" wraps) +├── 03-insights-stimmung-after-ranges.png +├── 03-insights-workouts.png +├── 03-insights-workouts-after-ranges.png +├── 05-settings-about.png +├── 05-settings-account.png (empty username) +├── 05-settings-advanced.png +├── 05-settings-ai.png +├── 05-settings-api.png +├── 05-settings-dashboard.png +├── 05-settings-export.png +├── 05-settings-integrations.png +├── 05-settings-notifications.png +├── 05-settings-sources.png +├── 05-settings-thresholds.png (read-only Zielwerte) +├── 06-mobile-insights-{slug}.png (multiple) +├── 06-mobile-settings-{slug}.png (multiple) +├── 06a-mobile-dashboard.png (truncation NOT present on mobile) +├── 07-after-repeats.png (post-3x revisit baseline) +└── probe-*.png (login flow debug) +``` + +End of report. diff --git a/.planning/round-v1433-audit-settings.md b/.planning/round-v1433-audit-settings.md new file mode 100644 index 000000000..3efa71cca --- /dev/null +++ b/.planning/round-v1433-audit-settings.md @@ -0,0 +1,492 @@ +# v1.4.33 Settings UX Audit + +Scope: Every route under `/settings/[section]`, every component in +`src/components/settings/`, and the German + English locale bundles. Goal +is the same one the maintainer keeps returning to: Settings should feel +like one product, not a stack of historical extractions. v1.4.32 closed +a long polish run but the Settings surface still drifts in two ways — +mechanical (one card has `p-5`, another has `p-6`; one action row uses +`flex flex-wrap`, the next uses `flex`; one button shows the IANA zone +inline, another doesn't) and editorial (the same word — "Kanal", +"Konfiguriert", "Gespeichert", "Aktiv" — shows up four times on a single +screen). The maintainer's report calls out both, and a wider read +confirms the same two patterns repeating across surfaces he did not +single out. + +This document is read-only. Nothing was modified. + +--- + +## 1. Executive Summary + +Severity tally: + +- **Critical**: 0 +- **High**: 6 +- **Medium**: 9 +- **Low**: 7 + +Top five wins for v1.4.33 (highest ROI per minute of patch time): + +1. Wrap the MoodLog action row (`Sync starten · Voll-Sync · Trennen`) — + single-class change, kills the maintainer-reported overflow. +2. Replace the parenthesised timezone in the "Browser-Zeitzone + übernehmen" button with an aria-only suffix — gives the toggle a + short, single-line German label and lets the picker + button heights + match. +3. Strip `lastSuccess`, `lastFailure`, `consecutiveFailures`, + `disabledReason` from the Kanalzuverlässigkeit body whenever the pill + in the badge already conveys the state. The block currently double- + states everything the pill renders. +4. Normalise `` so its on-mobile height equals + ``/`` (the native `type="date"` chrome on iOS + Safari + Android Chrome puffs the field). Single-line CSS fix + (`appearance-none`+ `min-h-10`). +5. Drop the in-card Telegram redundancy: today the card title is + "Telegram-Benachrichtigungen", the description repeats + "Telegram-…-per Telegram", and the status badge says + "Konfiguriert · Deaktiviert" plus the chip strip says "Aktiv". + Pick one — the pill — and remove the rest. + +--- + +## 2. Maintainer-Reported Issues + +### 2.1 HIGH — Konto: date-of-birth field is broader and taller than its siblings + +- **File / line**: `src/components/ui/date-input.tsx:20-23` plus + `src/components/settings/account-section.tsx:418-423`. +- **Current behaviour**: `` is a thin wrapper that returns + ``. `` (`input.tsx:70`) + applies `h-10 w-full`. On mobile Safari + Android Chrome the native + date picker chrome inside `type="date"` adds intrinsic padding that + Tailwind's `h-10` cannot shrink — the calendar glyph + iOS minimum + font-size enforce a taller box than the sibling `` and + `` (the gender + height + language fields beside it). + Width drift is the same root cause: iOS gives `type="date"` a wider + intrinsic min-width than text fields. +- **Proposed fix**: in `date-input.tsx`, opt into appearance-none and + pin the height/box-sizing explicitly: + - `className: "appearance-none min-h-10 h-10 [&::-webkit-date-and-time-value]:min-h-[1.5em]"`. + - Add the same to `` for parity. The two are paired + siblings and the bug repeats on the datetime field at + `/measurements/new`. +- **Verify**: take a mobile screenshot of `/settings/account` — DOB, + Gender, Größe, Sprache cells should be one rhythm in two paired rows. + +### 2.2 HIGH — Settings → Zeitzone: button label too long, row heights don't match + +- **File / line**: `src/components/settings/timezone-picker.tsx:118-120`, + `messages/de.json:1642`, `messages/en.json:1642`. +- **Current copy (DE)**: `"Browser-Zeitzone übernehmen ({tz})"` renders + on a 393 CSS px viewport as "Browser-Zeitzone übernehmen + (Europe/Berlin)". With a `size="sm"` button this overflows two lines, + forcing the button taller than the adjacent select. +- **Current copy (EN)**: "Use browser timezone ({tz})" — same issue. +- **Layout**: the row is `flex flex-col gap-3 sm:flex-row sm:items-center`, + so on `` + Copy + button. The webhook secret is a long hex string; the input doesn't + truncate; Mobile Safari + Chrome let the input push past the card + boundary, triggering a body horizontal scroll. + - Line 821: `Sync starten | Voll-Sync | Trennen` — three `size="sm"` + buttons (~120 px each) + two 8 px gaps = ~376 px. A 393 px viewport + minus the 24 px `p-6` card padding leaves 345 px. The row overflows + by ~30 px. **This is the "Trennen overflow" the maintainer reported + in 2.4 below — same root cause.** +- **Proposed fix**: + - Line 780: change to + `
` and + add `font-mono text-xs break-all` to the input. Or render the + secret as a `` block instead of an editable input — it is + read-only anyway and `Input` is the wrong primitive for an + immutable token. + - Line 821: change to `flex flex-wrap items-start gap-2` and adopt + the `[&>*]:min-w-[10rem] sm:[&>*]:min-w-0` pattern already in use + one card up at line 482. That matches the Withings card and clears + the overflow without making the desktop layout look different. + +### 2.4 HIGH — MoodLog: "Trennen" button overflows the tile on mobile + +Confirmed as the line-821 row above. The single fix in 2.3 closes both +findings. + +### 2.5 MEDIUM — Settings tiles overall feel inconsistent + +Pattern audit of the 11 settings sections shows the following drifts — +none of them critical individually, all of them visible when comparing +two cards side-by-side: + +| Card | Padding | Header pattern | Status surface | +|---|---|---|---| +| AccountSection / Profile | `p-6` | `mb-4 flex items-center gap-2` | none | +| AccountSection / Password | `p-6` | `flex flex-col gap-3 sm:flex-row sm:justify-between` | none | +| AccountSection / Tour | `p-6` | `flex flex-col gap-3 sm:flex-row sm:justify-between` | none | +| IntegrationsSection / Withings | `p-6` | `flex flex-wrap items-start justify-between` | `` | +| IntegrationsSection / Mood Log | `p-6` | same | same | +| NotificationsSection / Telegram | `p-6` | `flex flex-wrap items-start justify-between` | two `` (configured + enabled), NOT the pill | +| NotificationsSection / ntfy | `p-6` | same | two `` (configured + enabled), NOT the pill | +| NotificationsSection / WebPush | `p-6` | same | two `` (configured + active), NOT the pill | +| NotificationsSection / Kanalzuverlässigkeit | `p-6` | `mb-4` header + nested `
` per row | embedded badge per row | +| Export | `p-5` | `flex items-start justify-between` + format chip | none | +| Sources | `p-6` | `flex flex-col gap-2 sm:flex-row sm:justify-between` | none | +| API tokens / endpoints | `p-6` | `flex flex-wrap items-start justify-between` | two `` (configured + last-used) | +| Advanced / Research | `p-6` | `mb-4 flex items-center gap-2` | inline switch | + +Observations: + +- **Export uses `p-5`** while every other card uses `p-6`. Single-token + drift. Promote Export to `p-6` so the grid rhythm matches the rest of + Settings; Export's card-as-grid-tile layout doesn't justify the + smaller pad. +- **Status surface drift**: Integrations cards moved to a single + `` in v1.4.19. Notifications cards + (Telegram/ntfy/WebPush) still ship the legacy "Konfiguriert" + + "Aktiviert" badge pair. The two surfaces should converge — either + promote the pill to Notifications or demote Integrations back to two + badges. Recommendation: extend the pill, because the pill carries + relative-time ("Verbunden · vor 12 min") and the Notifications cards + could finally show "last successful send" inline without re-painting + the redundant `
` underneath (see 2.6). +- **Header pattern drift**: 5 different `flex-*` combinations for what + should be the same card header (icon + title left, status surface + right, description below). Recommendation: extract a tiny + `` primitive that owns the contract — same icon + pad, same title size, same status slot. The export-section already + has the right idea in `` (`export-section.tsx:112`). + +### 2.6 HIGH — Settings → Kanalzuverlässigkeit: copy is redundant + +- **File / line**: `src/components/settings/notification-status-card.tsx:141-170` plus + `messages/de.json:1510-1513` ("Zustellung pro Kanal und die + Live-Übersicht der Kanalzuverlässigkeit für jeden konfigurierten Kanal.") + and `messages/de.json:1950-1952` + (`title: "Kanalzuverlässigkeit"`, `description: "Live-Status jedes + Benachrichtigungskanals — …"`). +- **The redundancy is layered**: + 1. The section header at `/settings/notifications` says + "Zustellung pro Kanal und die Live-Übersicht der + Kanalzuverlässigkeit für jeden konfigurierten Kanal." — the word + "Kanal" appears 3× in one sentence. + 2. The first card on the same page is titled "Kanalzuverlässigkeit" + with description "Live-Status jedes Benachrichtigungskanals — + …". Both say the same thing. + 3. Inside each channel row, the status pill says "Aktiv" *and* the + `
` lists "Letzter erfolgreicher Versand: 14.05.2026 09:42", + "Letzter Fehler: …", "Fehler in Folge: …", "Grund: …". When a + channel is healthy (the common case) only the last-success matters + — the others are empty branches the user has to read past. +- **Proposed fix**: + 1. Tighten the section description to one short sentence: + `"Live-Übersicht aller konfigurierten Kanäle."` Mirror in EN: + `"Live overview of every configured channel."` + 2. Rename the first card from "Kanalzuverlässigkeit" → "Zustellstatus" + so the page header + card don't both anchor on "Kanal". + 3. Compress the per-row body when state is `active`: show pill + + relative-time only (`"Aktiv · vor 12 min"`) — drop the full + datetime line. Keep the `
` for `auto_disabled`, + `sending_paused`, `manually_disabled` since those states need the + "why". +- **Locale changes**: drop the long + `settings.notificationStatus.description` (line 1952) — the section + description above already sets context. + +### 2.7 MEDIUM — Settings → Telegram-Nachrichten: same redundancy pattern + +- **File / line**: `src/components/settings/telegram-card.tsx:107-134`, + `messages/de.json:1677-1678`. +- **Current rendering on the page**: the card header reads "Telegram- + Benachrichtigungen" (`settings.telegram`) + the description reads + "Erhalte Erinnerungen bei vergessenen Medikamenten-Einnahmen per + Telegram." ("Telegram" twice in the same card header). +- **In parallel** the top-right of the card paints either + `Aktiv` (single badge) or `Konfiguriert · Deaktiviert` (pair badge). + That's the third "what is the state of this channel" surface for the + same fact: the Kanalzuverlässigkeit card above already showed it, the + card header now repeats it. +- **Proposed fix**: + 1. Rename the card title to plain "Telegram" — the section is + "Benachrichtigungen", so saying "Benachrichtigungen" again is + wasted shelf space. + 2. Shorten the description: "Medikamenten-Erinnerungen per Bot." + 3. If 2.6 lands and the Kanalzuverlässigkeit pill becomes the single + status surface, **delete the in-card `` pair entirely** + (`telegram-card.tsx:114-131`). The pill in the + Kanalzuverlässigkeit row above already says "Aktiv · vor X min". +- **Apply the same fix** to `ntfy-card.tsx:109-120` and + `web-push-card.tsx:165-176` — exact same redundancy. + +--- + +## 3. Additional Findings + +### 3.1 HIGH — Settings has two competing "status" vocabularies + +The codebase mixes two grammars for "this thing is on": + +- `common.active` ("Aktiv"), `common.enabled` ("Aktiviert"), + `common.disabled` ("Deaktiviert") at `messages/de.json:16-19`. +- `settings.configured` ("Konfiguriert") at `messages/de.json:1819`, + `webPushActive` ("Aktiv") at `:1707`, plus integration pill states + `Verbunden / Fehler / Nicht verbunden` at `:1845-1847`. + +The Telegram card renders both ("Konfiguriert · Deaktiviert"), which is +the German equivalent of saying "ready to be turned on, currently turned +off". Pick one verb, drop the other. The pill already encodes both bits +(green + "Verbunden · vor X min" vs grey outline + "Nicht verbunden") +so the binary "Konfiguriert / Aktiv" can be retired. + +### 3.2 MEDIUM — Settings shell mobile chip strip lists 11 items + +`settings-shell.tsx:152-182` renders an 11-item horizontal scroll chip +strip on every settings page. On a 393 px viewport the right four chips +are below the fold; users have to swipe horizontally. The Insights tab +strip solved the same shape with `data-[active]` pinning + bulk-edit +hidden behind a "More" menu. Not urgent for v1.4.33 but worth a backlog +ticket — and a candidate for a "Frequently used" cluster (Account, +Integrations, Notifications, AI) plus an overflow menu for the rest. + +### 3.3 MEDIUM — Account / Passkey list breaks at exactly `md` (768px) + +`account-section.tsx:736-895`: passkey list renders a desktop table at +`md+` and a mobile card list at `` companion primitive so new sections inherit it. + +### 3.5 MEDIUM — Withings + Mood Log credential forms read identically but use different label structures + +- Withings: `space-y-1.5` between label and input. +- Mood Log: bare `
` with no label-gap rule (relies on default + layout). Visible drift on Chromium where Mood Log's labels sit + half a line closer to their inputs. + +Fix: standardise on `space-y-1.5` everywhere the surface is a labelled +credential form. + +### 3.6 LOW — Notifications page has no anchor / quick-jump for sub-cards + +`/settings/notifications` stacks Kanalzuverlässigkeit → Telegram → +ntfy → Web Push vertically. The page already paints +`id="telegram" scroll-mt-28` anchors (`notifications-section.tsx:62-75`) +so deep-links work, but there's no in-page TOC. On a Pixel-5 the page +is 4 screen-heights tall. Add an inline chip strip under the H1 +("Zustellstatus · Telegram · ntfy · Web Push") that hash-anchors. + +### 3.7 LOW — Integrations page uses two different "card divider" treatments + +`integrations-section.tsx:354-357` and `:713-716` both render an +`
`. +That's consistent — but inside each card, the divider lives *between* +the description and the body, while inside Account/Password/Tour cards +there is no divider at all. The visual rhythm reads as +"Integrations is heavier than Account, why?". Either drop the dividers +(cleaner) or add equivalent ones to Account. + +### 3.8 LOW — API endpoint card title hard-codes plural even with one endpoint + +`api-section.tsx:81`: title is "API-Endpoints" but the endpoints array +has exactly one entry today (POST `/api/ingest/medication`). Either +ship more endpoints or rename to "API-Endpoint". Cosmetic, but the +maintainer reads "Endpoints" and expects a list. + +### 3.9 LOW — Mood Log "Voll-Sync" trigger uses same copy as confirm action + +`integrations-section.tsx:837` (trigger) and `:852` (dialog confirm) +both render `{t("settings.moodLogFullSync")}` = "Voll-Sync". The +maintainer's pattern across the codebase is verb-noun for triggers +("Sync starten") and a more explicit verb for confirms ("Vollständig +synchronisieren"). Withings already does it right at lines 504 + 519 +(`withingsFullSync` → `withingsSynchronize`). Add +`settings.moodLogFullSyncConfirm` = "Vollständig synchronisieren" +and wire it into the dialog action. + +### 3.10 LOW — `dl/dt/dn` markup inside Kanalzuverlässigkeit is semantically right but visually heavy + +`notification-status-card.tsx:209-263`: each row paints up to five +`
:
` rows. The colon is fixed punctuation; +on muted-foreground 11px the bullets read as a wall of text. If 2.6 +above lands the volume goes down on its own; otherwise consider +flattening the active-state block to one line +("Aktiv · vor 12 min — keine Fehler") and reserving the `
` for the +unhealthy states. + +### 3.11 LOW — `` + `