docs(planning): v1.4.33 quality-and-reliability audit pass

Five-agent audit pass plus the Playwright runtime sweep against v1.4.32.
The five audit documents land alongside the 58 runtime screenshots and
the runtime-results.json that drove the seven highest-severity
implementation rounds (IW1, IW3, IW4, IW6, IW8) on this branch. Future
contributors can trace each implementation commit back to a finding
without re-running the audits.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 17:40:18 +02:00
1 parent f389408169
commit 6135c32507
64 files changed
+3011

No files matched your search

+184
View File
@@ -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`) — `<AccountSection>` 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 `<DashboardLayoutSection>` card holding comparison-baseline picker + the widget toggle table.
5. **Thresholds** (`/settings/thresholds`) — single `<ThresholdsEditorSection>` card.
6. **Sources** (`/settings/sources`) — source-priority editor card + per-metric override list + device-type ordering.
7. **AI auswertungen** (`/settings/ai`) — single dropdown-driven `<AiInsightsCard>` (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
- `<SectionPlaceholder>` 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 `<sm` viewports the user sees roughly Overview + Blutdruck + half of Gewicht before the fade kicks in.
- **The five wave-A HealthKit pills sit at the end.** HRV, Ruhepuls, Sauerstoff, Körpertemperatur, Aktive Energie were added in v1.4.32 and stacked behind every existing pill. They are a coherent group ("vital signs from a wearable") and would naturally collapse into a single Vital-Signs sub-page with its own internal switcher, or into a single "Apple Health" pill that fans out the five metrics inside.
- **Body-composition group.** Gewicht + BMI are the same domain (weight-derived). The BMI sub-page already references the WEIGHT measurement series and adds a profile-height computation. Folding BMI into the Gewicht sub-page as a chart toggle drops a pill without losing functionality.
- **Pulse-domain redundancy.** Puls is the live heart-rate pill, Ruhepuls is resting HR, HRV is heart-rate variability. These three pills are three views on cardiovascular data. Grouping them under a "Herz" pill with internal tabs is the cleanest path and matches how Apple Health groups them.
- **Workouts is the right kind of new.** v1.4.32 added the pill alongside the dashboard tile + sub-page; the tab strip placement (after Schlaf, before the wave-A HealthKit cluster) reads correctly because it sits next to Sleep, which is also event-driven.
- **No literal duplicates** — each pill links to its own route and renders different content.
## 5. Mobile sheet menus
### Bottom-nav
Source of truth: `src/components/layout/bottom-nav.tsx` (`PRIMARY` + `OVERFLOW`).
- **Primary (5):** Dashboard, Messungen, Stimmung, Medikamente, Insights.
- **More sheet (2):** Zielwerte, Erfolge.
That decision (4-out-of-5 most-used + Insights, with Targets/Achievements moved into the More sheet) was made in v1.4.16 wave-C MED. It tracks the desktop sidebar's `navItems` ordering, so mobile and desktop are not diverging.
### Mobile Settings strip
Source of truth: the `<md:` branch inside `<SettingsShell>`. 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 `<TherapyTimeline>` 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 `<HeroStrip>` + `<DailyBriefing>` + `<CorrelationRow>` + `<TrendsRow>` 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 `<SectionPlaceholder>` 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.
+328
View File
@@ -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 `<InView>` 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<MeasurementType>` 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 `<Link>` `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<string, { result, expires }>` 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<MeasurementType>` 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 `<InsightsTabStrip>` 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 `<Link>` `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 `<CoachDrawer>` —
`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.
+527
View File
@@ -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 `<sm` branch in `src/app/page.tsx`
uses `flex overflow-x-auto` + `min-w-[10rem]` per tile, which both
yields a deliberate side-scroll on phone widths AND clones the same
visual contract twice (mobile-flex / desktop-grid). Replace with a
single `grid auto-rows-fr` track that picks `minmax(min(100%,9rem),
1fr)` from `xs:` onward; the strip wraps to two rows on Pixel 5
instead of scrolling. The maintainer explicitly cited "unwanted
horizontal scroll" as a polish complaint.
3. **Hoist the `min-h-[18px]` callout slot in `<TrendCard>` 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 `<SubPageShell>` 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 `<SuggestedPrompts>` and the
`<MessageThread>` 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] `<SubPageShell>` + `/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**: `<ChartSkeleton>` 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 `<sm`. On
`sm:` and up the grid track uses `auto-rows-fr`, so the strip's
height becomes a function of the tallest tile. The callout's
`min-h-[18px]` reservation in `<TrendCard>` 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 `<sm` the tile strip switches to `flex overflow-x-auto`
with `min-w-[10rem]` per tile + `snap-x snap-mandatory`. The
maintainer's complaint stack for v1.4.33 explicitly calls out the
side scroll as unwanted — what was a v1.4.27 fix (MB7 / CF-42) is
now a polish regression.
- **Cause**: The flex/scroll branch was chosen so the tile strip
doesn't wrap onto 3-4 rows on Galaxy Fold (280 px viewport). But
Pixel 5 (375 px) and iPhone-13-mini (375 px) both fit two tiles per
row at the current `min-w-[10rem]`, so the strip wraps to two rows
naturally if we drop `overflow-x-auto`.
- **Fix**: Lift the existing `sm:` grid contract to the default
branch — `grid auto-rows-fr [grid-template-columns:repeat(auto-fit,
minmax(min(100%,9rem),1fr))] gap-3`. Drop `flex snap-x
snap-mandatory`, `[--tile-h:140px]`, `overflow-x-auto pb-2`, and
the per-tile `min-w-[10rem] shrink-0 snap-start`. Galaxy Fold falls
back to a single column (every tile sized at the full viewport
width), which reads as a "tall single-column dashboard" — the same
shape the targets and settings pages already use at that width.
### 3.2 [Medium] API-section tables force a `min-w-[760px]` body inside `overflow-x-auto`
- **File**: `src/components/settings/api-section.tsx:90`, `:290`,
`:512` plus `src/components/settings/account-section.tsx:736`.
- **Symptom**: On desktop the API-tokens table is comfortable; on
tablet (md breakpoint kicks in at 768 px), the table renders with
a horizontal scrollbar because the `min-w-[760px]` floor exceeds
the column width. The shell gives the main column at most
`max-w-screen-xl - 220px sidebar - 32px gutters` ≈ 1028 px, which
is fine — but the wrapper width is `100%` of an internal flex
child, not the column. On a tablet emulator the table flickers
between fits + scroll states.
- **Cause**: The `min-w-[760px]` is conservative against the smallest
rendered API-tokens row (token name + last-used-at + revoke
button). Today's data shape needs ~560 px to render legibly.
- **Fix**: Drop to `min-w-[560px]` (or `min-w-[28rem]`), then verify
on the existing `api-section-responsive.test.tsx` fixtures that
the mobile card list still kicks in below 640 px.
### 3.3 [Medium] `medications/page.tsx` request-example `<pre>` 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 `<pre>`
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
`<Card>` defaults to `p-6`) vs.
`src/components/insights/daily-briefing.tsx:260` (shadcn `<Card>`,
`<CardHeader pb-3>` + `<CardContent>` 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 `<Card>` default, which is
`gap-6 py-6 px-6` plus `<CardHeader pb-3>`. 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
`<Card>` 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] `<TrendsRow>` and `<CorrelationRow>` 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 `<EmptyState>` component so
all empty surfaces share a single layout.
### 4.6 [Low] `<HealthScoreCard>` 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 | `<SuggestedPrompts>` + 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 `<HealthScoreCard>` (delta caption, component
weight chips), `<CorrelationCard>` (confidence pill), and
`<DailyBriefing>` (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 `<Card>`
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
`<pre>`. (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 `<HealthScoreCard>` 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
`<EmptyState>`. (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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

@@ -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": []
}
+617
View File
@@ -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/<slug>`.
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/<metric>` 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 `<p>` with `.text-base.font-medium`,
NOT a `<h2>` / `<h3>`. 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 `<h2>` / `<h3>`
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/<slug>`).
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/<metric>` → 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.
+492
View File
@@ -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 `<DateInput>` so its on-mobile height equals
`<Input>`/`<NativeSelect>` (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**: `<DateInput>` is a thin wrapper that returns
`<Input type="date" lang={locale} />`. `<Input>` (`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 `<Input>` and
`<NativeSelect>` (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 `<DateTimeInput>` 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 `<sm` the button is its own row and height mismatch matters less,
but on `sm+` (where the maintainer also reports it) the button sits next
to the IANA select and the two `h-10` controls drift apart whenever the
button wraps to two lines.
- **Proposed fix (preferred)**: drop the `(${tz})` segment from the
visible label — IANA is already shown in the select beside the button.
Keep `tz` in the `aria-label` so screen-reader users still hear the
exact zone the click will apply. The button is identity ("Browser-
Zeitzone übernehmen"); the select is value ("Europe/Berlin").
- **Alternative**: if the maintainer wants the zone visible, render it
on a dedicated muted line below the picker block ("Browser meldet
Europe/Berlin") so the action button stays single-line.
- **Locale fix**: rename the key
`settings.timezoneDetect` → `settings.timezoneDetect.label`
("Browser-Zeitzone übernehmen") + `settings.timezoneDetect.aria`
("Browser-Zeitzone Europe/Berlin übernehmen") to keep the
accessibility tree intact.
### 2.3 HIGH — Settings → Integrations: viewport sometimes broken, odd scroll
- **File / line**:
`src/components/settings/integrations-section.tsx:780-805` (Mood Log
webhook-secret input + copy button), `:821-891` (Mood Log action row).
- **Current behaviour**: two `flex gap-2` rows (no `flex-wrap`) inside
the Mood Log card.
- Line 780: `<Input value={status.webhookSecret} readOnly>` + 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
`<div className="flex flex-col gap-2 sm:flex-row sm:gap-2">` and
add `font-mono text-xs break-all` to the input. Or render the
secret as a `<code>` 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` | `<IntegrationStatusPill>` |
| IntegrationsSection / Mood Log | `p-6` | same | same |
| NotificationsSection / Telegram | `p-6` | `flex flex-wrap items-start justify-between` | two `<Badge>` (configured + enabled), NOT the pill |
| NotificationsSection / ntfy | `p-6` | same | two `<Badge>` (configured + enabled), NOT the pill |
| NotificationsSection / WebPush | `p-6` | same | two `<Badge>` (configured + active), NOT the pill |
| NotificationsSection / Kanalzuverlässigkeit | `p-6` | `mb-4` header + nested `<dl>` 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 `<Badge>` (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
`<IntegrationStatusPill>` 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 `<dl>` 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
`<SettingsCardHeader>` primitive that owns the contract — same icon
pad, same title size, same status slot. The export-section already
has the right idea in `<ExportCardShell>` (`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
`<dl>` 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 `<dl>` 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 `<Badge>` 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 `<md`. The breakpoint is `md` (768 px).
Tablets (iPad mini portrait = 768 px) land right at the inflection and
flip-flop between layouts on rotation. Recommendation: bump the
breakpoint to `lg` for this surface specifically — passkey tables need
the wide column to read; the card list works fine on iPad portrait.
### 3.4 MEDIUM — Save buttons are inconsistently placed across forms
- AccountSection profile form: `flex justify-end` (right-aligned).
- Withings credentials form: `flex justify-end` + `w-full sm:w-auto`
(right-aligned desktop, full-width mobile).
- Telegram form: `flex justify-end gap-2` (two buttons right-aligned).
- ntfy form: `flex justify-end gap-2`.
- Mood Log form: `flex flex-wrap items-start gap-2` (LEFT-aligned).
- API token create form: button beside input, no submit footer.
The eye sees "Speichern" in five different positions. Recommendation:
all primary save buttons right-aligned, full-width on `<sm`, secondary
"Test" buttons immediately left of save. Lift the rule into the same
`<SettingsCardHeader>` 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 `<div>` 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
`<hr data-testid="integration-card-divider" className="border-border/60 mt-4">`.
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
`<div><dt>:</dt> <dd></dd></div>` 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 `<dl>` for the
unhealthy states.
### 3.11 LOW — `<Switch>` + `<Label>` pairs use four different layouts
- Telegram: `flex items-center gap-3` (label after switch).
- ntfy: `flex items-center justify-between` (label before switch).
- Advanced (Research): inline switch in card header.
- Settings → Web Push: button instead of switch.
Pick one. The de-facto winner is "label left, switch right, justified" —
matches every toggle on `/settings/dashboard`.
---
## 4. Locale String Redundancy
### 4.1 Duplicate keys for the same surface
| Concept | DE key A | DE key B | Recommended single key |
|---|---|---|---|
| "Gespeichert" | `common.saved` (line 12) | `settings.saved` (line 1888) | `common.saved` |
| "Konfiguriert" | `settings.configured` (1819) | `admin.…configured` (2068) | namespace-scoped is fine, but de-dup the surface that renders both |
| "Aktiv" | `common.active` (16) | `webPushActive` (1707), `tokenActive` (1862), `admin.…active` (2879) | adopt `common.active` everywhere except where status-specific copy matters |
| "Token-Name" | `tokenNamePlaceholder` (1856) | implicit | nothing to fix; flagged because it's the only token field without a top-level label key |
### 4.2 Verbose section descriptions to compress
- `settings.sections.notifications.description` (1512): 13 words, 3 ×
"Kanal". Recommendation: "Live-Übersicht aller konfigurierten Kanäle."
- `settings.notificationStatus.description` (1952): 21 words. Tucks the
Auto-disabled definition inside the surface description; move that
definition to the badge tooltip instead.
- `settings.telegramDescription` (1678): "Telegram" twice. Recommend
"Medikamenten-Erinnerungen per Bot."
- `settings.moodLogDescription` (1891): "moodLog" twice — "Stimmungs-
daten aus moodLog importieren". Recommend "Stimmungsdaten aus dem
moodLog-Dienst übernehmen." (or simpler: "Stimmungseinträge
übernehmen.").
### 4.3 Inconsistent disconnect / trennen wording
- `settings.withingsDisconnect` (line 1827): "Trennen"
- `settings.moodLogDisconnect` (1902): "Trennen"
- `settings.codexDisconnected` etc.: "getrennt"
- Notifications has no "trennen" — uses Switch toggling instead.
`common.disconnect` would consolidate the trigger copy; the dialog
title strings ("Withings trennen?", "moodLog trennen?") stay
integration-specific.
### 4.4 EN parity holds
`messages/en.json` mirrors the DE bundle 1:1 on every key inspected in
this audit. Any redundancy fix needs to land in both files in the same
commit. The i18n parity test
(`src/components/settings/__tests__/sections-i18n-parity.test.ts`)
should catch any one-side drop.
---
## 5. Punch List (ordered by severity)
### High
1. **DOB field height/width** — `src/components/ui/date-input.tsx`, scope
= single component change. Effect: every `<DateInput>` callsite
(settings, measurements/new, admin user editor).
2. **Timezone button label** — `messages/{de,en}.json` key restructure
+ `src/components/settings/timezone-picker.tsx:118-121`.
3. **MoodLog action row + webhook input overflow** —
`src/components/settings/integrations-section.tsx:780-805` and
`:821`.
4. **Kanalzuverlässigkeit redundancy** —
`src/components/settings/notification-status-card.tsx` rendering of
active-state rows + `messages/{de,en}.json:1510-1513,1950-1952`.
5. **Telegram card redundancy** —
`src/components/settings/telegram-card.tsx:107-134`,
`messages/{de,en}.json:1677-1678`, parity in `ntfy-card.tsx` and
`web-push-card.tsx`.
6. **Status vocabulary unification** — locale only; refactor
`Konfiguriert · Deaktiviert` → single pill across Telegram, ntfy,
Web Push.
### Medium
7. **Tile padding parity (Export `p-5` → `p-6`)** —
`src/components/settings/export-section.tsx:125`.
8. **Header pattern primitive** — extract `<SettingsCardHeader>` into
`src/components/settings/_card-header.tsx`; collapse 5 variants in
`integrations-section.tsx`, `notification-status-card.tsx`,
`telegram-card.tsx`, `ntfy-card.tsx`, `web-push-card.tsx`,
`account-section.tsx`, `api-section.tsx`.
9. **Save-button placement contract** — formalise right-aligned
primary + secondary order; fix Mood Log left-alignment outlier at
`integrations-section.tsx:756`.
10. **Account passkey breakpoint** — `account-section.tsx:736` flip
`md:` → `lg:` for the table-vs-cards switch.
11. **Settings shell chip strip overflow** —
`src/components/settings/settings-shell.tsx:152-182`; cluster +
overflow menu, not in v1.4.33 scope necessarily.
12. **Credential form label spacing** — `space-y-1.5` everywhere in
Withings + Mood Log + Telegram + ntfy forms.
13. **Native select / input height parity** — re-verify post-DOB-fix
that every row on `/settings/account` aligns at exactly 40 px.
14. **Section description compression** — 4 strings flagged in 4.2.
15. **Notification anchor strip** — small in-page TOC on
`/settings/notifications`.
### Low
16. **Integrations card dividers** — drop or apply consistently across
the whole settings surface.
17. **API endpoint singular/plural** — title + i18n key.
18. **Mood Log Voll-Sync dialog confirm copy** — add
`moodLogFullSyncConfirm` in locale + wire at line 852.
19. **Kanalzuverlässigkeit `<dl>` flattening** — secondary to (4).
20. **Switch + label layout drift** — pick one pattern.
21. **`common.disconnect` consolidation** — locale key.
22. **EN parity audit** — gate via the existing parity test.
---
## Closing Note
The maintainer's read — "feels not fully thought-through" — is accurate
and the diagnosis is convergence drift across an 11-section surface
that was historically extracted card-by-card. Three structural moves
unlock most of the polish in one round:
1. One `<SettingsCardHeader>` primitive that every card consumes,
carrying the icon, title, optional pill slot, and description with a
fixed pad rhythm.
2. One status surface — the `<IntegrationStatusPill>` already used by
Integrations — promoted to Notifications so Telegram/ntfy/Web Push
stop double-stating "Konfiguriert · Aktiviert" alongside the
Kanalzuverlässigkeit pill.
3. One copy pass that drops repeated nouns ("Kanal" ×3,
"Telegram" ×2, "moodLog" ×2) and consolidates the
"Aktiv/Konfiguriert/Verbunden/Aktiviert" thicket into one vocabulary.
Everything in §3 falls out for free once those three land.