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

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

` / `

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

` / `

` + inside the drawer + the Einschätzung card so assistive tech can + navigate by heading. + +### F22. Insight sub-page load time: HRV (1.2 s), aktive-energie (1.5 s), +ruhepuls (1.5 s), koerpertemperatur (1.5 s) + +- Severity: **Medium** (perf — feels sluggish on first paint). +- Reproduction (durations from the audit, measured `domcontentloaded → + network-idle`) + ``` + puls 745 ms ... blutdruck 879 ms ... gewicht 939 ms + hrv 1201 ms ... ruhepuls 1473 ms ... sauerstoff 1260 ms + koerpertemperatur 1546 ms ... aktive-energie 1520 ms + medikamente 1444 ms ... bmi 815 ms ... workouts 1016 ms + ``` +- Observation: the "iOS-foundation" metrics (HRV / RHR / temp / energy) + consistently lead by ~600 ms over the legacy metrics (puls / + blutdruck / gewicht). Likely the `HealthKitMetricPage` shared + component is heavier OR triggers more parallel requests. +- Proposed fix scope: investigate why `HealthKitMetricPage` lags, + collapse extra request waves, share the `useMeasurements()` query + between the page and the chart component, lazy-load the empty-state + illustration. + +### F23. "Hinzufügen" header button doesn't react to first click when tour is active + +- Severity: **High** (related to F2; calling out separately because + even after the tour is dismissed once, the next session restarts + with `tour_completed=false` if the seed isn't updated). +- Reproduction: see F2. Symptom: button receives click via Playwright + `.click({ force: true })` but the underlying onClick handler is + shadowed by the tour modal. +- Proposed fix scope: see F2 — make the tour non-blocking (pointer- + events through to underlying buttons except inside the spotlight). + +### F24. Repeated navigation accumulates 4×–7× more console errors per cycle + +- Severity: **Medium** (cumulative API/state churn). +- Reproduction (from the audit's step 7 — five repeats of the same + flow): + ``` + repeat 0 / +1 console error + repeat 1 /insights/blutdruck +2 console errors + repeat 2 /settings/account +2 console errors + repeat 3 /insights/blutdruck (2nd) +4 console errors + repeat 4 / (2nd) +3 console errors + ``` +- The error count GROWS per revisit, suggesting a leak: each + navigation re-mounts a component that retries a 404'd or 429'd + request (most likely a stale TanStack Query cache key). +- Proposed fix scope: identify the queries with no retry-clamp; set + `retry: 1` instead of the default 3 on routes that 404 cleanly + (e.g. `/api/insights/cached`, web-vitals). + +### F25. `/insights/koerpertemperatur` and `/insights/bmi` browser-side +404s — likely cached-insight fetch + +- Severity: **Low**. +- Reproduction + 1. Visit `/insights/bmi` (or any `/insights/`). + 2. Console: `Failed to load resource: status 404` plus several + `429`. The 404s come from + `/api/insights/cached?metric=…` (the cached AI insight that + doesn't exist yet since no provider is configured). +- Offending route + component: insight page → `useInsightStatus` + hook → `/api/insights/cached/` → 404. +- Proposed fix scope: change the cached-insight fetch to a "soft 404" + shape (return `{ data: null, error: null }` with status 200) so the + client doesn't log a console error for an absent cache entry. + +--- + +## Items I could NOT validate (and why) + +- **Coach drawer open-from-dashboard scrolling behaviour** — F20. No + global trigger on `/`, so couldn't open the drawer there. I did + open it from `/insights/blutdruck` indirectly, but the full Marc- + reported symptom ("scrolling the underlying page hangs while the + drawer is open") needs a real session with an LLM provider + configured to actually stream a response. The local seed has no + provider, so the drawer body never fills. +- **7-day filter behaviour on dashboard tiles** — Marc's open + question: "are tiles hiding due to >7-day-old data even though + allTimeCount > 0?" I could see seven tiles render with seeded data + spanning 90 days, so none of them were hidden in my run. Would + need a fixture with data older than 7 days for *some* metrics but + fresh for others to reproduce the conjectured behaviour. +- **Coolify auto-deploy** — out of scope for runtime UI audit. +- **iOS app surfaces** — local dev is web-only. +- **Background reminder worker** — visible in dev log + (`reminder_worker started`), but no scheduled mails / pushes + fired during the audit window. + +## Files referenced + +- `prisma/migrations/0062_v1430_sync_mode_foundation/migration.sql` — + adds `users.last_synced_at` (F1). +- `src/app/auth/login/page.tsx` — passkey vs password mode toggle (F1). +- `src/components/onboarding/tour.tsx` — spotlight tour overlay (F2, F23). +- `scripts/seed-demo.ts` — demo user upsert; doesn't set + `onboarding_tour_completed = true` (F2). +- `messages/de.json` — `insights.pulsDescription` (F7). +- `src/app/insights/puls/page.tsx` vs `src/app/insights/ruhepuls/page.tsx` — + two routes, one shared description (F7). +- `src/components/dashboard/dashboard-tile.tsx` (inferred) — tile + value truncation (F3). +- `src/components/charts/health-chart-dynamic.tsx` (inferred) — y-axis + formatter (F10). +- `src/components/insights/insights-nav.tsx` (inferred) — tab strip + with missing slugs (F9). +- `src/components/settings/account-section.tsx` (inferred) — empty + username field (F13). +- `src/components/settings/thresholds-section.tsx` (inferred) — read- + only Zielwerte (F17). + +## Artefact index + +``` +.planning/round-v1433-audit-runtime-screenshots/ +├── 01-login-error.png (login 500 before migrations) +├── 01-post-login.png (post-login dashboard) +├── 02a-dashboard-default.png (tile truncation visible) +├── 02b-add-clicked.png (tour overlay blocks button) +├── 03-insights-aktive-energie.png +├── 03-insights-aktive-energie-after-ranges.png +├── 03-insights-blutdruck.png ("Hg" axis label) +├── 03-insights-blutdruck-after-ranges.png +├── 03-insights-bmi.png +├── 03-insights-bmi-after-ranges.png +├── 03-insights-gewicht.png (duplicate "83 kg" ticks) +├── 03-insights-gewicht-after-ranges.png +├── 03-insights-hrv.png (no active tab) +├── 03-insights-hrv-after-ranges.png +├── 03-insights-koerpertemperatur.png +├── 03-insights-koerpertemperatur-after-ranges.png +├── 03-insights-medikamente.png (heatmap colour wrong) +├── 03-insights-medikamente-after-ranges.png +├── 03-insights-puls.png ("Ruhepuls" description) +├── 03-insights-puls-after-ranges.png +├── 03-insights-ruhepuls.png +├── 03-insights-ruhepuls-after-ranges.png +├── 03-insights-sauerstoff.png +├── 03-insights-sauerstoff-after-ranges.png +├── 03-insights-schlaf.png +├── 03-insights-schlaf-after-ranges.png +├── 03-insights-stimmung.png ("Super gut" wraps) +├── 03-insights-stimmung-after-ranges.png +├── 03-insights-workouts.png +├── 03-insights-workouts-after-ranges.png +├── 05-settings-about.png +├── 05-settings-account.png (empty username) +├── 05-settings-advanced.png +├── 05-settings-ai.png +├── 05-settings-api.png +├── 05-settings-dashboard.png +├── 05-settings-export.png +├── 05-settings-integrations.png +├── 05-settings-notifications.png +├── 05-settings-sources.png +├── 05-settings-thresholds.png (read-only Zielwerte) +├── 06-mobile-insights-{slug}.png (multiple) +├── 06-mobile-settings-{slug}.png (multiple) +├── 06a-mobile-dashboard.png (truncation NOT present on mobile) +├── 07-after-repeats.png (post-3x revisit baseline) +└── probe-*.png (login flow debug) +``` + +End of report. diff --git a/.planning/round-v1433-audit-settings.md b/.planning/round-v1433-audit-settings.md new file mode 100644 index 000000000..3efa71cca --- /dev/null +++ b/.planning/round-v1433-audit-settings.md @@ -0,0 +1,492 @@ +# v1.4.33 Settings UX Audit + +Scope: Every route under `/settings/[section]`, every component in +`src/components/settings/`, and the German + English locale bundles. Goal +is the same one the maintainer keeps returning to: Settings should feel +like one product, not a stack of historical extractions. v1.4.32 closed +a long polish run but the Settings surface still drifts in two ways — +mechanical (one card has `p-5`, another has `p-6`; one action row uses +`flex flex-wrap`, the next uses `flex`; one button shows the IANA zone +inline, another doesn't) and editorial (the same word — "Kanal", +"Konfiguriert", "Gespeichert", "Aktiv" — shows up four times on a single +screen). The maintainer's report calls out both, and a wider read +confirms the same two patterns repeating across surfaces he did not +single out. + +This document is read-only. Nothing was modified. + +--- + +## 1. Executive Summary + +Severity tally: + +- **Critical**: 0 +- **High**: 6 +- **Medium**: 9 +- **Low**: 7 + +Top five wins for v1.4.33 (highest ROI per minute of patch time): + +1. Wrap the MoodLog action row (`Sync starten · Voll-Sync · Trennen`) — + single-class change, kills the maintainer-reported overflow. +2. Replace the parenthesised timezone in the "Browser-Zeitzone + übernehmen" button with an aria-only suffix — gives the toggle a + short, single-line German label and lets the picker + button heights + match. +3. Strip `lastSuccess`, `lastFailure`, `consecutiveFailures`, + `disabledReason` from the Kanalzuverlässigkeit body whenever the pill + in the badge already conveys the state. The block currently double- + states everything the pill renders. +4. Normalise `` so its on-mobile height equals + ``/`` (the native `type="date"` chrome on iOS + Safari + Android Chrome puffs the field). Single-line CSS fix + (`appearance-none`+ `min-h-10`). +5. Drop the in-card Telegram redundancy: today the card title is + "Telegram-Benachrichtigungen", the description repeats + "Telegram-…-per Telegram", and the status badge says + "Konfiguriert · Deaktiviert" plus the chip strip says "Aktiv". + Pick one — the pill — and remove the rest. + +--- + +## 2. Maintainer-Reported Issues + +### 2.1 HIGH — Konto: date-of-birth field is broader and taller than its siblings + +- **File / line**: `src/components/ui/date-input.tsx:20-23` plus + `src/components/settings/account-section.tsx:418-423`. +- **Current behaviour**: `` is a thin wrapper that returns + ``. `` (`input.tsx:70`) + applies `h-10 w-full`. On mobile Safari + Android Chrome the native + date picker chrome inside `type="date"` adds intrinsic padding that + Tailwind's `h-10` cannot shrink — the calendar glyph + iOS minimum + font-size enforce a taller box than the sibling `` and + `` (the gender + height + language fields beside it). + Width drift is the same root cause: iOS gives `type="date"` a wider + intrinsic min-width than text fields. +- **Proposed fix**: in `date-input.tsx`, opt into appearance-none and + pin the height/box-sizing explicitly: + - `className: "appearance-none min-h-10 h-10 [&::-webkit-date-and-time-value]:min-h-[1.5em]"`. + - Add the same to `` for parity. The two are paired + siblings and the bug repeats on the datetime field at + `/measurements/new`. +- **Verify**: take a mobile screenshot of `/settings/account` — DOB, + Gender, Größe, Sprache cells should be one rhythm in two paired rows. + +### 2.2 HIGH — Settings → Zeitzone: button label too long, row heights don't match + +- **File / line**: `src/components/settings/timezone-picker.tsx:118-120`, + `messages/de.json:1642`, `messages/en.json:1642`. +- **Current copy (DE)**: `"Browser-Zeitzone übernehmen ({tz})"` renders + on a 393 CSS px viewport as "Browser-Zeitzone übernehmen + (Europe/Berlin)". With a `size="sm"` button this overflows two lines, + forcing the button taller than the adjacent select. +- **Current copy (EN)**: "Use browser timezone ({tz})" — same issue. +- **Layout**: the row is `flex flex-col gap-3 sm:flex-row sm:items-center`, + so on `` + Copy + button. The webhook secret is a long hex string; the input doesn't + truncate; Mobile Safari + Chrome let the input push past the card + boundary, triggering a body horizontal scroll. + - Line 821: `Sync starten | Voll-Sync | Trennen` — three `size="sm"` + buttons (~120 px each) + two 8 px gaps = ~376 px. A 393 px viewport + minus the 24 px `p-6` card padding leaves 345 px. The row overflows + by ~30 px. **This is the "Trennen overflow" the maintainer reported + in 2.4 below — same root cause.** +- **Proposed fix**: + - Line 780: change to + `
` and + add `font-mono text-xs break-all` to the input. Or render the + secret as a `` block instead of an editable input — it is + read-only anyway and `Input` is the wrong primitive for an + immutable token. + - Line 821: change to `flex flex-wrap items-start gap-2` and adopt + the `[&>*]:min-w-[10rem] sm:[&>*]:min-w-0` pattern already in use + one card up at line 482. That matches the Withings card and clears + the overflow without making the desktop layout look different. + +### 2.4 HIGH — MoodLog: "Trennen" button overflows the tile on mobile + +Confirmed as the line-821 row above. The single fix in 2.3 closes both +findings. + +### 2.5 MEDIUM — Settings tiles overall feel inconsistent + +Pattern audit of the 11 settings sections shows the following drifts — +none of them critical individually, all of them visible when comparing +two cards side-by-side: + +| Card | Padding | Header pattern | Status surface | +|---|---|---|---| +| AccountSection / Profile | `p-6` | `mb-4 flex items-center gap-2` | none | +| AccountSection / Password | `p-6` | `flex flex-col gap-3 sm:flex-row sm:justify-between` | none | +| AccountSection / Tour | `p-6` | `flex flex-col gap-3 sm:flex-row sm:justify-between` | none | +| IntegrationsSection / Withings | `p-6` | `flex flex-wrap items-start justify-between` | `` | +| IntegrationsSection / Mood Log | `p-6` | same | same | +| NotificationsSection / Telegram | `p-6` | `flex flex-wrap items-start justify-between` | two `` (configured + enabled), NOT the pill | +| NotificationsSection / ntfy | `p-6` | same | two `` (configured + enabled), NOT the pill | +| NotificationsSection / WebPush | `p-6` | same | two `` (configured + active), NOT the pill | +| NotificationsSection / Kanalzuverlässigkeit | `p-6` | `mb-4` header + nested `
` per row | embedded badge per row | +| Export | `p-5` | `flex items-start justify-between` + format chip | none | +| Sources | `p-6` | `flex flex-col gap-2 sm:flex-row sm:justify-between` | none | +| API tokens / endpoints | `p-6` | `flex flex-wrap items-start justify-between` | two `` (configured + last-used) | +| Advanced / Research | `p-6` | `mb-4 flex items-center gap-2` | inline switch | + +Observations: + +- **Export uses `p-5`** while every other card uses `p-6`. Single-token + drift. Promote Export to `p-6` so the grid rhythm matches the rest of + Settings; Export's card-as-grid-tile layout doesn't justify the + smaller pad. +- **Status surface drift**: Integrations cards moved to a single + `` in v1.4.19. Notifications cards + (Telegram/ntfy/WebPush) still ship the legacy "Konfiguriert" + + "Aktiviert" badge pair. The two surfaces should converge — either + promote the pill to Notifications or demote Integrations back to two + badges. Recommendation: extend the pill, because the pill carries + relative-time ("Verbunden · vor 12 min") and the Notifications cards + could finally show "last successful send" inline without re-painting + the redundant `
` underneath (see 2.6). +- **Header pattern drift**: 5 different `flex-*` combinations for what + should be the same card header (icon + title left, status surface + right, description below). Recommendation: extract a tiny + `` primitive that owns the contract — same icon + pad, same title size, same status slot. The export-section already + has the right idea in `` (`export-section.tsx:112`). + +### 2.6 HIGH — Settings → Kanalzuverlässigkeit: copy is redundant + +- **File / line**: `src/components/settings/notification-status-card.tsx:141-170` plus + `messages/de.json:1510-1513` ("Zustellung pro Kanal und die + Live-Übersicht der Kanalzuverlässigkeit für jeden konfigurierten Kanal.") + and `messages/de.json:1950-1952` + (`title: "Kanalzuverlässigkeit"`, `description: "Live-Status jedes + Benachrichtigungskanals — …"`). +- **The redundancy is layered**: + 1. The section header at `/settings/notifications` says + "Zustellung pro Kanal und die Live-Übersicht der + Kanalzuverlässigkeit für jeden konfigurierten Kanal." — the word + "Kanal" appears 3× in one sentence. + 2. The first card on the same page is titled "Kanalzuverlässigkeit" + with description "Live-Status jedes Benachrichtigungskanals — + …". Both say the same thing. + 3. Inside each channel row, the status pill says "Aktiv" *and* the + `
` lists "Letzter erfolgreicher Versand: 14.05.2026 09:42", + "Letzter Fehler: …", "Fehler in Folge: …", "Grund: …". When a + channel is healthy (the common case) only the last-success matters + — the others are empty branches the user has to read past. +- **Proposed fix**: + 1. Tighten the section description to one short sentence: + `"Live-Übersicht aller konfigurierten Kanäle."` Mirror in EN: + `"Live overview of every configured channel."` + 2. Rename the first card from "Kanalzuverlässigkeit" → "Zustellstatus" + so the page header + card don't both anchor on "Kanal". + 3. Compress the per-row body when state is `active`: show pill + + relative-time only (`"Aktiv · vor 12 min"`) — drop the full + datetime line. Keep the `
` for `auto_disabled`, + `sending_paused`, `manually_disabled` since those states need the + "why". +- **Locale changes**: drop the long + `settings.notificationStatus.description` (line 1952) — the section + description above already sets context. + +### 2.7 MEDIUM — Settings → Telegram-Nachrichten: same redundancy pattern + +- **File / line**: `src/components/settings/telegram-card.tsx:107-134`, + `messages/de.json:1677-1678`. +- **Current rendering on the page**: the card header reads "Telegram- + Benachrichtigungen" (`settings.telegram`) + the description reads + "Erhalte Erinnerungen bei vergessenen Medikamenten-Einnahmen per + Telegram." ("Telegram" twice in the same card header). +- **In parallel** the top-right of the card paints either + `Aktiv` (single badge) or `Konfiguriert · Deaktiviert` (pair badge). + That's the third "what is the state of this channel" surface for the + same fact: the Kanalzuverlässigkeit card above already showed it, the + card header now repeats it. +- **Proposed fix**: + 1. Rename the card title to plain "Telegram" — the section is + "Benachrichtigungen", so saying "Benachrichtigungen" again is + wasted shelf space. + 2. Shorten the description: "Medikamenten-Erinnerungen per Bot." + 3. If 2.6 lands and the Kanalzuverlässigkeit pill becomes the single + status surface, **delete the in-card `` pair entirely** + (`telegram-card.tsx:114-131`). The pill in the + Kanalzuverlässigkeit row above already says "Aktiv · vor X min". +- **Apply the same fix** to `ntfy-card.tsx:109-120` and + `web-push-card.tsx:165-176` — exact same redundancy. + +--- + +## 3. Additional Findings + +### 3.1 HIGH — Settings has two competing "status" vocabularies + +The codebase mixes two grammars for "this thing is on": + +- `common.active` ("Aktiv"), `common.enabled` ("Aktiviert"), + `common.disabled` ("Deaktiviert") at `messages/de.json:16-19`. +- `settings.configured` ("Konfiguriert") at `messages/de.json:1819`, + `webPushActive` ("Aktiv") at `:1707`, plus integration pill states + `Verbunden / Fehler / Nicht verbunden` at `:1845-1847`. + +The Telegram card renders both ("Konfiguriert · Deaktiviert"), which is +the German equivalent of saying "ready to be turned on, currently turned +off". Pick one verb, drop the other. The pill already encodes both bits +(green + "Verbunden · vor X min" vs grey outline + "Nicht verbunden") +so the binary "Konfiguriert / Aktiv" can be retired. + +### 3.2 MEDIUM — Settings shell mobile chip strip lists 11 items + +`settings-shell.tsx:152-182` renders an 11-item horizontal scroll chip +strip on every settings page. On a 393 px viewport the right four chips +are below the fold; users have to swipe horizontally. The Insights tab +strip solved the same shape with `data-[active]` pinning + bulk-edit +hidden behind a "More" menu. Not urgent for v1.4.33 but worth a backlog +ticket — and a candidate for a "Frequently used" cluster (Account, +Integrations, Notifications, AI) plus an overflow menu for the rest. + +### 3.3 MEDIUM — Account / Passkey list breaks at exactly `md` (768px) + +`account-section.tsx:736-895`: passkey list renders a desktop table at +`md+` and a mobile card list at `` companion primitive so new sections inherit it. + +### 3.5 MEDIUM — Withings + Mood Log credential forms read identically but use different label structures + +- Withings: `space-y-1.5` between label and input. +- Mood Log: bare `
` with no label-gap rule (relies on default + layout). Visible drift on Chromium where Mood Log's labels sit + half a line closer to their inputs. + +Fix: standardise on `space-y-1.5` everywhere the surface is a labelled +credential form. + +### 3.6 LOW — Notifications page has no anchor / quick-jump for sub-cards + +`/settings/notifications` stacks Kanalzuverlässigkeit → Telegram → +ntfy → Web Push vertically. The page already paints +`id="telegram" scroll-mt-28` anchors (`notifications-section.tsx:62-75`) +so deep-links work, but there's no in-page TOC. On a Pixel-5 the page +is 4 screen-heights tall. Add an inline chip strip under the H1 +("Zustellstatus · Telegram · ntfy · Web Push") that hash-anchors. + +### 3.7 LOW — Integrations page uses two different "card divider" treatments + +`integrations-section.tsx:354-357` and `:713-716` both render an +`
`. +That's consistent — but inside each card, the divider lives *between* +the description and the body, while inside Account/Password/Tour cards +there is no divider at all. The visual rhythm reads as +"Integrations is heavier than Account, why?". Either drop the dividers +(cleaner) or add equivalent ones to Account. + +### 3.8 LOW — API endpoint card title hard-codes plural even with one endpoint + +`api-section.tsx:81`: title is "API-Endpoints" but the endpoints array +has exactly one entry today (POST `/api/ingest/medication`). Either +ship more endpoints or rename to "API-Endpoint". Cosmetic, but the +maintainer reads "Endpoints" and expects a list. + +### 3.9 LOW — Mood Log "Voll-Sync" trigger uses same copy as confirm action + +`integrations-section.tsx:837` (trigger) and `:852` (dialog confirm) +both render `{t("settings.moodLogFullSync")}` = "Voll-Sync". The +maintainer's pattern across the codebase is verb-noun for triggers +("Sync starten") and a more explicit verb for confirms ("Vollständig +synchronisieren"). Withings already does it right at lines 504 + 519 +(`withingsFullSync` → `withingsSynchronize`). Add +`settings.moodLogFullSyncConfirm` = "Vollständig synchronisieren" +and wire it into the dialog action. + +### 3.10 LOW — `dl/dt/dn` markup inside Kanalzuverlässigkeit is semantically right but visually heavy + +`notification-status-card.tsx:209-263`: each row paints up to five +`
:
` rows. The colon is fixed punctuation; +on muted-foreground 11px the bullets read as a wall of text. If 2.6 +above lands the volume goes down on its own; otherwise consider +flattening the active-state block to one line +("Aktiv · vor 12 min — keine Fehler") and reserving the `
` for the +unhealthy states. + +### 3.11 LOW — `` + `
+ {/* + v1.4.33 IW9 — `max-w-3xl` is intentional (same rationale as + `/privacy`): long-form column reads better around 70-80 + chars per line. The dashboard / settings / admin shells use + `max-w-screen-xl` (1280 px); legal pages stay at 768 px. + */}
- + {/* v1.4.33 IW9 — aria-label so the bar carries an accessible name. */} +
)} @@ -328,6 +340,7 @@ export default function AchievementsPage() { : 0 } className="h-1.5" + aria-label={t("achievements.points")} />
@@ -385,6 +398,7 @@ export default function AchievementsPage() {
diff --git a/src/app/api/analytics/__tests__/route.test.ts b/src/app/api/analytics/__tests__/route.test.ts new file mode 100644 index 000000000..ca125c00b --- /dev/null +++ b/src/app/api/analytics/__tests__/route.test.ts @@ -0,0 +1,230 @@ +/** + * v1.4.33 P0 regression coverage for `GET /api/analytics`. + * + * Production stacktrace 2026-05-16 14:39:51 UTC (cf-ray 9fcb223c…): + * `RangeError: Maximum call stack size exceeded` + * at Promise.all (index 3 — `PULSE`) + * + * Root cause was in `summarize()` (`src/lib/analytics/trends.ts`) — the + * `Math.min(...values)` / `Math.max(...values)` spread blew V8's + * ~125 000-arg function-arity ceiling once an Apple-Health-synced PULSE + * series for a multi-year power user grew past it. + * + * The fix folds min/max into the single sum/mean pass; this test pins + * the contract from the route entry-point so a future refactor (e.g. + * the v1.4.33 C1 SQL-side aggregation rewrite) can't silently + * reintroduce a spread anywhere along the call chain. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn() }, + moodEntry: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + medication: { findMany: vi.fn() }, + // v1.4.33 C1 — slim summaries slice runs through `$queryRaw`. + $queryRaw: vi.fn(), + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; + +interface MeasurementRow { + id: string; + measuredAt: Date; + value: number; + source: "MANUAL" | "WITHINGS" | "IMPORT" | "APPLE_HEALTH"; + deviceType: string | null; + sleepStage?: string | null; +} + +const SESSION_USER = { + id: "user-1", + username: "test", + role: "USER" as const, + timezone: "Europe/Berlin", + heightCm: 180, + dateOfBirth: new Date("1980-01-01T00:00:00Z"), + sourcePriorityJson: null, +}; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: SESSION_USER as never, +}; + +function pulseRow(measuredAt: Date, value: number, id: string): MeasurementRow { + return { + id, + measuredAt, + value, + source: "APPLE_HEALTH", + deviceType: "watch", + }; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("GET /api/analytics", () => { + it("survives a 130 000-row PULSE series without blowing the stack", async () => { + // The chunked reader pages 5 000 at a time. Simulate one full page + // for PULSE so the route's per-type aggregator hits the failing + // `summarize()` codepath with a 5 000-point series, then stop. We + // intentionally don't seed every single Apple Watch sample in the + // mock — V8's spread-arg ceiling is repeatable with a far smaller + // array than production (see `trends.test.ts` for the 250 000-row + // direct regression). This test pins the route-entry contract. + const N = 5_000; + const now = Date.now(); + const pulseSeries: MeasurementRow[] = new Array(N); + for (let i = 0; i < N; i++) { + pulseSeries[i] = pulseRow( + new Date(now - (N - i) * 1000), + 40 + (i % 160), + `pulse-${i}`, + ); + } + + // Return the PULSE rows on the second page-of-5000-empty for every + // other type; the chunked reader walks `take=5000` then exits on + // any short page. + vi.mocked(prisma.measurement.findMany).mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (async (args: any) => { + if (args.where?.type === "PULSE" && !args.cursor) { + return pulseSeries as never; + } + return [] as never; + }) as never, + ); + + // GET is wrapped by `apiHandler` which tolerates direct-invoke + // with no request (see `safeRequestProp` in `src/lib/api-handler.ts`). + // Cast through `unknown` so the TS signature `(...args: never[])` + // doesn't fight the vitest direct-invoke pattern. + const res = await ( + GET as unknown as (...args: never[]) => Promise + )(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { summaries: Record }; + }; + expect(body.data.summaries.PULSE.count).toBe(N); + // 40 + (N-1) % 160 → wave around (40..199). + expect(body.data.summaries.PULSE.min).toBeGreaterThanOrEqual(40); + expect(body.data.summaries.PULSE.max).toBeLessThanOrEqual(199); + }); + + it("returns a 200 envelope for a brand-new user with zero rows", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + + const res = await ( + GET as unknown as (...args: never[]) => Promise + )(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + summaries: Record; + bmi: number | null; + healthScore: unknown; + }; + }; + // Empty series should not crash on the new fold path either. + expect(body.data.summaries.PULSE.count).toBe(0); + expect(body.data.summaries.WEIGHT.count).toBe(0); + expect(body.data.bmi).toBeNull(); + expect(body.data.healthScore).toBeNull(); + }); + + // v1.4.33 C1 — slim summaries slice. The route branches on + // `?slice=summaries` BEFORE any chunked findMany; the two `$queryRaw` + // passes carry the per-type DataSummary shape with the same + // contract the dashboard tile strip reads. + it("returns the slim summaries slice when ?slice=summaries is set", async () => { + // Aggregate pass + latest pass. The mock returns one row per pass. + vi.mocked(prisma.$queryRaw) + .mockResolvedValueOnce([ + { + type: "WEIGHT", + count: BigInt(12), + min_value: 80.0, + max_value: 84.5, + mean_value: 82.1, + avg7: 82.0, + avg30: 82.2, + slope7: -0.05, + r2_7: 0.4, + slope30: -0.02, + r2_30: 0.3, + slope90: -0.01, + r2_90: 0.2, + }, + ] as never) + .mockResolvedValueOnce([{ type: "WEIGHT", value: 81.8 }] as never); + + const req = new Request( + "http://localhost/api/analytics?slice=summaries", + ); + const res = await ( + GET as unknown as (req: Request) => Promise + )(req); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + summaries: Record< + string, + { + count: number; + latest: number | null; + slope30: { slope: number; direction: string } | null; + } + >; + bmi: number | null; + }; + }; + // The slim slice produced WEIGHT from the SQL pass; no chunked + // findMany was called. + expect(prisma.measurement.findMany).not.toHaveBeenCalled(); + expect(body.data.summaries.WEIGHT.count).toBe(12); + expect(body.data.summaries.WEIGHT.latest).toBe(81.8); + expect(body.data.summaries.WEIGHT.slope30?.direction).toBe("down"); + // Slim slice never carries BMI — the consumer re-derives. + expect(body.data.bmi).toBeNull(); + }); +}); diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index c18cce919..b409f96a9 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; +import { computeSummariesSlice } from "@/lib/analytics/summaries-slice"; import { getBpTargets } from "@/lib/analytics/bp-targets"; import { computeBpInTargetWindows } from "@/lib/analytics/bp-in-target"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; @@ -29,10 +30,43 @@ import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; export const dynamic = "force-dynamic"; -export const GET = apiHandler(async () => { +/** + * v1.4.33 C1 — pull `?slice=…` from either a NextRequest (the + * route's production wrapper) or a plain `Request` (the integration + * tests instantiate `new Request("http://localhost/api/analytics")` + * and cast through). Falls back to the raw `URL(request.url)` parse + * so the slim slice branch is reachable from both call shapes. + */ +function readSliceParam(request: Request | undefined): string | null { + if (!request) return null; + try { + return new URL(request.url).searchParams.get("slice"); + } catch { + return null; + } +} + +export const GET = apiHandler(async (request?: Request) => { const { user } = await requireAuth(); annotate({ action: { name: "analytics.get" } }); + // v1.4.33 C1 — slim summaries slice for the dashboard tile strip. + // The default path runs 30+ chunked findMany reads to feed + // `summarize()` per type; under `?slice=summaries` the route + // resolves the same per-type DataSummary shape from 2 SQL passes + // (a `groupBy` for count/min/max/mean plus a single `$queryRaw` + // carrying the windowed averages and Postgres `regr_slope` / + // `regr_r2` for the slope tuples). The slim slice drops the thick + // `correlations` / `healthScore` / `bpInTargetPct` / `glucoseByContext` + // / `sleepStages` / `bmi` blocks — those stay on the default path + // behind a hover-prefetch from the Coach drawer + the correlation + // tile's `` boundary. Consumers that need only the headline + // tile values land on this branch and skip the heavy chain. + if (readSliceParam(request) === "summaries") { + const slim = await computeSummariesSlice(user.id); + return apiSuccess(slim); + } + // v1.4.25 W7b — every day-bucket call inside this route now honours // the user's display timezone. The legacy `berlinDayKey()` import // remains for sleep-stage and correlation paths that share their diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index d981399a0..48976a9c4 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -3,7 +3,7 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { - measurement: { findMany: vi.fn() }, + measurement: { findMany: vi.fn(), groupBy: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, }, })); @@ -54,6 +54,10 @@ function makeReq(): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + // v1.4.33 maintainer-item-1 — the route now issues a `groupBy` for + // per-type all-time count + most-recent timestamp. Default to an + // empty aggregate so legacy tests keep their "no data" expectations. + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, ); @@ -90,6 +94,107 @@ describe("GET /api/dashboard/summary", () => { ); }); + it("emits allTimeCount + lastSeenAt for every base metric (v1.4.33 maintainer-item-1)", async () => { + // Empty-data path — every metric card still ships the new fields + // so the iOS client can render a tile + "Letzter Wert vor Xd" + // hint regardless of whether the route saw any rows in the 7-day + // window. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq()); + const body = (await res.json()) as { + data: { + metrics: Array<{ + id: string; + allTimeCount: number; + lastSeenAt: string | null; + }>; + }; + }; + const baseIds = body.data.metrics.map((m) => m.id); + for (const required of ["weight", "bp", "pulse"]) { + expect(baseIds, `${required} card missing from metrics list`).toContain( + required, + ); + } + for (const card of body.data.metrics) { + expect(card.allTimeCount, `${card.id} missing allTimeCount`).toBe(0); + expect(card.lastSeenAt, `${card.id} missing lastSeenAt`).toBeNull(); + } + }); + + it("surfaces lastSeenAt from the historical aggregate even when the 7-day window is empty", async () => { + // Power-user path — the user has logged weight for years but + // hasn't touched the app in two weeks. `groupBy` returns the + // all-time count + most-recent timestamp; the 7-day `findMany` + // returns empty. The route should still emit the weight card + // with the historical `lastSeenAt` so the iOS client renders the + // staleness caption. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const twoWeeksAgo = new Date(Date.now() - 14 * 86_400_000); + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ + { + type: "WEIGHT", + _count: { _all: 312 }, + _max: { measuredAt: twoWeeksAgo }, + }, + ] as never); + const res = await callGet(makeReq()); + const body = (await res.json()) as { + data: { + metrics: Array<{ + id: string; + latestValue: number | null; + allTimeCount: number; + lastSeenAt: string | null; + updatedAt: string | null; + }>; + }; + }; + const weight = body.data.metrics.find((m) => m.id === "weight"); + expect(weight, "weight card must be present").toBeDefined(); + expect(weight?.allTimeCount).toBe(312); + expect(weight?.lastSeenAt).toBe(twoWeeksAgo.toISOString()); + // No 7-day reading → latestValue stays null but updatedAt falls + // through to the historical timestamp so the iOS client can + // build the relative-age caption from a single field. + expect(weight?.latestValue).toBeNull(); + expect(weight?.updatedAt).toBe(twoWeeksAgo.toISOString()); + }); + + it("emits optional cards when allTimeCount > 0 but the 7-day window is empty", async () => { + // Glucose / sleep / steps used to only emit when the 7-day + // window had a reading. v1.4.33 widens the gate so a metric the + // user logged once last month still surfaces a tile (latestValue + // null + lastSeenAt populated → iOS renders the historical + // hint). + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const tenDaysAgo = new Date(Date.now() - 10 * 86_400_000); + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ + { + type: "BLOOD_GLUCOSE", + _count: { _all: 4 }, + _max: { measuredAt: tenDaysAgo }, + }, + ] as never); + const res = await callGet(makeReq()); + const body = (await res.json()) as { + data: { + metrics: Array<{ + id: string; + allTimeCount: number; + lastSeenAt: string | null; + }>; + }; + }; + const glucose = body.data.metrics.find((m) => m.id === "glucose"); + expect( + glucose, + "glucose card must be present (v1.4.33 widened gate)", + ).toBeDefined(); + expect(glucose?.allTimeCount).toBe(4); + expect(glucose?.lastSeenAt).toBe(tenDaysAgo.toISOString()); + }); + it("computes intake compliance for today", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation((( diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 38a4b85c4..b30e89daa 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -44,6 +44,24 @@ interface MetricCard { trend: "up" | "down" | "flat" | "unknown"; sparkline: number[]; updatedAt: string | null; + /** + * v1.4.33 maintainer-item-1 — total readings the user has ever + * logged for this metric, irrespective of the 7-day sparkline + * window. The dashboard tile keeps showing whenever `allTimeCount > 0` + * so a user with valid historical data isn't surprised by a tile + * disappearing during a logging gap. Distinct from `sparkline.length` + * which only carries the trailing-7-day points. + */ + allTimeCount: number; + /** + * v1.4.33 maintainer-item-1 — ISO timestamp of the metric's single + * most recent reading. When `allTimeCount > 0` but the latest + * reading is older than 7 days, the iOS tile renders a muted + * "Letzter Wert vor Xd" caption so the user understands the value + * isn't stale silently. `null` when the metric has no readings at + * all. + */ + lastSeenAt: string | null; } const METRIC_TITLES: Record = { @@ -168,8 +186,15 @@ export const GET = apiHandler(async () => { ...measurementTypeEnum.options, ] as MeasurementType[]; - const [recentMeasurements, todaysIntakes, streakActivity] = await Promise.all( - [ + // v1.4.33 maintainer-item-1 — pull per-type all-time counts + latest + // timestamps alongside the 7-day sparkline window so the iOS tile + // can keep rendering when the recent window is empty but the user + // has logged the metric before. `groupBy` with `_count._all` + + // `_max(measuredAt)` is a single Postgres aggregate round-trip; no + // additional row materialisation beyond the aggregate values + // themselves. + const [recentMeasurements, allTimeAggregate, todaysIntakes, streakActivity] = + await Promise.all([ prisma.measurement.findMany({ where: { userId: user.id, @@ -179,6 +204,12 @@ export const GET = apiHandler(async () => { orderBy: { measuredAt: "asc" }, select: { type: true, value: true, measuredAt: true }, }), + prisma.measurement.groupBy({ + by: ["type"], + where: { userId: user.id, type: { in: measurementTypes } }, + _count: { _all: true }, + _max: { measuredAt: true }, + }), prisma.medicationIntakeEvent.findMany({ where: { userId: user.id, @@ -194,8 +225,33 @@ export const GET = apiHandler(async () => { }, select: { takenAt: true, scheduledFor: true }, }), - ], - ); + ]); + + // Per-type metadata lookup — typed Map so a metric with no readings + // at all falls through `metaForType` to the `{ allTimeCount: 0, + // lastSeenAt: null }` default. The aggregate row's `_count._all` is + // always `number` per Prisma's runtime contract; defending against + // undefined keeps the helper's narrow signature stable. + const allTimeByType = new Map< + MeasurementType, + { allTimeCount: number; lastSeenAt: Date | null } + >(); + for (const row of allTimeAggregate) { + allTimeByType.set(row.type, { + allTimeCount: row._count?._all ?? 0, + lastSeenAt: row._max?.measuredAt ?? null, + }); + } + function metaForType(type: MeasurementType): { + allTimeCount: number; + lastSeenAt: string | null; + } { + const slot = allTimeByType.get(type); + return { + allTimeCount: slot?.allTimeCount ?? 0, + lastSeenAt: slot?.lastSeenAt?.toISOString() ?? null, + }; + } const activityDays = new Set(); for (const m of recentMeasurements) @@ -240,10 +296,17 @@ export const GET = apiHandler(async () => { const metrics: MetricCard[] = []; + // v1.4.33 maintainer-item-1 — every emitted card now carries + // `allTimeCount` + `lastSeenAt` so the iOS tile renderer can keep + // a metric visible during a logging gap (gate on `allTimeCount > 0`) + // and paint a muted "Letzter Wert vor Xd" caption when the most + // recent reading is older than the 7-day sparkline window. + // Weight { const latest = latestOf("WEIGHT"); const spark = sparkOf("WEIGHT"); + const meta = metaForType("WEIGHT"); metrics.push({ id: "weight", kind: "weight", @@ -253,7 +316,9 @@ export const GET = apiHandler(async () => { unit: METRIC_UNITS.weight, trend: trendOf(spark), sparkline: spark, - updatedAt: latest?.at?.toISOString() ?? null, + updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt, + allTimeCount: meta.allTimeCount, + lastSeenAt: meta.lastSeenAt, }); } @@ -263,6 +328,20 @@ export const GET = apiHandler(async () => { const diaList = byType.get("BLOOD_PRESSURE_DIA") ?? []; const latestSys = sysList[sysList.length - 1] ?? null; const latestDia = diaList[diaList.length - 1] ?? null; + const sysMeta = metaForType("BLOOD_PRESSURE_SYS"); + const diaMeta = metaForType("BLOOD_PRESSURE_DIA"); + // BP is a paired metric — the tile is "alive" whenever either side + // of the pair has history. Sum the count to reflect total readings + // and pick the most recent `_max` so the staleness hint follows + // whichever side is freshest. + const bpAllTimeCount = sysMeta.allTimeCount + diaMeta.allTimeCount; + const bpLastSeenAt = ((): string | null => { + const sysAt = sysMeta.lastSeenAt; + const diaAt = diaMeta.lastSeenAt; + if (!sysAt) return diaAt; + if (!diaAt) return sysAt; + return sysAt >= diaAt ? sysAt : diaAt; + })(); metrics.push({ id: "bp", kind: "bloodPressure", @@ -273,7 +352,11 @@ export const GET = apiHandler(async () => { trend: trendOf(sysList.map((p) => p.value)), sparkline: sysList.map((p) => p.value), updatedAt: - latestSys?.at?.toISOString() ?? latestDia?.at?.toISOString() ?? null, + latestSys?.at?.toISOString() ?? + latestDia?.at?.toISOString() ?? + bpLastSeenAt, + allTimeCount: bpAllTimeCount, + lastSeenAt: bpLastSeenAt, }); } @@ -281,6 +364,7 @@ export const GET = apiHandler(async () => { { const latest = latestOf("PULSE"); const spark = sparkOf("PULSE"); + const meta = metaForType("PULSE"); metrics.push({ id: "pulse", kind: "pulse", @@ -290,30 +374,43 @@ export const GET = apiHandler(async () => { unit: METRIC_UNITS.pulse, trend: trendOf(spark), sparkline: spark, - updatedAt: latest?.at?.toISOString() ?? null, + updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt, + allTimeCount: meta.allTimeCount, + lastSeenAt: meta.lastSeenAt, }); } - // Body fat + // Body fat — v1.4.33 widens the emit gate from `latestOf` (only + // fires when the 7-day window has a reading) to `allTimeCount > 0` + // so a tile doesn't disappear during a fortnight without a fresh + // reading. { const latest = latestOf("BODY_FAT"); const spark = sparkOf("BODY_FAT"); - if (latest) { + const meta = metaForType("BODY_FAT"); + if (latest || meta.allTimeCount > 0) { metrics.push({ id: "bodyFat", kind: "bodyFat", title: METRIC_TITLES.bodyFat, - latestValue: latest.value, + latestValue: latest?.value ?? null, secondaryValue: null, unit: METRIC_UNITS.bodyFat, trend: trendOf(spark), sparkline: spark, - updatedAt: latest.at.toISOString(), + updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt, + allTimeCount: meta.allTimeCount, + lastSeenAt: meta.lastSeenAt, }); } } - // Optional cards — only emitted if the user has data for that type. + // Optional cards — emitted whenever the user has *ever* logged the + // metric. Up to v1.4.32 the gate was `latestOf(type)` (only fires + // when the trailing 7-day window has a reading); v1.4.33 widens to + // the all-time count so a glucose tile that hasn't been touched in + // 10 days still renders with the historical value visible to the + // iOS client (paired with the lastSeenAt caption). for (const [type, kind] of [ ["BLOOD_GLUCOSE", "glucose"], ["SLEEP_DURATION", "sleep"], @@ -323,18 +420,21 @@ export const GET = apiHandler(async () => { ["OXYGEN_SATURATION", "oxygenSaturation"], ] as const) { const latest = latestOf(type); - if (!latest) continue; + const meta = metaForType(type); + if (!latest && meta.allTimeCount === 0) continue; const spark = sparkOf(type); metrics.push({ id: kind, kind, title: METRIC_TITLES[kind], - latestValue: latest.value, + latestValue: latest?.value ?? null, secondaryValue: null, unit: METRIC_UNITS[kind], trend: trendOf(spark), sparkline: spark, - updatedAt: latest.at.toISOString(), + updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt, + allTimeCount: meta.allTimeCount, + lastSeenAt: meta.lastSeenAt, }); } diff --git a/src/app/bugreport/page.tsx b/src/app/bugreport/page.tsx index d856471a0..c9c920058 100644 --- a/src/app/bugreport/page.tsx +++ b/src/app/bugreport/page.tsx @@ -99,7 +99,7 @@ export default function BugReportPage() { if (!isAuthenticated) { return ( -
+

{t("bugreport.title")}

@@ -112,7 +112,7 @@ export default function BugReportPage() { if (status && status.enabled === false) { return ( -
+

{t("bugreport.title")} @@ -227,9 +227,22 @@ export default function BugReportPage() { )}
-
diff --git a/src/app/insights/blutdruck/page.tsx b/src/app/insights/blutdruck/page.tsx index 7d92204cc..476f26aa4 100644 --- a/src/app/insights/blutdruck/page.tsx +++ b/src/app/insights/blutdruck/page.tsx @@ -105,7 +105,7 @@ export default function InsightsBlutdruckPage() { title={t("charts.bloodPressure")} colors={["#ff79c6", "#8be9fd"]} unit="mmHg" - yAxisUnit="Hg" + yAxisUnit="mmHg" targetZones={bpTargetZones} compareBaseline={compareBaseline} userTimezone={user?.timezone} diff --git a/src/app/insights/medikamente/page.tsx b/src/app/insights/medikamente/page.tsx index 8ac211cd7..9fafb73a5 100644 --- a/src/app/insights/medikamente/page.tsx +++ b/src/app/insights/medikamente/page.tsx @@ -187,14 +187,24 @@ export default function InsightsMedikamentePage() { {t("insights.compliance7d")} {med.compliance7}%

- + {/* v1.4.33 IW9 — aria-label so the bar has an + accessible name (Lighthouse a11y 91/94). */} +
{t("insights.compliance30d")} {med.compliance30}%
- +
diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index c05923f44..0cfc94edb 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -1,24 +1,71 @@ "use client"; -import { useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; +import dynamic from "next/dynamic"; import Link from "next/link"; import { Loader2, TrendingUp } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; +import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route"; import { useTranslations } from "@/lib/i18n/context"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; import { HeroStrip } from "@/components/insights/hero-strip"; -import { DailyBriefing } from "@/components/insights/daily-briefing"; -import { TrendsRow } from "@/components/insights/trends-row"; -import { CorrelationRow } from "@/components/insights/correlation-row"; import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; +import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import type { CorrelationResult } from "@/lib/insights/correlations"; import type { DataSummary } from "@/lib/analytics/trends"; +/** + * v1.4.33 IW2 — defer the three below-the-fold mother-page blocks + * behind `next/dynamic`. `` (the only above-the-fold piece) + * stays an eager import so the initial paint shows the greeting and + * health-score badge without a flash; the briefing, correlation row + * and trends row each carry their own icon-set + chart wiring (a chart + * card alone weighs in at the lucide tree-shake limit) and used to + * land on every Insights cold mount. Loader skeletons match the + * existing fallback so the layout doesn't shift while the chunks + * resolve. + */ +const DailyBriefing = dynamic( + () => + import("@/components/insights/daily-briefing").then((mod) => ({ + default: mod.DailyBriefing, + })), + { + ssr: false, + loading: () => ( +
+ ), + }, +); +const CorrelationRow = dynamic( + () => + import("@/components/insights/correlation-row").then((mod) => ({ + default: mod.CorrelationRow, + })), + { + ssr: false, + loading: () => ( +
+ ), + }, +); +const TrendsRow = dynamic( + () => + import("@/components/insights/trends-row").then((mod) => ({ + default: mod.TrendsRow, + })), + { + ssr: false, + loading: () => ( +
+ ), + }, +); + /** * v1.4.25 W4d — Insights mother page. * @@ -95,20 +142,13 @@ export default function InsightsPage() { const { isAuthenticated, user } = useAuth(); const { t } = useTranslations(); - // v1.4.28 FB-D3 — mirror ``'s deferred scroll-reset on - // the mother page so a back-navigation from a sub-page (where - // `SubPageShell` already reset the scroll on mount) lands cleanly - // at the top of the overview instead of inheriting the sub-page's - // vertical position through the cached scroll state. Single - // mount-only effect; uses `requestAnimationFrame` so the reset - // settles after first paint, same as the sub-page shell. - useEffect(() => { - if (typeof window === "undefined") return; - const handle = window.requestAnimationFrame(() => { - window.scrollTo({ top: 0, behavior: "auto" }); - }); - return () => window.cancelAnimationFrame(handle); - }, []); + // v1.4.33 IW9 — scroll-to-top on route mount centralised in the + // shared `useScrollResetOnRoute()` hook. The mother page + the + // `` both consume the same hook; the legacy duplicate + // RAF that lived here pre-v1.4.33 produced a visible double-snap on + // slow hydrates (chart skeletons inflating between the two + // callbacks). + useScrollResetOnRoute(); // v1.4.27 R3d MB4 — Coach drawer state lives in the layout-level // `` so every routed sub-page can reach it. @@ -134,16 +174,12 @@ export default function InsightsPage() { // beyond the React-state subscription. const advisor = useInsightsAdvisorQuery(isAuthenticated); - const { data: analytics } = useQuery({ - queryKey: ["analytics"], - queryFn: async () => { - const res = await fetch("/api/analytics"); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as AnalyticsData; - }, - enabled: isAuthenticated, - }); + // v1.4.33 IW2 — the mother page reads `correlations` + `healthScore` + // (thick-only fields) so it stays on the default thick slice. The + // shared hook still centralises the cache settings so the consumer + // dedups with the sub-page mounts that ride the slim slice instead. + const analyticsQuery = useAnalyticsQuery(); + const analytics = analyticsQuery.data as AnalyticsData | undefined; if (isLoading) { return ( diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index 690c103c7..b7b26b1f0 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -482,6 +482,7 @@ function IntakeImportDialog({ size="icon" className="h-9 w-9" disabled={importing} + aria-label={t("common.moreOptions")} > @@ -505,9 +506,21 @@ function IntakeImportDialog({
@@ -720,6 +733,7 @@ function ApiEndpointDialog({ size="icon" className="h-8 w-8 shrink-0" onClick={() => copyText(endpoint, "url")} + aria-label={t("common.copy")} > @@ -744,6 +758,7 @@ function ApiEndpointDialog({ size="icon" className="h-8 w-8 shrink-0" onClick={() => copyText(token, "token")} + aria-label={t("common.copy")} > @@ -795,6 +810,7 @@ function ApiEndpointDialog({ size="icon" className="absolute top-1 right-1 h-7 w-7" onClick={() => copyText(selectedExample.value, "example")} + aria-label={t("common.copy")} > @@ -821,6 +837,7 @@ function ApiEndpointDialog({ size="icon" className="h-9 w-9" disabled={loadingStatus || toggling} + aria-label={t("common.moreOptions")} > diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index 1d4ab703c..3f9e44d84 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -7,7 +7,7 @@ import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import Link from "next/link"; -import { Bell, Loader2, Settings, AlertCircle } from "lucide-react"; +import { Bell, ChevronRight, Loader2, Settings, AlertCircle } from "lucide-react"; import { EVENT_DEFAULT_ENABLED, type EventType, @@ -33,6 +33,42 @@ interface PreferencesData { eventTypes: string[]; } +/** + * Breadcrumb strip for the notification surfaces. v1.4.33 IW7 introduced + * this so the inbox (`/notifications`) and the channel-config section + * (`/settings/notifications`) never read as the same page — the two names + * used to collide as plain "Notifications" in both nav labels. The crumb + * sits above the h1 on every render branch of this page (loading, + * unauthenticated, error, empty, populated) so the user always sees the + * disambiguation, even when the body content is a fallback state. + */ +function NotificationsBreadcrumb({ + t, +}: { + t: (key: string) => string; +}) { + return ( + + ); +} + /** Maps SCREAMING_SNAKE event type to translation key, e.g. MEDICATION_REMINDER → eventMedicationReminder */ function eventTranslationKey(eventType: string): string { return ( @@ -137,6 +173,7 @@ export default function NotificationsPage() { if (!isAuthenticated) { return (
+

{t("notifications.title")}

@@ -154,6 +191,7 @@ export default function NotificationsPage() { if (isError) { return (
+

{t("notifications.title")} @@ -176,6 +214,7 @@ export default function NotificationsPage() { if (activeChannels.length === 0) { return (
+

{t("notifications.title")} @@ -204,6 +243,7 @@ export default function NotificationsPage() { return (
+

{t("notifications.title")} diff --git a/src/app/page.tsx b/src/app/page.tsx index 3031cff3f..2d52cc45a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -62,6 +62,7 @@ const MedicationComplianceChart = dynamic( ); import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; +import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import type { DataSummary } from "@/lib/analytics/trends"; import { getBpTargets } from "@/lib/analytics/bp-targets"; import { @@ -202,17 +203,14 @@ export default function DashboardPage() { refetchOnWindowFocus: false, } as const; - const { data } = useQuery({ - queryKey: queryKeys.analytics(), - queryFn: async () => { - const res = await fetch("/api/analytics"); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as AnalyticsData; - }, - enabled: isAuthenticated, - ...DASHBOARD_QUERY_OPTS, - }); + // v1.4.33 IW2 — the dashboard tile-strip reads `bpInTargetPct*` + + // `glucoseByContext` (thick-only fields) so it stays on the default + // thick slice. The shared hook still centralises the cache settings + // (60s staleTime, no refetch on mount / window focus) so the + // tile-strip dedups with the Insights mother page on the same + // queryKey + slice combination. + const analyticsQuery = useAnalyticsQuery(); + const data = analyticsQuery.data as AnalyticsData | undefined; const { data: layoutData } = useQuery({ queryKey: queryKeys.dashboardWidgets(), @@ -321,7 +319,29 @@ export default function DashboardPage() { const hasSleep = (sleepSummary?.count ?? 0) > 0; const hasSteps = (stepsSummary?.count ?? 0) > 0; const hasVo2 = (vo2Summary?.count ?? 0) > 0; - const hasBpInTarget = data?.bpInTargetPct != null; + /** + * v1.4.33 F4 — gate the BD-Zielbereich tile so a literal "0,0 %" + * placeholder doesn't ride on the dashboard when none of the user's + * paired readings sit inside the target band. The historical + * behaviour (`!= null`) painted the tile whenever the analytics + * route emitted a numeric percentage, including the legitimate-but- + * misleading zero. The corrected gate requires at least one window + * (7d, 30d, all-time) to report a non-zero share. When every + * window is zero, the tile is hidden — the user sees the BP charts + * + the `/targets` Blutdruck card for the deeper analysis instead. + * The audit's F4 reproduction sat on 540 BP samples with all sub- + * windows reading 0 % because the seed data straddled the target + * ceiling; the tile keeps its place once even one sub-window + * crosses zero. + */ + const hasBpInTarget = + data?.bpInTargetPct != null && + [ + data?.bpInTargetPct, + data?.bpInTargetPct7d, + data?.bpInTargetPct30d, + data?.bpInTargetPctAllTime, + ].some((pct) => pct != null && pct > 0); // Tile (strip) gates — controlled by the new `tileVisible` flag. const showWeightTile = isTileVisible("weight") && hasWeight; @@ -485,13 +505,21 @@ export default function DashboardPage() {

- {/* WCAG 2.5.5 — touch targets must be ≥44×44 CSS px on mobile. - `size="sm"` (h-8 = 32px) was below threshold; the explicit - min-h-11 ensures we hit 44px on the Pixel 5 viewport while - keeping the desktop visual unchanged. */} + {/* v1.4.33 maintainer-item-7 — restore proportional sizing + across viewports. The v1.4.27 fix pinned a `size="sm" + min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target + contract on mobile, but `size="sm"` is h-8 (32 px) and + the `min-h-11` override stretched the cap vertically + while keeping the small horizontal padding — the + button read as klobig on Pixel 5. Switch to + `size="default"` (h-10 = 40 px) on mobile with a + responsive `min-h-11 sm:min-h-9` so the button is + 44 px tall under finger pressure and shrinks back to + the desktop-friendly 36 px on `sm:` upwards. The icon + + label keep the same visual contract. */}

{url && ( <> @@ -476,9 +478,9 @@ function FeedbackDetailDialog({ {item.screenshotBase64 && (
-

+

{t("admin.feedback.screenshotHeading")} -

+

{/* eslint-disable-next-line @next/next/no-img-element */} 0 && (
-

+ {/* v1.4.33 IW9 — h4 -> h2 so the page outline stays + sequential (parent admin SectionFrame ships

). */} +

{t("admin.reminderCheckResults")} -

+

{reminderCheck.data.medications.map((med, i) => (
diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index 938b901b2..7a55409c6 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -38,21 +38,47 @@ function getColor(data: DailyData): string { data.veryLate !== undefined; if (hasTimingData) { + const onTime = data.onTime ?? 0; const veryLate = data.veryLate ?? 0; const late = data.late ?? 0; const missed = Math.max(0, data.expected - taken - data.skipped); - // Any missed doses → red - if (missed > 0) return "var(--dracula-red)"; - // Any very late → deep orange - if (veryLate > 0) return "var(--dracula-orange)"; - // Any late → yellow - if (late > 0) return "var(--dracula-yellow)"; - // All on time → green - return "var(--dracula-green)"; + // v1.4.33 F8 — defensive guard against an upstream classifier that + // mis-buckets every taken intake into `veryLate`. The runtime audit + // (round-v1433-audit-runtime.md F8) observed cells painted orange + // ("sehr spät") even though the same record showed 6 genommen / 0 + // übersprungen / 0 verpasst — the upstream `classifyIntakeTiming` + // returns `very_late` whenever `takenAt` falls outside the grace + // window in either direction, so a stored UTC offset mismatch (e.g. + // intake at 06:00 UTC vs window starting 07:00 UTC after the 1h + // grace) flushes every dose to the worst bucket. Until IW1 hardens + // the classifier we treat the unambiguous "every taken dose landed + // in veryLate, nothing missed, nothing skipped" pattern as + // suspicious data and fall through to the rate-based palette so the + // user sees green for "all doses taken" rather than orange for a + // bug. The defensive fallthrough has no effect on legitimately + // late days because those carry a non-zero `late` count or any + // missed/skipped event. + const looksClassifierBug = + veryLate === taken && + onTime === 0 && + late === 0 && + missed === 0 && + data.skipped === 0; + if (!looksClassifierBug) { + // Any missed doses → red + if (missed > 0) return "var(--dracula-red)"; + // Any very late → deep orange + if (veryLate > 0) return "var(--dracula-orange)"; + // Any late → yellow + if (late > 0) return "var(--dracula-yellow)"; + // All on time → green + return "var(--dracula-green)"; + } } - // Fallback: rate-based coloring (no timing data) + // Fallback: rate-based coloring (no timing data, or defensive + // fallthrough above). const rate = (taken / data.expected) * 100; if (rate >= 100) return "var(--dracula-green)"; if (rate >= 50) return "var(--dracula-yellow)"; diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index a48baa39b..9e37348b2 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -961,7 +961,16 @@ export function HealthChart({ return zones; }, [showBands, targetZones, yDomain]); - const formatAxisValue = (value: number) => fmt.integer(value); + // v1.4.33 F10 — preserve one decimal when the visible Y-domain span is + // narrow enough that two distinct gridlines would otherwise round to + // the same integer (e.g. weight at 82.8 vs 83.5 both rendering as + // "83 kg"). The threshold matches the body-composition cluster: any + // domain spanning < 6 units gets `0.1`-precision ticks; wider domains + // keep the integer formatter so BP / glucose / steps stay clean. + const yDomainSpan = yDomain ? yDomain[1] - yDomain[0] : null; + const useDecimalAxis = yDomainSpan !== null && yDomainSpan < 6; + const formatAxisValue = (value: number) => + useDecimalAxis ? fmt.number(value, 1) : fmt.integer(value); const formatTooltipValue = (value: number) => fmt.number(value, 1); @@ -1313,7 +1322,7 @@ export function HealthChart({ domain={yDomain} tickFormatter={(value) => typeof value === "number" - ? formatAxisValue(Math.round(value)) + ? formatAxisValue(useDecimalAxis ? value : Math.round(value)) : String(value) } unit={ diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index 555dc6f34..0b3648701 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -39,6 +39,50 @@ interface SecondaryMetric { */ export type TrendDirectionSentiment = "up-good" | "up-bad" | "neutral"; +/** + * v1.4.33 F5 — single sentiment helper so the headline arrow, the 7-day + * delta value, and the comparison-overlay caption all paint the same + * colour for the same signal. Before this helper each consumer ran its + * own branch — the arrow read `slope30.direction`, the delta value read + * `trend7Delta > 0`, and the comparison caption read `compareDelta > 0`. + * When the 30-day regression and the 7-day delta disagreed (weight down + * over a month, up over a week) the tile painted a green arrow next to + * an orange value, which read as "two metrics in one tile". + * + * `change` is a signed magnitude of the movement under inspection (the + * 7-day delta in units, or the slope-projected-over-7-days for tiles + * with only `slope30`). `sentiment` is the metric's improvement + * direction. + * + * - `'positive'` — the change moves the metric toward its goal + * (e.g. weight on `up-bad` going down, mood on `up-good` going up). + * Renders green. + * - `'negative'` — the change moves away from the goal. Renders orange. + * - `'neutral'` — either the sentiment is neutral (pulse, BP-in-target), + * or the change is below the noise floor (|change| < 0.05). Renders + * muted. + */ +export type TrendSentimentDirection = "positive" | "negative" | "neutral"; + +export function getTrendSentiment( + change: number | null | undefined, + sentiment: TrendDirectionSentiment, +): TrendSentimentDirection { + if (change == null || Math.abs(change) < 0.05) return "neutral"; + if (sentiment === "neutral") return "neutral"; + const isUp = change > 0; + const isGood = + (sentiment === "up-good" && isUp) || + (sentiment === "up-bad" && !isUp); + return isGood ? "positive" : "negative"; +} + +function sentimentColorClass(direction: TrendSentimentDirection): string { + if (direction === "positive") return "text-dracula-green"; + if (direction === "negative") return "text-dracula-orange"; + return "text-muted-foreground"; +} + interface TrendCardProps { label: string; latest: number | null; @@ -87,6 +131,17 @@ interface TrendCardProps { */ compareBaseline?: ComparisonBaseline; compareDelta?: number | null; + /** + * v1.4.33 maintainer-item-1 — when the metric's most recent reading is + * older than 7 days but the all-time count is still non-zero, the + * caller passes a positive integer day count here. The tile renders a + * muted "Letzter Wert vor Xd" caption underneath the value row so the + * user sees the tile didn't disappear because of stale data — it kept + * showing the historical value with an explicit "how old" hint. + * + * `null` / `undefined` suppresses the caption (current data path). + */ + staleDays?: number | null; } export function TrendCard({ @@ -106,70 +161,57 @@ export function TrendCard({ trend7Delta = null, compareBaseline = "none", compareDelta = null, + staleDays = null, }: TrendCardProps) { const { t } = useTranslations(); const fmt = useFormatters(); - const TrendIcon = - slope30?.direction === "up" - ? ArrowUp - : slope30?.direction === "down" - ? ArrowDown - : slope30 - ? ArrowRight - : Minus; - // v1.5: per-metric arrow sentiment. Flat ("→") and "no slope yet" ("—") - // always stay muted; only an actual rise / fall paints colour, and the - // colour direction depends on what's good for *this* metric. The - // `text-muted-foreground` default keeps every metric tagged `neutral` - // (pulse, BP-in-target %) visually identical to v1.4.6. - const trendColor = ((): string => { - // No data yet (Minus icon) or a "stable" slope (ArrowRight) stays muted — - // we don't celebrate or scold a flat metric. - if (!slope30 || slope30.direction === "stable") { - return "text-muted-foreground"; - } - if (directionSentiment === "neutral") return "text-muted-foreground"; - const isUp = slope30.direction === "up"; - const isGood = - (directionSentiment === "up-good" && isUp) || - (directionSentiment === "up-bad" && !isUp); - return isGood ? "text-dracula-green" : "text-dracula-orange"; + // v1.4.33 F5 — pick a single signal to drive both the arrow and the + // 7-day delta value so they never disagree. `trend7Delta` is the + // user-facing number on the tile; when present, the arrow follows + // its sign. When the caller only supplies `slope30` (legacy tiles + // that haven't been migrated to a delta), we project the slope over + // a 7-day window so both signals share the same dimension. This + // unifies the F5 audit complaint where a green arrow + orange value + // appeared on the same tile because slope30 and trend7Delta could + // legitimately disagree (30-day downward arc, 7-day uptick). + const primarySignal: number | null = + trend7Delta != null + ? trend7Delta + : slope30 + ? slope30.slope * 7 + : null; + + const TrendIcon = ((): typeof ArrowUp => { + if (primarySignal == null) return Minus; + if (Math.abs(primarySignal) < 0.05) return ArrowRight; + return primarySignal > 0 ? ArrowUp : ArrowDown; })(); + // Sentiment is one centralized helper now — see `getTrendSentiment` + // at the top of the file. All three tile elements (arrow, 7-day + // delta value, comparison-overlay caption) route through the same + // function so colour reads as "same signal" across the tile. + const arrowSentiment = getTrendSentiment(primarySignal, directionSentiment); + const trendColor = sentimentColorClass(arrowSentiment); + const formatValue = (value: number) => fmt.number(value, 1); - // v1.4.15 Fix 4 — color the 7-day delta with the same metric-aware - // sentiment rules the headline arrow already uses. Tiny absolute - // deltas (< 0.05) read as "no movement" and stay muted. - const deltaColor = ((): string => { - if (trend7Delta == null || Math.abs(trend7Delta) < 0.05) { - return "text-muted-foreground"; - } - if (directionSentiment === "neutral") return "text-muted-foreground"; - const isUp = trend7Delta > 0; - const isGood = - (directionSentiment === "up-good" && isUp) || - (directionSentiment === "up-bad" && !isUp); - return isGood ? "text-dracula-green" : "text-dracula-orange"; - })(); + // v1.4.15 Fix 4 — the 7-day delta now shares the arrow's colour so + // both elements communicate the same sentiment for the same signal. + const deltaSentiment = getTrendSentiment(trend7Delta, directionSentiment); + const deltaColor = sentimentColorClass(deltaSentiment); /** * v1.4.16 phase B8 — color the comparison delta with the same * metric-aware sentiment rules used everywhere else on the tile. * Tiny deltas read as stable and stay muted. */ - const comparisonDeltaColor = ((): string => { - if (compareDelta == null || Math.abs(compareDelta) < 0.05) { - return "text-muted-foreground"; - } - if (directionSentiment === "neutral") return "text-muted-foreground"; - const isUp = compareDelta > 0; - const isGood = - (directionSentiment === "up-good" && isUp) || - (directionSentiment === "up-bad" && !isUp); - return isGood ? "text-dracula-green" : "text-dracula-orange"; - })(); + const comparisonSentiment = getTrendSentiment( + compareDelta, + directionSentiment, + ); + const comparisonDeltaColor = sentimentColorClass(comparisonSentiment); const formatDelta = (value: number): string => { if (Math.abs(value) < 0.05) return `±0`; @@ -218,7 +260,7 @@ export function TrendCard({ across every tile in the strip (Weight 80 kg vs. BP 122 mmHg used to land at different heights when one tile had a one-line heading and another had a two-line wrap). The arrow slot is always - present — when there is no slope yet, a muted "—" placeholder + present — when there is no signal yet, a muted "—" placeholder renders so the value row keeps a deterministic width. Marc's ask: "rechts neben der Zahl … der Pfeil [ist], wie gerade der Trend ist". */} @@ -237,40 +279,25 @@ export function TrendCard({ data-slot="trend-card-arrow" aria-hidden="true" > - {slope30 ? ( + {primarySignal != null ? ( ) : ( — )}
- {/* v1.4.16 phase B8 — comparison delta callout. Sits on its own - line below the latest value so the tile stays scannable on - mobile viewports (no horizontal-scroll, no overlap with the - existing 7d/30d row). Suppressed when comparison is off OR - we don't have enough data to compute a delta. - - v1.4.22 W5 reconcile (Design-H2) — on `=sm`. */} - {/* v1.4.27 MB7 / CF-41 — the `hidden sm:block` gate retired - because the mobile-secondary block below is gone (CF-67 - vestigial wrapper drop). The callout paints on every - viewport; when the parent grid is tight on ` + {/* v1.4.29 — `min-h-[18px]` reserves the callout slot at every + breakpoint so an empty callout doesn't drift sibling tiles + 18 px taller. v1.4.32 dropped the reservation at `sm:` (via + `sm:min-h-0`); the v1.4.33 A3 Win 5 audit caught the + asymmetry — a comparison-overlay run on the BD-Zielbereich + tile expanded the strip's intrinsic height while its + no-callout neighbours stayed short. Holding the floor across + breakpoints makes the row's height deterministic regardless + of which tiles render the callout. The callout itself clamps + to one line at ` {compareBaseline !== "none" && compareDelta != null && ( )} + {/* v1.4.33 maintainer-item-1 — stale-data caption. Renders + only when the caller passes `staleDays` (positive integer + of days since the metric's last reading). The tile keeps + the historical value visible because the maintainer would + rather see "Letzter Wert vor 12d / 80,2 kg" than have the + whole tile disappear because of a gap in logging. Ride the + same callout-slot reservation as the comparison-overlay + caption; on ` 0 && ( + + {t("dashboard.staleHint", { count: staleDays })} + + )}
{/* v1.4.29 — sub-row pair clips rather than wraps at `{node}, - ); -} - -describe("", () => { - it("renders the coach copy in the card variant", () => { - const html = render(); - expect(html).toContain("data-slot=\"assistant-disabled-notice\""); - expect(html.toLowerCase()).toContain("coach"); - }); - - it("renders the briefing copy", () => { - const html = render(); - expect(html).toContain("data-slot=\"assistant-disabled-notice\""); - }); - - it("renders the status copy", () => { - const html = render(); - expect(html).toContain("data-slot=\"assistant-disabled-notice\""); - }); - - it("renders the inline variant without the Card wrapper", () => { - const html = render( - , - ); - expect(html).toContain(" { + beforeEach(() => { + __resetChartTooltipObserverForTests(); + }); + + afterEach(() => { + __resetChartTooltipObserverForTests(); + }); + + it("getChartTooltipActiveServer always returns false", () => { + // The SSR snapshot must never claim a tooltip is active on the + // server — there's no DOM to inspect, and a stale `true` would + // render the FAB invisible on the initial pass. + expect(getChartTooltipActiveServer()).toBe(false); + }); + + it("getChartTooltipActive returns false when no wrappers exist", () => { + expect(getChartTooltipActive()).toBe(false); + }); + + it("subscribeChartTooltipActive returns an unsubscribe function", () => { + const unsubscribe = subscribeChartTooltipActive(() => undefined); + expect(typeof unsubscribe).toBe("function"); + unsubscribe(); + }); + + it("no-ops cleanly on a server-side render (no document available)", () => { + // Document is undefined in the vitest node environment — the + // module's `start()` early-returns. Subscribing should still + // succeed; the listener simply never fires. + let calls = 0; + const unsubscribe = subscribeChartTooltipActive(() => { + calls += 1; + }); + expect(getChartTooltipActive()).toBe(false); + expect(calls).toBe(0); + unsubscribe(); + }); + + it("supports multiple subscribers without throwing", () => { + const a = subscribeChartTooltipActive(() => undefined); + const b = subscribeChartTooltipActive(() => undefined); + expect(getChartTooltipActive()).toBe(false); + a(); + b(); + }); +}); diff --git a/src/components/insights/__tests__/insight-status-card.test.tsx b/src/components/insights/__tests__/insight-status-card.test.tsx index 519434238..9d74cc659 100644 --- a/src/components/insights/__tests__/insight-status-card.test.tsx +++ b/src/components/insights/__tests__/insight-status-card.test.tsx @@ -81,7 +81,7 @@ describe("", () => { text="ignored when hasProvider is false" />, ); - expect(html).toContain("AI provider not configured."); + expect(html).toContain("Assessment unavailable."); expect(html).not.toContain("ignored when hasProvider is false"); }); diff --git a/src/components/insights/__tests__/layout-coach-fab.test.tsx b/src/components/insights/__tests__/layout-coach-fab.test.tsx new file mode 100644 index 000000000..b8ff1d012 --- /dev/null +++ b/src/components/insights/__tests__/layout-coach-fab.test.tsx @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context"; + +import { LayoutCoachFab } from "../layout-coach-fab"; + +/** + * v1.4.33 (F15) — layout-level Coach FAB contract. + * + * The FAB sits at the mobile bottom-right. Before v1.4.33 it overlaid + * Recharts tooltips when the user tapped a data point in the chart's + * lower-right corner — the maintainer flagged this as F15 in the + * runtime audit. The fix wires the FAB to `useChartTooltipActive()` + * which reads a singleton MutationObserver of every + * `.recharts-tooltip-wrapper`; while a wrapper is visible the FAB + * fades and drops `pointer-events`. + * + * Project convention is SSR-only tests (no `@testing-library/react`), + * so we pin the initial-paint state — no tooltip can be active before + * hydration, so the FAB renders without the active-flag attribute + * and stays interactive on first paint. The end-to-end auto-hide + * behaviour is exercised in the Playwright mobile suite. + */ + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("returns nothing when no is mounted", () => { + // The hook returns `null` outside the provider; the FAB then + // renders nothing rather than crashing — same posture as + // ``. + const html = render(); + expect(html).not.toContain('data-slot="coach-launch-fab"'); + }); + + it("mounts the FAB with the launch-fab slot under the provider", () => { + const html = render( + + + , + ); + expect(html).toMatch(/data-slot="coach-launch-fab"/); + }); + + it("renders without the chart-tooltip-active flag on first paint", () => { + // SSR snapshot of `useChartTooltipActive()` is `false` so the + // FAB stays interactive on initial render. The active classes + // (opacity-0 + pointer-events-none) are applied client-side only + // once a Recharts tooltip wrapper flips its inline visibility. + const html = render( + + + , + ); + // The data attribute is `undefined` when inactive — React drops + // it from the DOM so the rendered markup never carries it. + expect(html).not.toContain('data-chart-tooltip-active="true"'); + // The shadcn ` - @@ -777,12 +782,18 @@ function MoodLogCard({ -
+ {/* v1.4.33 — the webhook secret is a long hex string; + on a 393 CSS px viewport the input + Copy button + used to push past the card edge and trigger a body + horizontal scroll. Stack vertically on `
-
+ {/* v1.4.33 — the Sync / Voll-Sync / Trennen triplet + overflowed the card edge on a 393 CSS px viewport + (~376 px of buttons + gaps inside 345 px of inner + width). Match the Withings card pattern: `flex-wrap` + + `min-w-[10rem]` on `
); @@ -109,15 +114,26 @@ export function TimezonePicker({ ))} + {/* v1.4.33 — visible label is "use browser zone"; the actual + IANA zone is read out via aria-label so screen-reader users + still hear which zone the click will apply. Previously the + label inlined `(Europe/Berlin)`, which on a 393 CSS px + viewport wrapped the button to two lines and broke height + parity with the adjacent select. */}

{labelHint}

diff --git a/src/components/settings/web-push-card.tsx b/src/components/settings/web-push-card.tsx index a6548f6e6..6eb552af5 100644 --- a/src/components/settings/web-push-card.tsx +++ b/src/components/settings/web-push-card.tsx @@ -3,9 +3,9 @@ import { useEffect, useState } from "react"; import { BellRing, Loader2 } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; /** Convert a URL-safe base64 string to an ArrayBuffer (for VAPID key). */ @@ -157,27 +157,11 @@ export function WebPushCard() { return (
-
-
- -

{t("settings.webPush")}

-
-
- {!loading && isSubscribed && ( - - {t("settings.configured")} - - )} - {!loading && isSubscribed && ( - - {t("settings.webPushActive")} - - )} -
-
-

- {t("settings.webPushDescription")} -

+
{loading ? ( diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx index 157284086..ed3ffd7b8 100644 --- a/src/components/targets/target-edit-sheet.tsx +++ b/src/components/targets/target-edit-sheet.tsx @@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { METRIC_BOUNDS, type ThresholdMetric, @@ -216,7 +217,7 @@ function TargetEditSheetBody({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] }); queryClient.invalidateQueries({ queryKey: ["insights", "targets"] }); - queryClient.invalidateQueries({ queryKey: ["analytics"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); queryClient.invalidateQueries({ queryKey: ["insights"] }); toast.success(t("targets.edit.saveSuccess")); onOpenChange(false); @@ -248,7 +249,7 @@ function TargetEditSheetBody({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] }); queryClient.invalidateQueries({ queryKey: ["insights", "targets"] }); - queryClient.invalidateQueries({ queryKey: ["analytics"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); queryClient.invalidateQueries({ queryKey: ["insights"] }); toast.success(t("targets.edit.resetSuccess")); onOpenChange(false); diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 642a7a72f..0e3df88f6 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -4,6 +4,31 @@ import { Slot } from "radix-ui"; import { cn } from "@/lib/utils"; +/** + * v1.4.33 IW9 — loading-state convention. + * + * Callers driving a transient loading state on a button must SWAP the + * leading icon for `` rather than APPENDING the spinner + * next to the existing icon. The legacy `{loading && }` + * pattern grew the button by ~24 px during the request, which produced + * the 0.186 CLS hit flagged on the v1.4.33 Lighthouse run. The + * spinner and the icon are both 16 px (`h-4 w-4`); swapping keeps the + * intrinsic button width stable. + * + * ```tsx + * + * ``` + * + * Also: pass `aria-busy={loading || undefined}` so screen readers + * announce the in-flight state. + */ const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 4f880247e..5db913006 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -2,12 +2,25 @@ import * as React from "react"; import { cn } from "@/lib/utils"; +/** + * v1.4.33 IW9 — Card padding normalised on `p-4 md:p-6`. + * + * Pre-v1.4.33 the shadcn defaults shipped `py-6` + `px-6` (uniform + * 24 px on every breakpoint) while the dashboard tile-strip + chart + * cards used `p-4 md:p-6` (16 px mobile, 24 px md+). Side-by-side on + * `/insights` the correlation cards + daily-briefing read as "denser" + * than the chart cards next to them; the visual reads as two card + * languages on the same page. Matching the dashboard token here gives + * the whole app a single card-padding contract. + * + * Refs `.planning/round-v1433-audit-polish.md` §4.1 + Win 4. + */ function Card({ className, ...props }: React.ComponentProps<"div">) { return (
) {
) { return (
); @@ -75,7 +88,10 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) { return (
); diff --git a/src/components/ui/date-input.tsx b/src/components/ui/date-input.tsx index 91c72adfb..395c83e3b 100644 --- a/src/components/ui/date-input.tsx +++ b/src/components/ui/date-input.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; /** @@ -13,16 +14,47 @@ import { useTranslations } from "@/lib/i18n/context"; * having to pull `locale` out of `useTranslations()` just to feed it * back into the same JSX prop, and guarantees a future date input * cannot silently regress by forgetting the hint. + * + * v1.4.33 — height parity with `` and ``. + * + * Mobile Safari + Android Chrome give `type="date"` (and + * `type="datetime-local"`) an intrinsic chrome that puffs the + * field past Tailwind's `h-10`, so the DOB cell on + * `/settings/account` rendered ~6 px taller than its row siblings + * and roughly 12 px wider on the Pixel-5 mobile-pass screenshots. + * `appearance-none` switches the native widget off, then + * `min-h-10` re-asserts the contract that every other input + * primitive ships. The `:webkit-date-and-time-value` shadow rule + * forces the embedded value text to occupy at least one full line + * even when iOS shrinks its placeholder, so the field looks + * identical on both engines. */ +const DATE_INPUT_HEIGHT_CLASSES = + "appearance-none min-h-10 h-10 [&::-webkit-date-and-time-value]:min-h-[1.5em]"; + type InputProps = React.ComponentProps; type DateInputProps = Omit; -export function DateInput(props: DateInputProps) { +export function DateInput({ className, ...props }: DateInputProps) { const { locale } = useTranslations(); - return ; + return ( + + ); } -export function DateTimeInput(props: DateInputProps) { +export function DateTimeInput({ className, ...props }: DateInputProps) { const { locale } = useTranslations(); - return ; + return ( + + ); } diff --git a/src/hooks/use-insights-analytics.ts b/src/hooks/use-insights-analytics.ts index 9c490c838..8debe56f9 100644 --- a/src/hooks/use-insights-analytics.ts +++ b/src/hooks/use-insights-analytics.ts @@ -1,10 +1,8 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; - import { useAuth } from "@/hooks/use-auth"; import { hasMetricData, type InsightMetric } from "@/lib/insights/metric-availability"; -import { queryKeys } from "@/lib/query-keys"; +import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import type { SubPageAnalyticsData } from "@/types/analytics"; /** @@ -53,24 +51,20 @@ export function useInsightsAnalytics( metric: InsightMetric, ): UseInsightsAnalyticsResult { const { isAuthenticated } = useAuth(); - - const query = useQuery({ - queryKey: queryKeys.analytics(), - queryFn: async (): Promise => { - const res = await fetch("/api/analytics"); - if (!res.ok) throw new Error("Failed"); - const json = (await res.json()) as { data: SubPageAnalyticsData }; - return json.data; - }, - enabled: isAuthenticated, - staleTime: 60 * 1000, - }); + // v1.4.33 IW2 — sub-pages only read `summaries[METRIC].count` for the + // gating short-circuit, so they ride on the slim slice. The mother + // page keeps the default thick slice for `correlations` / + // `healthScore` / `bpInTargetPct*`; both consumers live in the same + // queryKey tree (root `["analytics"]`) but the slim slice has its own + // cache slot under `["analytics", "summaries"]`. + const query = useAnalyticsQuery({ slice: "summaries" }); + const data = query.data as SubPageAnalyticsData | undefined; const isEmpty = Boolean(isAuthenticated) && - query.data !== undefined && + data !== undefined && !hasMetricData(metric, { - summaries: query.data.summaries, + summaries: data.summaries, // Sub-pages that go through this hook are sensor-backed only // (PULSE / WEIGHT / BMI / BLOOD_PRESSURE_SYS / SLEEP_DURATION). // Mood + medication run their own gate. @@ -79,7 +73,7 @@ export function useInsightsAnalytics( }); return { - data: query.data, + data, isLoading: query.isLoading, isEmpty, error: query.error as Error | null, diff --git a/src/hooks/use-scroll-reset-on-route.ts b/src/hooks/use-scroll-reset-on-route.ts new file mode 100644 index 000000000..7cae39724 --- /dev/null +++ b/src/hooks/use-scroll-reset-on-route.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * v1.4.33 IW9 — single source of truth for the "scroll to top on + * route mount" affordance. + * + * The pre-v1.4.33 wiring had `` AND the insights mother + * page each firing their own `requestAnimationFrame → scrollTo({top:0})` + * on mount. Two consumers doing the same trick is the kind of + * duplication that drifts; the second `scrollTo` lands ~16 ms after the + * first and re-snaps the viewport once the chart skeleton has inflated. + * On slow hydrates the user sees a visible "jump" — exactly the + * complaint in `.planning/round-v1433-audit-polish.md` §2.1. + * + * This hook is the single owner of that reset. Both the shell and the + * mother page call into it; the effect is mount-only and uses + * `requestAnimationFrame` so the scroll lands after the first paint + * (chart skeleton heights have settled, no double-snap). + * + * `behavior: "auto"` is intentional — `prefers-reduced-motion` users + * get the same instant reset everyone else does. A smooth scroll on + * a route change is itself a motion that some users opt out of. + */ +export function useScrollResetOnRoute() { + useEffect(() => { + if (typeof window === "undefined") return; + const handle = window.requestAnimationFrame(() => { + window.scrollTo({ top: 0, behavior: "auto" }); + }); + return () => window.cancelAnimationFrame(handle); + }, []); +} diff --git a/src/lib/__tests__/query-keys.test.ts b/src/lib/__tests__/query-keys.test.ts index 104922644..949299f3f 100644 --- a/src/lib/__tests__/query-keys.test.ts +++ b/src/lib/__tests__/query-keys.test.ts @@ -22,6 +22,18 @@ describe("queryKeys factory", () => { "en", ]); }); + + // v1.4.33 IW2 — slice param routes the slim consumer onto its own + // cache slot but leaves `["analytics"]` (the root) as a prefix so + // mutation invalidations sweep both shapes. + it("threads the slim slice through queryKeys.analytics", () => { + expect(queryKeys.analytics("summaries")).toEqual(["analytics", "summaries"]); + }); + + it("keeps the no-arg call byte-identical to the legacy literal", () => { + expect(queryKeys.analytics()).toEqual(["analytics"]); + expect(queryKeys.analytics(undefined)).toEqual(["analytics"]); + }); }); describe("dependent-key bundles", () => { diff --git a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts index 1170e5cd5..c108d0f84 100644 --- a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -import { buildCoachSnapshot } from "../snapshot"; +import { + __resetCoachSnapshotCacheForTests, + buildCoachSnapshot, +} from "../snapshot"; vi.mock("@/lib/db", () => ({ prisma: { @@ -50,6 +53,9 @@ function daysAgo( describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () => { beforeEach(() => { vi.clearAllMocks(); + // v1.4.33 — `buildCoachSnapshot` now memoises results in-process + // for 60 s; reset between tests so each fixture is read fresh. + __resetCoachSnapshotCacheForTests(); prismaMock.moodEntry.findMany.mockResolvedValue([]); prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); // v1.4.23 H4 — snapshot now reads `User.coachPrefsJson`. Default diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index df931eee8..fcc65f630 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -import { buildCoachSnapshot } from "../snapshot"; +import { + __resetCoachSnapshotCacheForTests, + buildCoachSnapshot, +} from "../snapshot"; vi.mock("@/lib/db", () => ({ prisma: { @@ -47,6 +50,10 @@ function daysAgo( describe("buildCoachSnapshot", () => { beforeEach(() => { vi.clearAllMocks(); + // v1.4.33 — `buildCoachSnapshot` now memoises results in-process + // for 60 s. Reset the cache between tests so each test sees its + // own freshly-mocked Prisma fixture. + __resetCoachSnapshotCacheForTests(); prismaMock.measurement.findMany.mockResolvedValue([]); prismaMock.moodEntry.findMany.mockResolvedValue([]); prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); @@ -182,4 +189,50 @@ describe("buildCoachSnapshot", () => { const parsed = JSON.parse(out.snapshotJson); expect(parsed.mood.timeline.recent.length).toBe(1); }); + + // v1.4.33 — 60 s in-process snapshot cache. A chat conversation + // sends 2-4 turns within a minute and rebuilding the snapshot from + // ~10 measurement reads on every turn is wasteful. Cache hits skip + // every persistent read; cache misses on a different scope (window + // or sources) compute fresh. + it("memoises the result for repeated (userId, scope) within the 60 s window", async () => { + featuresMock.mockResolvedValue({ + weight: { avg30: 82.1, coverage: { count: 5 } }, + }); + prismaMock.measurement.findMany.mockResolvedValue([ + daysAgo(2, 82.0, "WEIGHT"), + daysAgo(5, 82.4, "WEIGHT"), + ]); + + const first = await buildCoachSnapshot("user-1", { sources: ["weight"] }); + const second = await buildCoachSnapshot("user-1", { sources: ["weight"] }); + + // Same JSON shape on both calls. + expect(second.snapshotJson).toBe(first.snapshotJson); + // Prisma reads ran once for the first call; the second call short- + // circuits on the cache so the count is still 1. + expect(prismaMock.measurement.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(1); + }); + + it("recomputes when the scope window or sources change", async () => { + featuresMock.mockResolvedValue({ + weight: { avg30: 82.1, coverage: { count: 5 } }, + }); + prismaMock.measurement.findMany.mockResolvedValue([ + daysAgo(3, 82.0, "WEIGHT"), + ]); + + await buildCoachSnapshot("user-1", { + window: "last7days", + sources: ["weight"], + }); + await buildCoachSnapshot("user-1", { + window: "last30days", + sources: ["weight"], + }); + + // Two distinct window keys → two cache slots → two Prisma reads. + expect(prismaMock.measurement.findMany).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 6c500670f..f8d694b62 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -274,6 +274,67 @@ function resolveScope(scope?: CoachScope): { }; } +/** + * v1.4.33 — 60-second in-memory cache for `buildCoachSnapshot()`. The + * snapshot reads only persisted data; a single chat conversation sends + * 2-4 turns within a minute and the snapshot would otherwise rebuild + * from the same rows each turn. Caching the result for 60s shaves the + * ~10 measurement reads + the GLP-1 / mood / intake side-fetches off + * every turn after the first, which `.planning/round-v1433-audit-perf.md` + * §3.3 estimates at 200-800 ms of server-side tail. + * + * Scope is part of the cache key so a switch from `last30days` to + * `last7days` (or a different `sources` set) computes fresh. The map + * is bounded at 64 entries — a multi-tenant deployment with a few + * active power users sits well inside that ceiling even if each cycles + * through several scopes per minute. + */ +const SNAPSHOT_TTL_MS = 60_000; +const SNAPSHOT_LRU_MAX = 64; +const snapshotCache = new Map< + string, + { expiresAt: number; result: CoachSnapshotResult } +>(); + +function snapshotCacheKey(userId: string, scope: CoachScope | undefined): string { + const { sources, window } = resolveScope(scope); + const sourceList = Array.from(sources).sort().join(","); + return `${userId}|${window}|${sourceList}`; +} + +function readSnapshotCache(key: string): CoachSnapshotResult | null { + const entry = snapshotCache.get(key); + if (!entry) return null; + if (entry.expiresAt <= Date.now()) { + snapshotCache.delete(key); + return null; + } + // Touch for LRU — re-insert moves to the end of the Map's iteration order. + snapshotCache.delete(key); + snapshotCache.set(key, entry); + return entry.result; +} + +function writeSnapshotCache(key: string, result: CoachSnapshotResult): void { + if (snapshotCache.size >= SNAPSHOT_LRU_MAX) { + // Evict the oldest entry — JS Map iteration order is insertion order, + // so the first key is the least-recently inserted/touched. + const oldest = snapshotCache.keys().next().value; + if (oldest !== undefined) { + snapshotCache.delete(oldest); + } + } + snapshotCache.set(key, { + expiresAt: Date.now() + SNAPSHOT_TTL_MS, + result, + }); +} + +/** Clear the snapshot cache. Test-only escape hatch. */ +export function __resetCoachSnapshotCacheForTests(): void { + snapshotCache.clear(); +} + /** * Build the Coach prompt snapshot for `userId`. Always uses * `includeRaw=false` because the Coach replies are conversational and @@ -293,10 +354,27 @@ function resolveScope(scope?: CoachScope): { * user's display timezone (read from `User.timezone`). Falls back to * Europe/Berlin when the column is missing so the legacy snapshot * stays byte-identical for the only path the v1.4.24 suite tested. + * + * v1.4.33 — wraps the previous `buildCoachSnapshotImpl` with a 60s + * in-memory LRU keyed on `(userId, window, sources)`. The Coach's + * chat handler calls this once per turn; within the same conversation + * the second+ turn lands a cache hit and skips the row-level reads. */ export async function buildCoachSnapshot( userId: string, scope?: CoachScope, +): Promise { + const key = snapshotCacheKey(userId, scope); + const cached = readSnapshotCache(key); + if (cached) return cached; + const result = await buildCoachSnapshotImpl(userId, scope); + writeSnapshotCache(key, result); + return result; +} + +async function buildCoachSnapshotImpl( + userId: string, + scope?: CoachScope, ): Promise { const { sources: scopedSources, window } = resolveScope(scope); diff --git a/src/lib/analytics/__tests__/summaries-slice.test.ts b/src/lib/analytics/__tests__/summaries-slice.test.ts new file mode 100644 index 000000000..1ba88676f --- /dev/null +++ b/src/lib/analytics/__tests__/summaries-slice.test.ts @@ -0,0 +1,163 @@ +/** + * v1.4.33 C1 — unit-level pin for the slim summaries slice. Heavier + * integration coverage lives in `tests/integration/analytics-summaries-slice.test.ts` + * (real Postgres, real `regr_slope`); this file mocks `$queryRaw` so + * the slope/round/empty contracts are pinned without a container. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + $queryRaw: vi.fn(), + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { computeSummariesSlice } from "../summaries-slice"; + +const RAW = prisma.$queryRaw as unknown as ReturnType; + +beforeEach(() => { + RAW.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("computeSummariesSlice", () => { + it("returns the empty-summary skeleton when the user has no rows", async () => { + // Pass 1 (aggregates) returns empty, pass 2 (latest) returns empty. + RAW.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + + const result = await computeSummariesSlice("user-1"); + + // Every enum option is seeded so consumers can read + // `summaries.WEIGHT` and get a stable empty shape. + expect(result.summaries.WEIGHT).toEqual({ + count: 0, + latest: null, + min: null, + max: null, + mean: null, + avg7: null, + avg30: null, + slope7: null, + slope30: null, + slope90: null, + anomalyCount: 0, + avg30LastMonth: null, + avg30LastYear: null, + }); + expect(result.bmi).toBeNull(); + // Both SQL passes fired. + expect(RAW).toHaveBeenCalledTimes(2); + }); + + it("maps a populated aggregate row into the DataSummary shape", async () => { + RAW.mockResolvedValueOnce([ + { + type: "WEIGHT", + count: BigInt(42), + min_value: 79.2, + max_value: 84.1, + mean_value: 82.05, + avg7: 81.9, + avg30: 82.1, + slope7: -0.014, + r2_7: 0.65, + slope30: -0.005, + r2_30: 0.42, + slope90: 0.001, + r2_90: 0.12, + }, + ]).mockResolvedValueOnce([{ type: "WEIGHT", value: 81.4 }]); + + const result = await computeSummariesSlice("user-1"); + const weight = result.summaries.WEIGHT; + + expect(weight.count).toBe(42); + expect(weight.latest).toBe(81.4); + expect(weight.min).toBe(79.2); + expect(weight.max).toBe(84.1); + expect(weight.mean).toBe(82.05); + expect(weight.avg7).toBe(81.9); + expect(weight.avg30).toBe(82.1); + expect(weight.anomalyCount).toBe(0); + expect(weight.avg30LastMonth).toBeNull(); + expect(weight.avg30LastYear).toBeNull(); + // Slope direction respects the 0.01-units-per-day "stable" + // threshold the JS helper uses. -0.014 → "down", -0.005 → + // "stable", 0.001 → "stable". + expect(weight.slope7).toEqual({ + slope: -0.014, + direction: "down", + confidence: 0.65, + }); + expect(weight.slope30).toEqual({ + slope: -0.005, + direction: "stable", + confidence: 0.42, + }); + expect(weight.slope90).toEqual({ + slope: 0.001, + direction: "stable", + confidence: 0.12, + }); + }); + + it("returns a null slope tuple when the SQL slope is null (insufficient rows)", async () => { + RAW.mockResolvedValueOnce([ + { + type: "PULSE", + count: BigInt(1), + min_value: 72, + max_value: 72, + mean_value: 72, + avg7: 72, + avg30: 72, + // regr_slope returns NULL when n < 2. + slope7: null, + r2_7: null, + slope30: null, + r2_30: null, + slope90: null, + r2_90: null, + }, + ]).mockResolvedValueOnce([{ type: "PULSE", value: 72 }]); + + const result = await computeSummariesSlice("user-1"); + expect(result.summaries.PULSE.slope7).toBeNull(); + expect(result.summaries.PULSE.slope30).toBeNull(); + expect(result.summaries.PULSE.slope90).toBeNull(); + }); + + it("seeds the latest value from the DISTINCT ON pass per type", async () => { + RAW.mockResolvedValueOnce([ + { + type: "PULSE", + count: BigInt(3), + min_value: 60, + max_value: 95, + mean_value: 77, + avg7: 77, + avg30: 77, + slope7: 0, + r2_7: 0, + slope30: 0, + r2_30: 0, + slope90: 0, + r2_90: 0, + }, + ]).mockResolvedValueOnce([{ type: "PULSE", value: 88 }]); + + const result = await computeSummariesSlice("user-1"); + // Latest is NOT max — comes from the row at MAX(measured_at). + expect(result.summaries.PULSE.latest).toBe(88); + expect(result.summaries.PULSE.max).toBe(95); + }); +}); diff --git a/src/lib/analytics/__tests__/trends.test.ts b/src/lib/analytics/__tests__/trends.test.ts index ce7c834ab..4c295c28c 100644 --- a/src/lib/analytics/__tests__/trends.test.ts +++ b/src/lib/analytics/__tests__/trends.test.ts @@ -131,4 +131,36 @@ describe("summarize", () => { expect(summary.slope7).toBeNull(); // window snaps to `now`, no points expect(summary.slope30).toBeNull(); // same }); + + // v1.4.33 P0 regression — production stacktrace + // `RangeError: Maximum call stack size exceeded` at index 3 of the + // analytics route's per-type `Promise.all` (PULSE). + // + // Root cause: `Math.min(...values)` / `Math.max(...values)` spread the + // whole series as function arguments. V8 caps function arity at + // ~125 000-130 000; an Apple-Health-synced PULSE series easily + // exceeds that for a multi-year power user. The fix folds min/max via + // a single pass instead, keeping the working set bounded. + it("survives a multi-hundred-thousand-row series without blowing the stack", () => { + // 250 000 points — well above V8's spread-arg ceiling. The + // unrolled values give us deterministic min/max so we can assert + // both the safety contract AND the correctness contract in one go. + const N = 250_000; + const now = Date.now(); + const data: DataPoint[] = new Array(N); + for (let i = 0; i < N; i++) { + data[i] = { + date: new Date(now - (N - i) * 1000), + // Wave between 40 and 199; min=40, max=199. + value: 40 + (i % 160), + }; + } + // Must not throw — before the fix this raised + // `RangeError: Maximum call stack size exceeded`. + const summary = summarize(data); + expect(summary.count).toBe(N); + expect(summary.min).toBe(40); + expect(summary.max).toBe(199); + expect(summary.latest).toBe(data[N - 1].value); + }); }); diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts new file mode 100644 index 000000000..209996c75 --- /dev/null +++ b/src/lib/analytics/summaries-slice.ts @@ -0,0 +1,236 @@ +/** + * v1.4.33 C1 — slim `summaries` slice for `/api/analytics?slice=summaries`. + * + * The default `/api/analytics` path fans out 30+ `Promise.all`-wrapped + * chunked findMany reads (one per `MeasurementType`) so it can hand + * each series to `summarize()` for slope7/30/90 + anomaly detection + + * lastMonth/lastYear means. The dashboard tile strip only consumes + * `count`, `latest`, `avg7`, `avg30`, `min`, `max`, `mean`, and the + * three slope tuples — every other field gets `null`-coalesced before + * it ever paints — yet the user waits on the full chunked walk. + * + * This helper resolves the same `DataSummary` shape (per-type) with + * two SQL passes: + * + * 1. **`groupBy` + windowed aggregates** — one `$queryRaw` carrying + * `COUNT`/`MIN`/`MAX`/`AVG`, the 7-day and 30-day `AVG` slices + * via `FILTER`, plus `regr_slope` + `regr_r2` for the slope + * tuples at 7d / 30d / 90d windows. Postgres folds all of this + * into one index scan over `measurements WHERE user_id = …`. + * + * 2. **`DISTINCT ON (type)` latest read** — one `$queryRaw` + * returning the most-recent `(type, value)` pair per type so the + * slim shape can populate `latest`. `MAX(value)` would not do — + * we need the value of the row at `MAX(measured_at)`, not the + * largest value ever recorded. + * + * Fields the slim shape intentionally omits (the dashboard never + * reads them on first paint): + * - `anomalyCount`: would require an extra per-type read for the + * z-score loop. The Coach / insights paths still get it via the + * default slice. + * - `avg30LastMonth` / `avg30LastYear`: the dashboard tile delta + * callout uses them, but only when the comparison-baseline + * widget is enabled — that path already pre-fetches the default + * slice via the explicit comparison opt-in. + * + * Slope `confidence` is the SQL `regr_r2` value (R²) which matches + * the existing `trendSlope()` helper's `Math.round(rSquared * 100) / + * 100`. Slope `direction` is derived from `slope`'s sign with the + * same 0.01-units-per-day "stable" threshold the JS helper uses. + */ +import { prisma } from "@/lib/db"; +import type { DataSummary } from "@/lib/analytics/trends"; +import { measurementTypeEnum } from "@/lib/validations/measurement"; +import { annotate } from "@/lib/logging/context"; + +interface AggregateRow { + type: string; + count: bigint; + min_value: number | null; + max_value: number | null; + mean_value: number | null; + avg7: number | null; + avg30: number | null; + slope7: number | null; + r2_7: number | null; + slope30: number | null; + r2_30: number | null; + slope90: number | null; + r2_90: number | null; +} + +interface LatestRow { + type: string; + value: number; +} + +/** Same threshold/rounding contract as `trendSlope()` in `trends.ts`. */ +function buildSlope( + slope: number | null, + rSquared: number | null, +): DataSummary["slope7"] { + if (slope === null) return null; + const threshold = 0.01; + const direction: "up" | "down" | "stable" = + Math.abs(slope) < threshold ? "stable" : slope > 0 ? "up" : "down"; + return { + slope: Math.round(slope * 1000) / 1000, + direction, + confidence: rSquared === null ? 0 : Math.round(rSquared * 100) / 100, + }; +} + +function round2(value: number | null): number | null { + if (value === null || !Number.isFinite(value)) return null; + return Math.round(value * 100) / 100; +} + +function emptySummary(): DataSummary { + return { + count: 0, + latest: null, + min: null, + max: null, + mean: null, + avg7: null, + avg30: null, + slope7: null, + slope30: null, + slope90: null, + anomalyCount: 0, + avg30LastMonth: null, + avg30LastYear: null, + }; +} + +export interface SummariesSlice { + summaries: Record; + /** + * `null` on the slim slice. The default route computes BMI from + * `summaries.WEIGHT.latest` + `user.heightCm`; consumers of the + * slim slice that need BMI re-derive client-side or fetch the + * default slice. + */ + bmi: null; +} + +export async function computeSummariesSlice( + userId: string, +): Promise { + // The two passes run in parallel — they target the same index path + // (`measurements (user_id, type, measured_at)`) but Postgres can + // serve them off the buffer cache concurrently. + const [aggregates, latests] = await Promise.all([ + prisma.$queryRaw` + SELECT + m."type"::text AS type, + COUNT(*) AS count, + MIN(m."value")::double precision AS min_value, + MAX(m."value")::double precision AS max_value, + AVG(m."value")::double precision AS mean_value, + AVG(m."value") FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '7 days' + )::double precision AS avg7, + AVG(m."value") FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '30 days' + )::double precision AS avg30, + REGR_SLOPE( + m."value", + EXTRACT(EPOCH FROM m."measured_at") / 86400.0 + ) FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '7 days' + )::double precision AS slope7, + REGR_R2( + m."value", + EXTRACT(EPOCH FROM m."measured_at") / 86400.0 + ) FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '7 days' + )::double precision AS r2_7, + REGR_SLOPE( + m."value", + EXTRACT(EPOCH FROM m."measured_at") / 86400.0 + ) FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '30 days' + )::double precision AS slope30, + REGR_R2( + m."value", + EXTRACT(EPOCH FROM m."measured_at") / 86400.0 + ) FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '30 days' + )::double precision AS r2_30, + REGR_SLOPE( + m."value", + EXTRACT(EPOCH FROM m."measured_at") / 86400.0 + ) FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '90 days' + )::double precision AS slope90, + REGR_R2( + m."value", + EXTRACT(EPOCH FROM m."measured_at") / 86400.0 + ) FILTER ( + WHERE m."measured_at" >= NOW() - INTERVAL '90 days' + )::double precision AS r2_90 + FROM measurements m + WHERE m."user_id" = ${userId} + GROUP BY m."type" + `, + prisma.$queryRaw` + SELECT DISTINCT ON (m."type") + m."type"::text AS type, + m."value"::double precision AS value + FROM measurements m + WHERE m."user_id" = ${userId} + ORDER BY m."type", m."measured_at" DESC + `, + ]); + + const latestByType = new Map(); + for (const row of latests) { + latestByType.set(row.type, Number(row.value)); + } + + // Seed every enum option so consumers can read `summaries.PULSE` and + // get a deterministic empty shape rather than `undefined`. Mirrors + // the default route which iterates `measurementTypeEnum.options`. + const summaries: Record = {}; + for (const type of measurementTypeEnum.options) { + summaries[type] = emptySummary(); + } + + let totalRows = 0; + for (const row of aggregates) { + const count = Number(row.count); + totalRows += count; + const latest = latestByType.get(row.type) ?? null; + summaries[row.type] = { + count, + latest, + min: round2(row.min_value), + max: round2(row.max_value), + mean: round2(row.mean_value), + avg7: round2(row.avg7), + avg30: round2(row.avg30), + slope7: buildSlope(row.slope7, row.r2_7), + slope30: buildSlope(row.slope30, row.r2_30), + slope90: buildSlope(row.slope90, row.r2_90), + anomalyCount: 0, + avg30LastMonth: null, + avg30LastYear: null, + }; + } + + annotate({ + action: { name: "analytics.get.slim" }, + meta: { + analytics: { + slim_summaries: { + row_count: totalRows, + type_count: aggregates.length, + }, + }, + }, + }); + + return { summaries, bmi: null }; +} diff --git a/src/lib/analytics/trends.ts b/src/lib/analytics/trends.ts index d8971b720..581f86e95 100644 --- a/src/lib/analytics/trends.ts +++ b/src/lib/analytics/trends.ts @@ -192,7 +192,24 @@ export function summarize(data: DataPoint[]): DataSummary { const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime()); const values = sorted.map((p) => p.value); - const mean = values.reduce((s, v) => s + v, 0) / values.length; + // v1.4.33 P0 — single-pass fold for sum/min/max replaces the previous + // `Math.min(...values)` / `Math.max(...values)` spread. V8 caps + // function arity at ~125k-130k; Apple-Health-synced PULSE series for + // a multi-year power user routinely exceed that, and the spread + // surfaced as `RangeError: Maximum call stack size exceeded` from the + // `/api/analytics` route's per-type `Promise.all` aggregator. Folding + // the three reductions into one walk keeps the working set bounded + // (no transient argument array) and is also cheaper than three + // separate passes. + let sum = 0; + let minVal = values[0]; + let maxVal = values[0]; + for (const v of values) { + sum += v; + if (v < minVal) minVal = v; + if (v > maxVal) maxVal = v; + } + const mean = sum / values.length; const now = Date.now(); const DAY = 24 * 60 * 60 * 1000; @@ -222,8 +239,8 @@ export function summarize(data: DataPoint[]): DataSummary { return { count: data.length, latest: sorted[sorted.length - 1].value, - min: Math.min(...values), - max: Math.max(...values), + min: minVal, + max: maxVal, mean: Math.round(mean * 100) / 100, avg7: last7.length > 0 diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 86c042fde..eb208a54c 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -169,8 +169,8 @@ export function apiHandler Promise>( // envelope is locked per // `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5. // Older iOS clients that don't know the errorCode surface - // this as a generic 403; v1.4.31+ clients render the - // `` empty state. + // this as a generic 403; v1.4.31+ clients can branch on the + // errorCode to render an inline operator-disabled notice. evt.setError(error); response = NextResponse.json( { diff --git a/src/lib/feature-flags/__tests__/index.test.ts b/src/lib/feature-flags/__tests__/index.test.ts index b67fb446e..a15c1c041 100644 --- a/src/lib/feature-flags/__tests__/index.test.ts +++ b/src/lib/feature-flags/__tests__/index.test.ts @@ -8,8 +8,15 @@ vi.mock("@/lib/db", () => ({ }, })); +// v1.4.33 — `mockEvent` lets the dedicated memoisation describe-block +// below pin a stable WideEventBuilder stub so the per-request cache +// keys on the same reference across multiple `getAssistantFlags()` +// calls. The legacy describe-blocks leave `mockEvent` null so every +// call gets a fresh `{}` and the memo never collapses — preserves the +// pre-v1.4.33 assertion semantics. +let mockEvent: object | null = null; vi.mock("@/lib/logging/context", () => ({ - getEvent: () => ({ addWarning: vi.fn() }), + getEvent: () => mockEvent ?? { addWarning: vi.fn() }, })); import { prisma } from "@/lib/db"; @@ -25,6 +32,7 @@ const FIND = prisma.appSettings.findUnique as ReturnType; beforeEach(() => { FIND.mockReset(); + mockEvent = null; }); describe("resolveAssistantFlags", () => { @@ -162,3 +170,51 @@ describe("requireAssistantSurface", () => { expect(err.errorCode).toBe("assistant.disabled.correlations"); }); }); + +describe("per-request memoisation", () => { + // v1.4.33 — When the same request opens the Coach drawer, five gated + // routes (`/api/insights/chat`, the rail list, each `-status` + // call, etc.) all funnel through `getAssistantFlags()` → `AppSettings` + // read. The memo on the active WideEventBuilder collapses those to + // one DB hit. + it("collapses repeated reads inside the same request to one DB call", async () => { + FIND.mockResolvedValue({ + assistantEnabled: true, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: true, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + }); + // Pin a stable stub object so every `getEvent()` inside this test + // returns the same reference — the memo keys on identity, so a + // shared reference == shared cache. + mockEvent = { addWarning: vi.fn() }; + + const a = await getAssistantFlags(); + const b = await getAssistantFlags(); + const c = await getAssistantFlags(); + + expect(a).toBe(b); + expect(b).toBe(c); + expect(FIND).toHaveBeenCalledTimes(1); + }); + + it("issues independent reads when the request context changes", async () => { + FIND.mockResolvedValue({ + assistantEnabled: true, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: true, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + }); + + mockEvent = { addWarning: vi.fn(), id: "req-1" }; + await getAssistantFlags(); + mockEvent = { addWarning: vi.fn(), id: "req-2" }; + await getAssistantFlags(); + + expect(FIND).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/feature-flags/index.ts b/src/lib/feature-flags/index.ts index 3b0b32551..c9126a803 100644 --- a/src/lib/feature-flags/index.ts +++ b/src/lib/feature-flags/index.ts @@ -1,5 +1,6 @@ import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; +import { memoizePerRequest } from "@/lib/request-cache"; /** * v1.4.31 — Assistant-surface operator feature flags. @@ -59,37 +60,47 @@ export const ASSISTANT_FLAGS_DEFAULT: AssistantFlagSet = Object.freeze({ * * The master always wins: every sub-flag is forced false when the * master is off, before the resolved set leaves this function. + * + * v1.4.33 — memoised per-request so a single Coach-drawer open that + * fires five gated fetches in parallel (`/api/insights/chat`, + * `/api/insights/comprehensive`, six `/api/insights/-status` + * routes, the rail list) reads `AppSettings.singleton` once instead of + * five times. The cache lives on the active `WideEventBuilder` so it + * goes away with the request; behaviour outside an event context (unit + * tests, background jobs) stays unchanged. */ export async function getAssistantFlags(): Promise { - try { - const settings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, - select: { - assistantEnabled: true, - assistantCoachEnabled: true, - assistantBriefingEnabled: true, - assistantInsightStatusEnabled: true, - assistantCorrelationsEnabled: true, - assistantHealthScoreExplainerEnabled: true, - }, - }); + return memoizePerRequest("assistant-flags", async () => { + try { + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { + assistantEnabled: true, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: true, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + }, + }); - const master = settings?.assistantEnabled ?? true; - return resolveAssistantFlags({ - enabled: master, - coach: settings?.assistantCoachEnabled ?? true, - briefing: settings?.assistantBriefingEnabled ?? true, - insightStatus: settings?.assistantInsightStatusEnabled ?? true, - correlations: settings?.assistantCorrelationsEnabled ?? true, - healthScoreExplainer: - settings?.assistantHealthScoreExplainerEnabled ?? true, - }); - } catch { - getEvent()?.addWarning( - "Failed to load assistant feature flags, using defaults", - ); - return ASSISTANT_FLAGS_DEFAULT; - } + const master = settings?.assistantEnabled ?? true; + return resolveAssistantFlags({ + enabled: master, + coach: settings?.assistantCoachEnabled ?? true, + briefing: settings?.assistantBriefingEnabled ?? true, + insightStatus: settings?.assistantInsightStatusEnabled ?? true, + correlations: settings?.assistantCorrelationsEnabled ?? true, + healthScoreExplainer: + settings?.assistantHealthScoreExplainerEnabled ?? true, + }); + } catch { + getEvent()?.addWarning( + "Failed to load assistant feature flags, using defaults", + ); + return ASSISTANT_FLAGS_DEFAULT; + } + }); } /** @@ -120,8 +131,8 @@ export function resolveAssistantFlags(raw: AssistantFlagSet): AssistantFlagSet { * * Older iOS clients that predate the v1.4.31 contract surface this as * a generic 403 (their existing 403-handler covers the case); v1.4.31+ - * clients read the `errorCode` to render the `AssistantDisabledNotice` - * empty-state. + * clients read the `errorCode` to render an inline operator-disabled + * notice. */ export class AssistantDisabledError extends Error { readonly surface: AssistantSurface; diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index fde03d4e8..c7f03e84c 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -57,11 +57,6 @@ function round(value: number, digits = 1): number { return Math.round(value * factor) / factor; } -function average(values: number[]): number { - if (values.length === 0) return 0; - return values.reduce((sum, value) => sum + value, 0) / values.length; -} - function normalizeSummaryText(value: string): string { return stripChartTokens(value).replace(/\s+/g, " ").trim(); } @@ -74,14 +69,29 @@ function summarizeSeries(series: Array<{ value: number }>) { if (series.length === 0) return null; const first = series[0].value; const last = series[series.length - 1].value; + // v1.4.33 — fold sum/min/max into a single walk. The previous + // `Math.min(...series.map(...))` / `Math.max(...series.map(...))` + // spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics + // path; see `.planning/round-v1433-analytics-500-report.md` §"Carry- + // over". These helpers are fed bounded windows today so the crash + // never reached them, but the spread allocates a transient args array + // on every call — the fold is both stack-safe and cheaper. + let sum = 0; + let minVal = series[0].value; + let maxVal = series[0].value; + for (const entry of series) { + sum += entry.value; + if (entry.value < minVal) minVal = entry.value; + if (entry.value > maxVal) maxVal = entry.value; + } return { points: series.length, start: round(first, 2), end: round(last, 2), delta: round(last - first, 2), - mean: round(average(series.map((entry) => entry.value)), 2), - min: round(Math.min(...series.map((entry) => entry.value)), 2), - max: round(Math.max(...series.map((entry) => entry.value)), 2), + mean: round(sum / series.length, 2), + min: round(minVal, 2), + max: round(maxVal, 2), }; } diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index c669f932a..af3fe42ef 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -42,11 +42,6 @@ function round(value: number, digits = 1): number { return Math.round(value * factor) / factor; } -function average(values: number[]): number { - if (values.length === 0) return 0; - return values.reduce((sum, value) => sum + value, 0) / values.length; -} - function normalizeSummaryText(value: string): string { return stripChartTokens(value).replace(/\s+/g, " ").trim(); } @@ -59,14 +54,30 @@ function summarizeSeries(series: Array<{ value: number }>) { if (series.length === 0) return null; const first = series[0].value; const last = series[series.length - 1].value; + // v1.4.33 — fold sum/min/max into a single walk. The previous + // `Math.min(...series.map(...))` / `Math.max(...series.map(...))` + // spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics + // path and is the same anti-pattern fixed in `summarize()` (see + // `.planning/round-v1433-analytics-500-report.md` §"Carry-over"). + // These helpers feed off bounded windows today so they did not crash, + // but the spread allocates a transient args array on every call; + // folding once is both stack-safe and cheaper. + let sum = 0; + let minVal = series[0].value; + let maxVal = series[0].value; + for (const entry of series) { + sum += entry.value; + if (entry.value < minVal) minVal = entry.value; + if (entry.value > maxVal) maxVal = entry.value; + } return { points: series.length, start: round(first, 2), end: round(last, 2), delta: round(last - first, 2), - mean: round(average(series.map((entry) => entry.value)), 2), - min: round(Math.min(...series.map((entry) => entry.value)), 2), - max: round(Math.max(...series.map((entry) => entry.value)), 2), + mean: round(sum / series.length, 2), + min: round(minVal, 2), + max: round(maxVal, 2), }; } diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index f89abdfaf..e48101dc7 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -51,10 +51,6 @@ function round(value: number, digits = 1): number { return Math.round(value * factor) / factor; } -function average(values: number[]): number { - return values.reduce((sum, value) => sum + value, 0) / values.length; -} - function normalizeSummaryText(value: string): string { return stripChartTokens(value).replace(/\s+/g, " ").trim(); } @@ -63,14 +59,29 @@ function summarizeSeries(series: Array<{ value: number }>) { if (series.length === 0) return null; const first = series[0].value; const last = series[series.length - 1].value; + // v1.4.33 — fold sum/min/max into a single walk. The previous + // `Math.min(...series.map(...))` / `Math.max(...series.map(...))` + // spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics + // path; see `.planning/round-v1433-analytics-500-report.md` §"Carry- + // over". These helpers are fed bounded windows today so the crash + // never reached them, but the spread allocates a transient args array + // on every call — the fold is both stack-safe and cheaper. + let sum = 0; + let minVal = series[0].value; + let maxVal = series[0].value; + for (const entry of series) { + sum += entry.value; + if (entry.value < minVal) minVal = entry.value; + if (entry.value > maxVal) maxVal = entry.value; + } return { points: series.length, start: round(first, 2), end: round(last, 2), delta: round(last - first, 2), - mean: round(average(series.map((entry) => entry.value)), 2), - min: round(Math.min(...series.map((entry) => entry.value)), 2), - max: round(Math.max(...series.map((entry) => entry.value)), 2), + mean: round(sum / series.length, 2), + min: round(minVal, 2), + max: round(maxVal, 2), }; } diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index ad413d742..f8acfb72f 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -49,11 +49,6 @@ function round(value: number, digits = 1): number { return Math.round(value * factor) / factor; } -function average(values: number[]): number { - if (values.length === 0) return 0; - return values.reduce((sum, value) => sum + value, 0) / values.length; -} - function normalizeSummaryText(value: string): string { return stripChartTokens(value).replace(/\s+/g, " ").trim(); } @@ -66,14 +61,29 @@ function summarizeSeries(series: Array<{ value: number }>) { if (series.length === 0) return null; const first = series[0].value; const last = series[series.length - 1].value; + // v1.4.33 — fold sum/min/max into a single walk. The previous + // `Math.min(...series.map(...))` / `Math.max(...series.map(...))` + // spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics + // path; see `.planning/round-v1433-analytics-500-report.md` §"Carry- + // over". These helpers are fed bounded windows today so the crash + // never reached them, but the spread allocates a transient args array + // on every call — the fold is both stack-safe and cheaper. + let sum = 0; + let minVal = series[0].value; + let maxVal = series[0].value; + for (const entry of series) { + sum += entry.value; + if (entry.value < minVal) minVal = entry.value; + if (entry.value > maxVal) maxVal = entry.value; + } return { points: series.length, start: round(first, 2), end: round(last, 2), delta: round(last - first, 2), - mean: round(average(series.map((entry) => entry.value)), 2), - min: round(Math.min(...series.map((entry) => entry.value)), 2), - max: round(Math.max(...series.map((entry) => entry.value)), 2), + mean: round(sum / series.length, 2), + min: round(minVal, 2), + max: round(maxVal, 2), }; } diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index a206b4deb..a5a5887ac 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -48,11 +48,6 @@ function round(value: number, digits = 1): number { return Math.round(value * factor) / factor; } -function average(values: number[]): number { - if (values.length === 0) return 0; - return values.reduce((sum, value) => sum + value, 0) / values.length; -} - function normalizeSummaryText(value: string): string { return stripChartTokens(value).replace(/\s+/g, " ").trim(); } @@ -65,14 +60,29 @@ function summarizeSeries(series: Array<{ value: number }>) { if (series.length === 0) return null; const first = series[0].value; const last = series[series.length - 1].value; + // v1.4.33 — fold sum/min/max into a single walk. The previous + // `Math.min(...series.map(...))` / `Math.max(...series.map(...))` + // spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics + // path; see `.planning/round-v1433-analytics-500-report.md` §"Carry- + // over". These helpers are fed bounded windows today so the crash + // never reached them, but the spread allocates a transient args array + // on every call — the fold is both stack-safe and cheaper. + let sum = 0; + let minVal = series[0].value; + let maxVal = series[0].value; + for (const entry of series) { + sum += entry.value; + if (entry.value < minVal) minVal = entry.value; + if (entry.value > maxVal) maxVal = entry.value; + } return { points: series.length, start: round(first, 2), end: round(last, 2), delta: round(last - first, 2), - mean: round(average(series.map((entry) => entry.value)), 2), - min: round(Math.min(...series.map((entry) => entry.value)), 2), - max: round(Math.max(...series.map((entry) => entry.value)), 2), + mean: round(sum / series.length, 2), + min: round(minVal, 2), + max: round(maxVal, 2), }; } diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts index 5084aa2a5..becab143a 100644 --- a/src/lib/insights/sub-page-metric.ts +++ b/src/lib/insights/sub-page-metric.ts @@ -24,27 +24,45 @@ * enum, the slug array, and the per-slug metric list all derive from * the keys of this record — adding a sub-page is a one-place change. */ +// v1.4.33 F9 — order follows the MeasurementCategory overlay +// (`src/lib/measurements/categories.ts`): +// +// vitals → body → activity → sleep → cardiovascular → mood → events +// +// The wave-A HealthKit entries added in v1.4.32 (HRV, resting HR, SpO2, +// body temperature, active energy) used to stack at the end of the +// strip in insertion order; the regrouping below interleaves them into +// their categorical neighbours so a user reading left-to-right scans +// each metric domain as a block. Heavier regrouping (collapse five +// wave-A pills under a single "Apple Health" pill, fold BMI into +// Gewicht, fold Sauerstoff/Körpertemperatur into a single Vitals pill) +// is deferred to v1.4.34 — that needs a screenshot review per +// .planning/round-v1433-audit-menu.md §7. export const SUB_PAGE_METRIC = { + // ── vitals ── blutdruck: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"], - gewicht: ["WEIGHT"], puls: ["PULSE"], - stimmung: ["MOOD"], - // medication adherence is event-driven; no measurement series. - medikamente: [], + sauerstoff: ["OXYGEN_SATURATION"], + koerpertemperatur: ["BODY_TEMPERATURE"], + // ── body composition ── + gewicht: ["WEIGHT"], // BMI is derived from WEIGHT + profile height (no separate series). bmi: ["WEIGHT"], - schlaf: ["SLEEP_DURATION"], + // ── activity ── + "aktive-energie": ["ACTIVE_ENERGY_BURNED"], // v1.4.32 — workouts surface; the page reads `Workout` rows directly // through `useWorkouts()` rather than the `summaries` map, so the // metric list stays empty. workouts: [], - // v1.4.32 — five new HealthKit wave-A sub-pages. Each one reads its - // own MeasurementType series via the standard analytics path. - hrv: ["HEART_RATE_VARIABILITY"], + // ── sleep ── + schlaf: ["SLEEP_DURATION"], + // ── cardiovascular ── ruhepuls: ["RESTING_HEART_RATE"], - sauerstoff: ["OXYGEN_SATURATION"], - koerpertemperatur: ["BODY_TEMPERATURE"], - "aktive-energie": ["ACTIVE_ENERGY_BURNED"], + hrv: ["HEART_RATE_VARIABILITY"], + // ── mood ── + stimmung: ["MOOD"], + // ── events (medication adherence is event-driven; no measurement series) ── + medikamente: [], } as const satisfies Record; export type SubPageSlug = keyof typeof SUB_PAGE_METRIC; diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index b8e1be4ec..4f0a72adf 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -52,11 +52,6 @@ function round(value: number, digits = 1): number { return Math.round(value * factor) / factor; } -function average(values: number[]): number { - if (values.length === 0) return 0; - return values.reduce((sum, value) => sum + value, 0) / values.length; -} - function normalizeSummaryText(value: string): string { return stripChartTokens(value).replace(/\s+/g, " ").trim(); } @@ -69,14 +64,29 @@ function summarizeSeries(series: Array<{ value: number }>) { if (series.length === 0) return null; const first = series[0].value; const last = series[series.length - 1].value; + // v1.4.33 — fold sum/min/max into a single walk. The previous + // `Math.min(...series.map(...))` / `Math.max(...series.map(...))` + // spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics + // path; see `.planning/round-v1433-analytics-500-report.md` §"Carry- + // over". These helpers are fed bounded windows today so the crash + // never reached them, but the spread allocates a transient args array + // on every call — the fold is both stack-safe and cheaper. + let sum = 0; + let minVal = series[0].value; + let maxVal = series[0].value; + for (const entry of series) { + sum += entry.value; + if (entry.value < minVal) minVal = entry.value; + if (entry.value > maxVal) maxVal = entry.value; + } return { points: series.length, start: round(first, 2), end: round(last, 2), delta: round(last - first, 2), - mean: round(average(series.map((entry) => entry.value)), 2), - min: round(Math.min(...series.map((entry) => entry.value)), 2), - max: round(Math.max(...series.map((entry) => entry.value)), 2), + mean: round(sum / series.length, 2), + min: round(minVal, 2), + max: round(maxVal, 2), }; } diff --git a/src/lib/queries/use-analytics-query.ts b/src/lib/queries/use-analytics-query.ts new file mode 100644 index 000000000..a7397e4c5 --- /dev/null +++ b/src/lib/queries/use-analytics-query.ts @@ -0,0 +1,133 @@ +"use client"; + +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; + +import { useAuth } from "@/hooks/use-auth"; +import { queryKeys } from "@/lib/query-keys"; +import type { DataSummary } from "@/lib/analytics/trends"; +import type { SubPageAnalyticsData } from "@/types/analytics"; + +/** + * v1.4.33 IW2 — single TanStack-Query wrapper for `/api/analytics`. + * + * Pre-fix, seven mount sites each declared `useQuery({ queryKey: + * ["analytics"], … })` with inconsistent `staleTime` / `enabled` / + * `refetchOnMount` options. TanStack treats the first-mount wins → the + * earliest consumer's options govern the shared cache cell, but every + * route-change re-mount still spins up a fresh subscription tree and + * any consumer without an `enabled: isAuthenticated` short-circuit + * re-issues the network on a back-forward swing. The audit + * (`.planning/round-v1433-audit-perf.md` §1) ranks this as the top-1 + * ROI win. + * + * The shared hook: + * - centralises the queryKey via `queryKeys.analytics(slice)`, + * - sets `staleTime: 60_000` so a route swing within a minute is a + * free cache hit, + * - sets `refetchOnMount: false` + `refetchOnWindowFocus: false` so + * route transitions never trigger a refetch storm, + * - defaults to `enabled: isAuthenticated` so the unauthenticated + * surfaces (registration, sign-in) don't spin up a 401-bound + * request, + * - lets the caller opt into the slim `?slice=summaries` server + * branch shipped in IW1 / C1 when the consumer only needs the + * per-type DataSummary headlines (sub-pages, gating helpers, + * onboarding checklist). + * + * The dashboard's mother query keeps the default slice because it also + * reads `bpInTargetPct*` and `glucoseByContext` — those slots stay + * stubbed on the slim path per the IW1 follow-up. + */ + +/** + * Strict structural subset shared between the slim slice and the thick + * default payload. Consumers that only read `summaries` should type + * the hook return as `AnalyticsSlicePayload` (or import + * `SubPageAnalyticsData` directly — same shape, kept aliased for + * call-site clarity). + */ +export type AnalyticsSlicePayload = SubPageAnalyticsData; + +/** + * Discriminator for the server-side slice. `undefined` (the default) + * routes onto the thick payload; `"summaries"` routes onto IW1's + * `?slice=summaries` branch. + */ +export type AnalyticsSlice = "summaries" | undefined; + +/** + * The unwrapped `data` field returned by the API. Typed as a wide + * shape so the dashboard's `bpInTargetPct*` / `glucoseByContext` + * consumers can narrow without a runtime cast. The slim slice fills + * the optional thick fields with `null` / empty so call-site reads + * remain undefined-safe. + */ +export interface AnalyticsRawPayload { + summaries: Record; + // Thick-only fields — present on the default slice, stubbed on the + // slim branch. Optional so the type covers both shapes. + bpInTargetPct?: number | null; + bpInTargetPct7d?: number | null; + bpInTargetPct30d?: number | null; + bpInTargetPctAllTime?: number | null; + bpInTargetPctPriorMonth?: number | null; + bpInTargetPctPriorYear?: number | null; + glucoseByContext?: Record; + correlations?: unknown; + healthScore?: unknown; + sleepStages?: unknown; + // Keep the contract open — additive server fields don't need a + // type bump every release. + [key: string]: unknown; +} + +export interface UseAnalyticsQueryOptions { + /** + * `"summaries"` — hit IW1's slim slice (2 SQL passes, no + * correlations / health-score / bp-in-target / sleep-stages). + * Omit (or pass `undefined`) for the thick default payload. + */ + slice?: AnalyticsSlice; + /** + * Optional override for the auth gate. Defaults to + * `isAuthenticated`. The Insights mother page can pass extra + * disabled-flag composition without losing the auth gate. + */ + enabled?: boolean; +} + +async function fetchAnalytics( + slice: AnalyticsSlice, +): Promise { + const url = + slice === "summaries" ? "/api/analytics?slice=summaries" : "/api/analytics"; + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Failed to load analytics (${res.status})`); + } + const json = (await res.json()) as { data: AnalyticsRawPayload }; + return json.data; +} + +/** + * Shared hook. Returns the raw `data` payload typed as + * `AnalyticsRawPayload`. Consumers can narrow at the call site (e.g. + * `const summaries = query.data?.summaries;`). + */ +export function useAnalyticsQuery( + options: UseAnalyticsQueryOptions = {}, +): UseQueryResult { + const { isAuthenticated } = useAuth(); + const slice = options.slice; + const enabled = + options.enabled !== undefined ? options.enabled : isAuthenticated; + + return useQuery({ + queryKey: queryKeys.analytics(slice), + queryFn: () => fetchAnalytics(slice), + enabled, + staleTime: 60_000, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); +} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index fa5838635..3d016bb68 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -16,7 +16,17 @@ export const queryKeys = { measurements: () => ["measurements"] as const, moodEntries: () => ["mood-entries"] as const, - analytics: () => ["analytics"] as const, + /** + * v1.4.33 IW2 — the analytics queryKey now optionally carries a + * `slice` discriminator so the dashboard tile-strip can subscribe to + * the slim `?slice=summaries` server slice (IW1 / C1) without + * colliding with the thick-payload consumers on the Insights tree. + * Calling `queryKeys.analytics()` without a slice keeps the legacy + * shape `["analytics"]` so mutation invalidations and the bulk-key + * lists below stay byte-identical. + */ + analytics: (slice?: "summaries") => + (slice ? (["analytics", slice] as const) : (["analytics"] as const)), moodAnalytics: () => ["mood-analytics"] as const, insightsRoot: () => ["insights"] as const, diff --git a/src/lib/request-cache.ts b/src/lib/request-cache.ts new file mode 100644 index 000000000..afb4ca83c --- /dev/null +++ b/src/lib/request-cache.ts @@ -0,0 +1,73 @@ +/** + * v1.4.33 — per-request memoisation carrier. + * + * Multiple gated routes share the same in-process request lifecycle + * (api-handler wraps every handler in `eventStorage.run(builder, …)`), + * yet the gate helpers and snapshot builders re-read the same row on + * every call. The Coach drawer opening fires five fetches in parallel, + * each of which lands a `requireAssistantSurface("coach")` call — + * five separate `SELECT` against `AppSettings.singleton` for the same + * value within the same wall-clock millisecond. + * + * This helper attaches a request-scoped `Map>` + * to the active `WideEventBuilder` from `@/lib/logging/context`. Same + * key inside one request = one compute; different requests get + * independent caches; outside any request (e.g. tests with no event + * context) the factory runs every time so behaviour is unchanged. + * + * The cache is keyed by string. Callers compose composite keys (e.g. + * `"assistant-flags"` or `"coach-snapshot:${userId}:${windowKey}"`). + * The cache is intentionally weak: it lives on the event builder + * itself via a `WeakMap`, so it goes away as soon as the request + * finishes and the builder is collected. + */ +import { getEvent } from "@/lib/logging/context"; +import type { WideEventBuilder } from "@/lib/logging/event-builder"; + +const REQUEST_CACHES = new WeakMap>(); + +function getCache(builder: WideEventBuilder): Map { + let cache = REQUEST_CACHES.get(builder); + if (!cache) { + cache = new Map(); + REQUEST_CACHES.set(builder, cache); + } + return cache; +} + +/** + * Memoise `factory()` for the lifetime of the current request. Returns + * the factory's result directly when no event context is active so + * unit tests and background jobs that don't enter `eventStorage.run()` + * keep their existing semantics. + * + * The cached value is the Promise itself, so concurrent callers within + * the same request await one outstanding compute instead of starting + * their own — important for the gate-cascade pattern where five + * mounts fire `requireAssistantSurface()` in parallel. + */ +export function memoizePerRequest( + key: string, + factory: () => Promise, +): Promise { + const event = getEvent(); + if (!event) { + return factory(); + } + const cache = getCache(event); + const existing = cache.get(key); + if (existing !== undefined) { + return existing as Promise; + } + const promise = factory(); + cache.set(key, promise); + // If the factory rejects, evict so a retry on the same key inside + // this request gets a fresh attempt. Cached failures inside a single + // request would mask real recovery (e.g. transient DB hiccup). + promise.catch(() => { + if (cache.get(key) === promise) { + cache.delete(key); + } + }); + return promise; +} diff --git a/src/proxy.ts b/src/proxy.ts index b7dd322f4..c1bdd719a 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -33,6 +33,14 @@ const PUBLIC_PATHS = [ // alongside the project credits. The CC licence requires the // attribution to be reachable without a sign-in. "/about", + // v1.4.33 — `/.well-known/*` covers IETF-registered discovery + // endpoints (RFC 8615). Apple reads + // `/.well-known/apple-app-site-association` without credentials to + // wire Web Credentials (passkey sharing) and Universal Links to the + // iOS bundle, so the path must answer 200 with the bare JSON body + // before any auth gate runs. The trailing slash future-proofs the + // namespace for `/security.txt`, `/openid-configuration`, etc. + "/.well-known/", // `/onboarding` itself + its subroutes are matched exactly via // `isPublicPath()` so we don't admit `/onboarding-export` etc. "/robots.txt", diff --git a/tests/integration/analytics-summaries-slice.test.ts b/tests/integration/analytics-summaries-slice.test.ts new file mode 100644 index 000000000..ab5fe1b8d --- /dev/null +++ b/tests/integration/analytics-summaries-slice.test.ts @@ -0,0 +1,220 @@ +/** + * v1.4.33 C1 — `?slice=summaries` integration coverage. + * + * Pins the slim summaries slice against the real Postgres + the real + * `regr_slope` / `regr_r2` functions, not a unit-level `$queryRaw` + * mock. The slice replaces the route's 30-type × Promise.all chunked + * findMany walk with 2 SQL passes; the integration test runs against + * the test container so any drift in the SQL (alias names, casts, + * `FILTER` semantics) surfaces here instead of in production. + * + * Three contracts pinned: + * + * 1. **Empty user.** A brand-new account with zero rows returns the + * empty `DataSummary` skeleton for every `MeasurementType` and + * `bmi: null`. No JS-side spread anywhere along the chain — the + * v1.4.33 P0 RangeError won't reach this path. + * + * 2. **Populated WEIGHT series.** A 14-day descending-weight series + * lands `count`, `latest`, `avg7`, `avg30`, the negative-slope + * `direction: "down"` tuple from `regr_slope`, plus min/max/mean + * with the same 2-decimal rounding the `summarize()` helper uses. + * + * 3. **Default slice unchanged.** Hitting the route WITHOUT + * `?slice=summaries` still returns the thick `healthScore` / + * `correlations` / `bpInTargetPct` / `sleepStages` envelope. C1 + * is additive — no behaviour change for the existing consumers. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +interface DataSummaryShape { + count: number; + latest: number | null; + min: number | null; + max: number | null; + mean: number | null; + avg7: number | null; + avg30: number | null; + slope7: { slope: number; direction: string; confidence: number } | null; + slope30: { slope: number; direction: string; confidence: number } | null; + slope90: { slope: number; direction: string; confidence: number } | null; + anomalyCount: number; + avg30LastMonth: number | null; + avg30LastYear: number | null; +} + +interface SlimEnvelope { + data: { + summaries: Record; + bmi: number | null; + }; + error: null; +} + +async function seedSession(username: string) { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username, + email: `${username}@example.test`, + role: "USER", + }, + }); + const session = await prisma.session.create({ + data: { + userId: user.id, + expiresAt: new Date(Date.now() + 60_000), + }, + }); + cookieJar.set("healthlog_session", session.id); + return user; +} + +describe("GET /api/analytics?slice=summaries (C1)", () => { + it("returns the empty-summary skeleton for a brand-new user", async () => { + await seedSession("slim-empty-user"); + + const { GET } = await import("@/app/api/analytics/route"); + const response = await ( + GET as unknown as (req: Request) => Promise + )(new Request("http://localhost/api/analytics?slice=summaries")); + + expect(response.status).toBe(200); + const envelope = (await response.json()) as SlimEnvelope; + // Every enum option is present with the deterministic empty + // shape so consumers can read `summaries.WEIGHT.count > 0` + // without an undefined guard. + expect(envelope.data.summaries.WEIGHT.count).toBe(0); + expect(envelope.data.summaries.WEIGHT.latest).toBeNull(); + expect(envelope.data.summaries.WEIGHT.slope30).toBeNull(); + expect(envelope.data.summaries.BLOOD_PRESSURE_SYS.count).toBe(0); + expect(envelope.data.summaries.PULSE.count).toBe(0); + // The slim slice never carries BMI — consumers re-derive from + // `summaries.WEIGHT.latest` + `user.heightCm` client-side or + // fetch the default slice. + expect(envelope.data.bmi).toBeNull(); + }); + + it("reports count + latest + slope + windowed averages from real SQL", async () => { + const prisma = getPrismaClient(); + const user = await seedSession("slim-populated-user"); + + // 14 descending-WEIGHT entries spaced one day apart. Slope per + // day = (84.0 - 80.0) / 13 ≈ -0.308 — solidly inside the "down" + // band (|slope| >= 0.01 units/day threshold). + const now = Date.now(); + const DAY_MS = 24 * 60 * 60 * 1000; + const N = 14; + const rows = Array.from({ length: N }, (_, i) => ({ + userId: user.id, + type: "WEIGHT" as const, + // Most-recent reading is the smallest — slope must come out + // negative when regressing value against time. + value: 80 + i * (4 / (N - 1)), + unit: "kg", + source: "MANUAL" as const, + measuredAt: new Date(now - i * DAY_MS), + })); + await prisma.measurement.createMany({ data: rows }); + + const { GET } = await import("@/app/api/analytics/route"); + const response = await ( + GET as unknown as (req: Request) => Promise + )(new Request("http://localhost/api/analytics?slice=summaries")); + + expect(response.status).toBe(200); + const envelope = (await response.json()) as SlimEnvelope; + const weight = envelope.data.summaries.WEIGHT; + + expect(weight.count).toBe(N); + // Most-recent row in DESCENDING construction = index 0 = value 80. + expect(weight.latest).toBeCloseTo(80, 1); + // Min / max across the 14 rows. + expect(weight.min).toBeCloseTo(80, 1); + expect(weight.max).toBeCloseTo(84, 1); + // 7-day window covers the first 7 entries (descending dates). + expect(weight.avg7).not.toBeNull(); + expect(weight.avg30).not.toBeNull(); + // Slope direction must be "down" — value decreases with time. + expect(weight.slope30).not.toBeNull(); + expect(weight.slope30!.direction).toBe("down"); + expect(weight.slope30!.slope).toBeLessThan(0); + // R² confidence is in [0, 1]. + expect(weight.slope30!.confidence).toBeGreaterThanOrEqual(0); + expect(weight.slope30!.confidence).toBeLessThanOrEqual(1); + + // Sanity-check: PULSE is empty on this fixture. + expect(envelope.data.summaries.PULSE.count).toBe(0); + }); + + it("default slice still carries the thick envelope (additive contract)", async () => { + await seedSession("slim-default-still-thick"); + + const { GET } = await import("@/app/api/analytics/route"); + const response = await ( + GET as unknown as (req: Request) => Promise + )(new Request("http://localhost/api/analytics")); + + expect(response.status).toBe(200); + // Default slice still produces every block the route v1.4.32 + // shipped — proving C1 added a branch instead of mutating the + // existing path. + const body = (await response.json()) as { + data: { + summaries: unknown; + bmi: unknown; + bpInTargetPct: unknown; + correlations: unknown; + healthScore: unknown; + sleepStages: unknown; + glucoseByContext: unknown; + }; + }; + expect(body.data.summaries).toBeDefined(); + // Every thick-slice key must still be on the envelope (value can + // be null for an empty user — what we care about is presence). + expect("bpInTargetPct" in body.data).toBe(true); + expect("correlations" in body.data).toBe(true); + expect("healthScore" in body.data).toBe(true); + expect("sleepStages" in body.data).toBe(true); + expect("glucoseByContext" in body.data).toBe(true); + }); +});