Commit Graph
1385 Commits
Author SHA1 Message Date
Marc-André Bombeck 432ebccdae fix(i18n): drop the "KI"/"AI" prefix from user-facing copy
Marc-Voice rule — the product never names itself "KI" or "AI" in
copy. Settings -> "KI-Auswertungen" becomes "Auswertungen" / "Insights",
the inner "KI-Insights" card heading collapses into the parent section
heading, "Aktiver KI-Provider" becomes "Aktiver Provider", and the
admin "AI Quality" view becomes "Insights Quality".

Sweep across de / en / es / fr / it / pl. Provider product names
(Anthropic, Claude, ChatGPT, OpenAI) are unchanged — those are
proper nouns, not the "AI" label.
2026-05-16 17:56:34 +02:00
Marc-André Bombeck 0cac3d3545 fix(a11y): give icon-only buttons accessible names on the medications page
Lighthouse v1.4.33 a11y run flagged "links without discernible name"
(score 91/94). The medications page renders five icon-only buttons
(reset-form dropdown, three copy actions, status dropdown) without
an aria-label, so the SR announcement collapses to the role name.

This patch reuses existing common.* translation keys (no new strings
added; messages/*.json off-limits for IW9):

- the reset / status DropdownMenu triggers get `common.moreOptions`,
- the three copy icon-buttons get `common.copy`.

Icon-only buttons in `src/components/settings/**` (IW4),
`src/components/insights/coach-panel/**` (IW5), and the bottom-nav
already carry aria-labels per earlier polish rounds.
2026-05-16 17:53:29 +02:00
Marc-André Bombeck 2c0d0d2969 docs(planning): IW5 implementation report for v1.4.33
Document the coach-rail heading promotion, mobile-FAB auto-hide on
chart-tooltip activity, and F20 scroll-hang verification.
2026-05-16 17:51:51 +02:00
Marc-André Bombeck b763424b1f fix(a11y): give every Progress bar an accessible name
Lighthouse v1.4.33 a11y run flagged "ARIA progressbar elements missing
accessible names" (score 91/94). The Radix Progress primitive renders
a `role="progressbar"` host element but doesn't synthesise an
`aria-label` from context -- the consumer must supply one. Reusing
the on-screen label keys keeps the SR announcement matched to the
visible caption.

- src/app/insights/medikamente/page.tsx: compliance7/compliance30
- src/app/achievements/page.tsx: criterion meter + summary
  earned-points + next-goal progress
- src/components/medications/medication-card.tsx: compliance7/30
- src/components/medications/glp1-medication-card.tsx: same

`role="progressbar"` divs in `health-score-card.tsx`,
`getting-started-checklist.tsx`, and `OnboardingShell.tsx` already
carry aria-labels -- verified, no change needed.

Refs Lighthouse-derived punch list (v1.4.33 perf audit).
2026-05-16 17:51:24 +02:00
Marc-André Bombeck a5a91e40bc fix(insights): auto-hide mobile Coach FAB while a chart tooltip is open
The Coach launch FAB sits fixed at bottom-right on mobile viewports
and used to overlay any Recharts tooltip painted near the chart's
lower-right corner. Tapping a data point on /insights/blutdruck
opened the tooltip but the FAB covered the value readout, so the
user couldn't read it.

Add a singleton MutationObserver that watches every
.recharts-tooltip-wrapper in the DOM for inline visibility flips
(Recharts toggles `style.visibility: visible` when the tooltip
should paint, and clears it otherwise). A useSyncExternalStore-
backed hook surfaces the live boolean to the FAB, which fades to
opacity-0 + pointer-events-none + aria-hidden while a tooltip is
active. When the user releases / scrolls away and Recharts clears
the wrapper the FAB fades back in.

The observer is module-scoped: it attaches on the first subscriber
and disconnects on the last unmount, so it costs nothing on routes
that don't mount the FAB. SSR snapshot is always false so the
initial server-rendered FAB stays fully interactive before
hydration.

End-to-end auto-hide behaviour belongs to the Playwright mobile
suite (chart hover/long-press → FAB fades); the unit tests pin the
SSR-safe contract and the FAB markup so the regression cannot
silently revert.
2026-05-16 17:50:08 +02:00
Marc-André Bombeck e65cdc97b2 fix(ui): reserve Button loader space to eliminate CLS during in-flight requests
Lighthouse v1.4.33 run flagged a 0.186 CLS attached to a
`div.space-y-2 > Button > Loader-Circle` element. Root cause: the
two heavy submit buttons in `/bugreport` and the medications import
dialog drew `{loading && <Loader2/>}<Icon/>` -- rendering BOTH icons
during the in-flight request and growing the button by ~24 px. The
fix is to swap (not append) the leading icon, so Loader2 occupies
the same 16x16 slot the static icon would.

- src/app/bugreport/page.tsx: ternary swap on `loading`, pass
  `aria-busy` for screen-reader narration.
- src/app/medications/page.tsx (Import dialog): same shape.
- src/components/ui/button.tsx: document the loading-state contract
  on the primitive so future contributors apply the swap convention
  by default. (Callsites inside `src/components/settings/**` are
  owned by IW4 -- left untouched, the doc-comment makes the convention
  visible from the primitive.)
2026-05-16 17:49:46 +02:00
Marc-André Bombeck 523ee0c7d1 fix(notifications): disambiguate inbox vs channel-config naming
Both the `/notifications` inbox and the Settings -> Notifications
section used to read as plain "Notifications" in every nav surface, so
the user-card dropdown and the settings sidebar looked like two pointers
at the same destination. Rename the inbox to "Notification Center" and
the settings section to "Notification channels"; add a small breadcrumb
to either surface so the user always knows which page they landed on
and can jump to the sibling in one tap.

Locale parity across de / en / es / fr / it / pl.
2026-05-16 17:49:31 +02:00
Marc-André Bombeck 3a5f7debdb fix(ui): normalise Card defaults to p-4 md:p-6
Pre-v1.4.33 the shadcn Card defaults shipped `py-6` + `px-6` (uniform
24 px 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.

This patch lifts the Card primitive (and its CardHeader / CardContent
/ CardFooter siblings) onto the same `p-4 md:p-6` token. Existing
consumers that paint their own explicit padding continue to win
through the `cn(...)` merge. Tests across `src/components/ui` /
`src/components/insights/__tests__` / `src/components/charts/__tests__`
(400 tests) still green.

Refs .planning/round-v1433-audit-polish.md §4.1 + Win 4.
2026-05-16 17:48:24 +02:00
Marc-André Bombeck fe94299122 fix(insights): consolidate route scroll-reset into a single hook
Pre-v1.4.33 the insights mother page AND `<SubPageShell>` both fired
their own `requestAnimationFrame -> window.scrollTo({top:0})` on mount.
On slow hydrates the two callbacks landed ~16 ms apart and the second
one re-snapped the viewport once the chart skeleton inflated -- a
visible double-snap the maintainer flagged as "viewport jumps on click".

- src/hooks/use-scroll-reset-on-route.ts: new hook owns the RAF +
  scrollTo dance, behaviour:"auto" so `prefers-reduced-motion` users
  get the same instant landing everyone else does.
- src/components/insights/sub-page-shell.tsx: drop the inline effect,
  call the hook. The opt-in `focusOnMount` ref-focus stays as its own
  separate effect (different opt-in surface).
- src/app/insights/page.tsx: drop the duplicate effect, call the hook.

Refs .planning/round-v1433-audit-polish.md §2.1 + Win 3.
2026-05-16 17:47:27 +02:00
Marc-André Bombeck bfb1b43af2 fix(insights): promote coach-rail labels to semantic h3 headings
The history and sources rails mount inline next to the message thread
on lg+ / xl+ viewports, where no SheetTitle wrapper covers them.
Both rails previously rendered their section labels as <span> with
uppercase styling, so the drawer's semantic outline disappeared above
the message thread on desktop. Screen-reader users navigating by
heading skipped straight from the drawer's <h2> title to the
message-thread region with no intermediate landmark.

Promote both labels to <h3> while keeping the existing visual
treatment intact. The mobile rail-tray (Sheet-wrapped) still surfaces
its SheetTitle <h2>, so the rail's own <h3> simply nests below it on
small screens. The desktop layout now carries a coherent h2 (drawer)
> h3 (rail) hierarchy.

Regression tests assert that each rail renders its label inside a
real <h3> element so the semantic outline cannot silently drop again.
2026-05-16 17:46:21 +02:00
Marc-André Bombeck 87e4cdcb1b fix(layout): normalise auth-shell container width on max-w-screen-xl
Pre-v1.4.33 the dashboard shell capped at max-w-[76.8rem] (1228 px)
while settings/admin shells used max-w-screen-xl (1280 px) and the
bugreport route capped at max-w-6xl (1152 px). Route switches between
those surfaces produced a 52-128 px lateral shift on a 1440 px laptop
which the user perceives as the page "wobbling".

- src/components/layout/auth-shell.tsx: max-w-[76.8rem] -> max-w-screen-xl
- src/app/bugreport/page.tsx: drop the inner max-w-6xl cap so the shell
  alone owns the content frame.

Settings/admin shells already use max-w-screen-xl; no change needed.

Refs .planning/round-v1433-audit-polish.md §4.2 + Win 1.
2026-05-16 17:45:20 +02:00
Marc-André Bombeck 6135c32507 docs(planning): v1.4.33 quality-and-reliability audit pass
Five-agent audit pass plus the Playwright runtime sweep against v1.4.32.
The five audit documents land alongside the 58 runtime screenshots and
the runtime-results.json that drove the seven highest-severity
implementation rounds (IW1, IW3, IW4, IW6, IW8) on this branch. Future
contributors can trace each implementation commit back to a finding
without re-running the audits.
2026-05-16 17:40:18 +02:00
Marc-André BombeckandClaude Opus 4.7 f389408169 docs(planning): IW4 implementation report for v1.4.33
Polish + reliability slice of the v1.4.33 audit cycle: DOB date-input
height, timezone aria/label split, F13 username readability, F14
mobile bottom-nav padding, Mood Log overflow + save-button
right-align, Notifications redundancy collapse with Badge-pair
deletion, copy compression on telegram + moodLog + notificationStatus
descriptions, Export card padding parity, passkey table breakpoint
flip from md to lg, settings shell chip-strip scroll-into-view on
route change, F17 threshold-toggle relabel, `<SettingsCardHeader>`
primitive extracted with three call sites migrated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:38:35 +02:00
Marc-André Bombeck b543988c3a docs(planning): IW3 implementation report for v1.4.33 2026-05-16 17:38:08 +02:00
Marc-André BombeckandClaude Opus 4.7 981a3d55e1 feat(settings): extract <SettingsCardHeader> primitive
Every Settings card paints the same shape — icon + title + optional
status surface + optional muted description — but the codebase
carried five different `flex-*` permutations across
`account-section.tsx`, `integrations-section.tsx`,
`notification-status-card.tsx`, the three notification channel
cards, `api-section.tsx`, and `advanced-section.tsx`. The mechanical
drift table in `.planning/round-v1433-audit-settings.md` §2.5
itemised the spread.

Consolidate the contract into `_card-header.tsx`: icon left, title
right of it, status slot lives top-right and falls below the title
on `<sm`, description sits muted below the title row. Call sites
keep ownership of their body forms and action footers.

First three call sites switched: `telegram-card.tsx`,
`ntfy-card.tsx`, `web-push-card.tsx`. The remaining cards
(`account-section.tsx`, `integrations-section.tsx`, `api-section.tsx`,
`advanced-section.tsx`, `notification-status-card.tsx`) are wired
in a follow-up — the primitive is now available without forcing
every consumer to migrate in one commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:37:16 +02:00
Marc-André Bombeck 8131fcb369 fix(dashboard): tile sentiment, mobile grid, BD-Ziel gate, summary metadata
- Centralise the trend-arrow + 7-day-delta + comparison-overlay colour
  through a single `getTrendSentiment(change, sentiment)` helper so the
  three elements never paint disagreeing signals on the same tile
  (F5 audit fix). Arrow direction now follows the same signal that
  drives the 7-day delta value.
- Collapse the dashboard tile strip from `flex overflow-x-auto` at <sm
  + grid above into a single responsive `repeat(auto-fit,
  minmax(min(100%, 11rem), 1fr))` track. Phones wrap to two rows
  instead of side-scrolling; 1440×900 picks 6 columns so the headline
  digits stop truncating to `8…` / `1.`.
- Hold the tile callout slot at `min-h-[18px]` on every breakpoint so
  a comparison overlay on one tile no longer pushes its callout-less
  neighbours 18 px taller.
- Gate the BD-Zielbereich tile so a literal "0,0 %" placeholder no
  longer renders when every window (7d / 30d / all-time) reports
  zero. The tile shows once any window crosses zero; otherwise the
  `/targets` Blutdruck card carries the deeper analysis.
- Restore proportional sizing on the dashboard Hinzufügen button:
  `size="default"` with `min-h-11 sm:min-h-9` keeps WCAG 2.5.5 on
  mobile (44 px) and tightens to a desktop-friendly 36 px above sm.
- Surface `allTimeCount` + `lastSeenAt` per metric on
  `/api/dashboard/summary` so the iOS client can keep a tile visible
  during a logging gap and render a `Letzter Wert vor Xd` caption.
  Optional cards (glucose / sleep / steps / body fat) widen the emit
  gate from "7-day window has data" to "ever logged". Added the
  matching `staleDays` prop to `<TrendCard>` and `dashboard.staleHint`
  i18n keys across all six locales.
2026-05-16 17:36:31 +02:00
Marc-André Bombeck 86aa27e68e docs(planning): IW1 implementation report for v1.4.33
Records the four commits landed on develop (6-helper fold, per-
request memo on `getAssistantFlags`, 60 s LRU on
`buildCoachSnapshot`, slim `?slice=summaries` slice + integration
test) plus the test delta (+114 unit, +3 integration) and the
deferred follow-ups (mislabelled `2d630994` commit, dashboard
wiring, slim slice's anomaly/prior-window stubs).
2026-05-16 17:35:26 +02:00
Marc-André BombeckandClaude Opus 4.7 73043d42d1 fix(settings): tile-padding parity, passkey breakpoint, scroll-into-view, F17 threshold toggle
Five small polish items from the v1.4.33 audit, plus the
maintainer's scroll-into-view item:

* Export-section card padding promoted from `p-5` to `p-6` so the
  Export grid rhythm matches every other Settings card.
* Passkey list flips from a desktop table to a mobile card list at
  `lg` instead of `md`. iPad portrait (768 px) used to sit on the
  `md` inflection and flip-flop between the two layouts on
  rotation; the card layout reads fine on iPad portrait, the table
  needs ~620 px of column width to show the destructive delete
  action without horizontal scroll.
* `<SettingsShell>` mobile chip strip now scrolls its active chip
  into view on route change (`block: "nearest", inline: "center",
  behavior: "smooth"`). On a 393 CSS px viewport the 11-chip strip
  pushes the rightmost four chips below the fold; without the
  scroll-into-view, tapping one of those chips left the strip
  pinned to the leftmost chip on the new page, which read as a
  broken nav. Smooth scroll honours the user's
  `prefers-reduced-motion` via the browser's own auto-detection.
* F17 — `thresholds-editor-section` toggle label anchors on the
  *action* it performs ("Eigene Werte" / "Custom range") instead
  of mirroring the current state ("Auto" / "Überschrieben"). The
  old label read as "Auto is off, why are there no inputs?" when
  the toggle was off and inputs were hidden; the new label
  unambiguously announces "flip me to enter a custom range". The
  `thresholds.sourceOverride` badge still surfaces when the
  override is the active source. Adds
  `thresholds.overrideToggleLabel`.

Locale parity across de, en, es, fr, it, pl.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:35:07 +02:00
Marc-André Bombeck a8a5887768 docs(planning): IW8 implementation report for v1.4.33
Records the four landed fixes: F2 (onboarding tour click pass-through),
F14 (bottom-nav defence-in-depth), F19 (web-vitals sampling at 10%),
and the A4 dead-code sweep (AssistantDisabledNotice + settings
placeholder copy). Notes the coordination boundary with IW1, IW3,
IW4, and IW6 plus the regression guard at
e2e/onboarding-tour-passthrough.spec.ts.
2026-05-16 17:34:41 +02:00
Marc-André Bombeck 67d05c0de9 perf(analytics): slim /api/analytics?slice=summaries slice (C1)
The default `/api/analytics` path fans out 30+ `Promise.all`-wrapped
chunked findMany reads to feed `summarize()` for every
MeasurementType. For a multi-year HealthKit user the PULSE branch
alone walks hundreds of thousands of rows before the dashboard tile
strip can paint a single arrow — even though the strip only consumes
`count / latest / avg7 / avg30 / min / max / mean` plus the three
slope tuples per type.

Add `?slice=summaries` that resolves the same `DataSummary` shape
from two SQL passes (no JS-side spread anywhere along the chain):

  1. One `$queryRaw` carrying `GROUP BY type` with `COUNT`/`MIN`/
     `MAX`/`AVG` plus 7d/30d windowed averages via `FILTER` plus
     Postgres `regr_slope` + `regr_r2` at 7/30/90-day windows.

  2. One `DISTINCT ON (type)` `$queryRaw` returning the most-recent
     `(type, value)` pair per type so the slim shape can populate
     `latest` (the value at MAX(measured_at), not MAX(value)).

Slope `direction` keeps the JS helper's 0.01-units-per-day "stable"
threshold; `confidence` is `regr_r2` rounded to 2 d.p. — both match
the existing `trendSlope()` contract so the dashboard's arrow / color
logic does not have to branch on slice. Anomaly count, lastMonth, and
lastYear means stay at `null` / `0` because the dashboard tile strip
does not paint them on first paint; the comparison-baseline widget
already opts into the default slice.

The thick `correlations` / `healthScore` / `bpInTargetPct` /
`glucoseByContext` / `sleepStages` / `bmi` blocks stay on the default
path behind a hover-prefetch / explicit slice param. C1 is purely
additive — the route's default behaviour is byte-identical.

Pinned by a 4-case unit test (mocked `$queryRaw`, slope thresholds,
null-slope branch) and a 3-case real-Postgres integration test
covering the empty user, the populated descending-WEIGHT fixture,
and the default-slice-still-thick contract. 199 unit tests pass
across `src/lib/analytics` + `src/app/api/analytics`; the
integration suite picks up `tests/integration/analytics-summaries-slice.test.ts`
on next `pnpm test:integration` run.
2026-05-16 17:33:58 +02:00
Marc-André Bombeck 1a101505c4 fix(monitoring): defer web-vitals sample draw to useEffect to satisfy react-hooks/purity
The 10% sampling decision in WebVitalsReporter (v1.4.33 F19) was
computed inline in useMemo(), which the react-hooks/purity lint rule
rejects: Math.random() is impure and the render body must be
idempotent. The original useMemo() happened to be stable in practice
(empty dep array) but the rule fires regardless because the
expression inside the memo factory is impure.

Move the draw into useEffect(() => {}, []) and stash the result in a
useRef so every reportWebVitals callback in the same navigation reads
the same decision. SSR seeds the ref to null, so a pre-hydration
beacon (which would have no rate-limit relief value) is dropped; the
first post-hydration callback finds the ref populated and decides
whether to POST. Bundle and runtime cost unchanged — same one draw
per mount, same all-or-none per page-load. ESLint clean.
2026-05-16 17:33:08 +02:00
Marc-André Bombeck 99af530490 chore(cleanup): retire AssistantDisabledNotice + dead settings.placeholder copy
Dead-code sweep flagged by the v1.4.33 menu audit.

* AssistantDisabledNotice (src/components/insights/assistant-disabled-notice.tsx)
  was introduced in v1.4.31 for the operator-disabled assistant surface
  but never wired into a render site. The v1.4.28 retirement of the
  weekly-report + advisor card removed the planned host components.
  Delete the component, its SSR test, and the four locale strings it
  consumed (insights.briefingDisabledByOperator,
  insights.statusDisabledByOperator, insights.correlationsDisabledByOperator,
  insights.coach.disabledByOperator) from all six locale files. The
  AssistantDisabledError thrown by requireAssistantSurface() and the
  assistant.disabled.<surface> errorCode envelope stay — iOS clients
  read the errorCode to render an inline notice.

* settings.sections.placeholder.coming_soon was an "until extraction
  lands" string for <SectionPlaceholder>. Every slug in
  SETTINGS_SECTION_SLUGS has had a matching component since v1.4.22
  so the placeholder body is unreachable through the type system. The
  defensive component itself stays as a guard, but inherits the
  section's own description for the EmptyState body, retiring the
  dedicated locale key from all six locale files.

Locale-integrity contract holds (parity check green across de, en, es,
fr, it, pl). Comments in api-handler.ts and feature-flags/index.ts
that referenced <AssistantDisabledNotice> are updated to describe
the surface generically.

Refs: .planning/round-v1433-audit-menu.md §6
2026-05-16 17:31:47 +02:00
Marc-André BombeckandClaude Opus 4.7 d4a1679ec6 i18n(settings): parity for the trimmed Notifications description
The notifications.description copy compression that landed in
ff06b0ce moved the four non-de/en bundles into drift. Apply the
same one-sentence description ("Live overview of every configured
channel.") across es, fr, it, pl so the parity test stays green
and the four still-translated locales don't keep the old 13-word
sentence as a fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:30:37 +02:00
Marc-André BombeckandClaude Opus 4.7 ff06b0ced2 fix(settings): collapse Notifications redundancy onto one status surface
Telegram, ntfy, and Web Push cards used to paint their own
"Configured · Disabled / Active" badge pair in the top-right
header. The `<NotificationStatusCard>` rendered above the same
cards already shows a live channel pill ("Active · 12 min ago" /
"Auto-disabled — web_push_410_gone"), so the per-card badges were
the third (sometimes fourth) surface for the same state.

Drop the dual-Badge slot from all three cards. The section header
pill is the single source of truth, the per-card surface now
reads as identity + description + form, and the in-app vocabulary
no longer mixes "Konfiguriert", "Aktiv", and "Verbunden" for the
same fact.

Copy pass:
* Settings → Notifications description: "Live-Übersicht aller
  konfigurierten Kanäle." (was a 13-word sentence repeating "Kanal"
  three times).
* `notificationStatus.title` renames to "Zustellstatus" /
  "Delivery status" so the page header and the first card don't
  both anchor on "Kanal".
* `notificationStatus.description` compresses the "Auto-disabled"
  definition.
* Telegram card title drops "-Benachrichtigungen" — the section is
  Notifications, so saying it again is wasted shelf space.
* `telegramDescription` shortens to "Medikamenten-Erinnerungen
  per Bot." / "Medication reminders via bot."
* `moodLogDescription` drops the second "moodLog" mention.

Notification-status SSR test updated to the new copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:29:37 +02:00
Marc-André Bombeck af17db5dd5 perf(coach): 60s LRU on buildCoachSnapshot keyed on (userId, scope)
`buildCoachSnapshot()` reads only persisted data — ~10 measurement
findManys plus the GLP-1 / mood / intake side-fetches — yet a single
chat conversation sends 2-4 turns within a minute and rebuilds the
same snapshot every time. Wrap the existing builder (renamed to
`buildCoachSnapshotImpl`) with an in-memory cache keyed on
`(userId, scope.window, sorted scope.sources)` with a 60 s TTL and a
64-entry LRU cap. Different scopes recompute; the cache evicts on the
next eligible read.

`.planning/round-v1433-audit-perf.md` §3.3 estimates 200-800 ms of
server-side tail saved on the second+ turn — the bulk of the
"blockt komplett" complaint between user-clicks-send and first SSE
`token` frame. The `__resetCoachSnapshotCacheForTests` export gives
the existing two snapshot-test suites a hook so each fixture is read
fresh; both add a `beforeEach` reset alongside the existing
`vi.clearAllMocks()` call. Two new tests pin the cache-hit (one
Prisma findMany call across two `buildCoachSnapshot` calls with the
same scope) and the scope-changed miss (different `window` → two
findManys). Feature-flag tests gain a parallel "per-request
memoisation" describe-block that pins the same behaviour for
`getAssistantFlags()` against a stable mocked event reference.
2026-05-16 17:23:07 +02:00
Marc-André Bombeck 4f63bd5f1e perf(coach): memoise assistant-flags per request + 60s LRU on snapshot builder
Two server-side latency cuts on the Coach drawer open / first turn path.

1. **Per-request memo on `getAssistantFlags()`.** Opening the Coach
   drawer fires five gated fetches in parallel — `/api/insights/chat`,
   the rail list, `/api/insights/comprehensive`, plus the per-metric
   status calls already on the page — and every gated route lands a
   `requireAssistantSurface(surface)` call near the top. Today each
   call issues its own `AppSettings.singleton` `SELECT`. The new
   `src/lib/request-cache.ts` carrier keys a `Map` on the active
   `WideEventBuilder` (already in `AsyncLocalStorage` via the
   api-handler) so the same request reads the row once. Outside an
   event context (unit tests, background jobs) the factory runs
   uncached so behaviour is unchanged.

2. **60-second in-memory LRU on `buildCoachSnapshot()`.** A chat
   conversation sends 2-4 turns within a minute and the snapshot reads
   only persisted data — ~10 measurement reads + GLP-1 + mood + intake
   side-fetches per turn. Cache the result keyed on
   `(userId, scope.window, sorted scope.sources)` with a 60 s TTL and
   a 64-entry LRU cap. Different scopes recompute; the cache evicts
   on the next eligible read. Tests exercise both the hit and the
   scope-changed miss; legacy tests gain an explicit reset between
   describe blocks.

`.planning/round-v1433-audit-perf.md` §3.3 estimates 200-800 ms of
server-side tail saved on the second+ turn and roughly 4×
`AppSettings.singleton` reads avoided on a cold Coach-drawer open.
2026-05-16 17:19:52 +02:00
Marc-André Bombeck adedf05f48 docs(planning): IW6 implementation report for v1.4.33 2026-05-16 17:18:36 +02:00
Marc-André Bombeck 85d74dd913 feat(insights): regroup tab-strip pills by metric category
The wave-A HealthKit pills (HRV, resting HR, SpO2, body temperature,
active energy) added in v1.4.32 stacked at the end of the strip in
insertion order so a left-to-right scan jumped across metric domains
multiple times. Reorder the pills to mirror the
MeasurementCategory overlay at src/lib/measurements/categories.ts:
vitals → body → activity → sleep → cardiovascular → mood → events.

No pill added or removed. The single source of truth is
SUB_PAGE_METRIC in src/lib/insights/sub-page-metric.ts; the strip
iterates SUB_PAGE_SLUGS (derived from the same record) and inherits
the new order automatically. Heavier regrouping (collapse five
wave-A pills under one Apple-Health pill, fold BMI into Gewicht)
is deferred to v1.4.34 — see .planning/round-v1433-audit-menu.md
§7 for the design-review rationale.

Per .planning/round-v1433-audit-runtime.md F9.
2026-05-16 17:16:50 +02:00
Marc-André BombeckandClaude Opus 4.7 c98d07effd fix(settings): F13 username readability, F14 mobile bottom-nav padding, Mood Log overflow
Three closely-related polish fixes from the v1.4.33 audit:

* `/settings/account` username field flips `disabled` to `readOnly`.
  The disabled state inherits `opacity-50` from the `<Input>` primitive,
  which on Marc's mobile pass read identically to placeholder text
  and made the field look empty. `readOnly` keeps the full-contrast
  value rendering while still preventing edits.
* `<SettingsShell>` main column reserves a 96 px bottom gutter on
  `<md` so the last form field on `/settings/account` (Gender) and
  `/settings/ai` (Active provider) is not eaten by the floating
  mobile bottom-nav. Desktop keeps the parent's own padding because
  the bottom-nav doesn't render on `md+`.
* Mood Log webhook-secret row stacks vertically on `<sm` with
  `break-all` on the input value, killing the body-level horizontal
  scroll triggered by the long hex secret on Pixel 5. The
  Sync / Voll-Sync / Trennen triplet picks up the Withings
  `flex-wrap` + `min-w-[10rem]` pattern, and the in-form save row
  right-aligns its primary action so it matches every other
  integration form.
* New locale key `settings.moodLogFullSyncConfirm`
  ("Vollständig synchronisieren" / "Synchronize fully") is now
  wired into the Voll-Sync confirmation dialog so the trigger and
  confirm actions read as distinct copy.

Locale parity across de, en, es, fr, it, pl.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:15:26 +02:00
Marc-André Bombeck 7b78a4bf39 fix(insights): gate /api/insights/generate POST on assistant feature flag
Every visit to /insights or any sub-route used to fire POST
/api/insights/generate from InsightsLayoutShell. The mutation
returned null on 422 (no provider configured) so the UI degraded
gracefully, but the server kept absorbing the call on every cold
mount and the dev-server logs filled with '422 Unprocessable
Entity'. Read the operator's assistant flag matrix from
/api/feature-flags (already cached server-side at 60s, client-side
under the shared ['feature-flags'] query key) and skip the mutation
entirely when 'enabled' or 'briefing' is false. The fail-open
default in useFeatureFlags means a flags-endpoint hiccup still
renders the advisor — the gate only takes effect when the operator
has explicitly turned the surface off.

Also update the per-metric InsightStatusCard's empty-state copy
from the diagnostic 'KI-Provider nicht konfiguriert' / 'AI
provider not configured' to the neutral 'Auswertung nicht
verfügbar' / 'Assessment unavailable'. The diagnostic phrasing
read as a setup failure even though the surface degrades cleanly;
the neutral phrasing matches the rest of the empty-state language.

Per .planning/round-v1433-audit-runtime.md F18.
2026-05-16 17:15:10 +02:00
Marc-André Bombeck b07143b07c fix(charts): defensive fallback when compliance classifier flushes every dose to 'very_late'
The medication heatmap painted every cell orange ('sehr spät') even
for days that recorded 'taken' intakes with no skipped or missed
events. Tracing the upstream API (src/app/api/medications/[id]/
compliance/route.ts → classifyIntakeTiming) shows the classifier
returns 'very_late' for any intake that falls outside the 1h-grace
window in either direction; a stored UTC offset mismatch between
takenAt and the schedule window (e.g. 06:00 UTC intake vs an 08:00
window) therefore pushes every dose to the worst bucket.

Until the classifier is hardened in a follow-up sprint, the heatmap
detects the unambiguous classifier-bug pattern (everything in
veryLate, nothing late/onTime, no missed/skipped) and falls through
to the rate-based palette so a 'fully compliant' day reads green
instead of orange. Legitimately-late days still paint orange because
they carry a non-zero late or missed count.

Per .planning/round-v1433-audit-runtime.md F8.
2026-05-16 17:13:53 +02:00
Marc-André Bombeck b5060f1422 refactor(insights): fold Math.min/max spreads in the six status helpers
`bmi`, `pulse`, `general`, `weight`, `blood-pressure`, and `mood`
status helpers all built their `summarizeSeries` block with
`Math.min(...series.map(...))` and `Math.max(...series.map(...))`.
The same anti-pattern hit `summarize()` in `src/lib/analytics/trends.ts`
on 2026-05-16 and crashed `/api/analytics` with
`RangeError: Maximum call stack size exceeded` once an
Apple-Health-synced PULSE series outgrew V8's ~125 000-arg ceiling;
`61107e0c` folded that one. The six insight helpers are fed bounded
windows today so they never reached the ceiling, but the spread still
allocates a transient args array on every call and the fold is both
stack-safe and cheaper.

Replace each `Math.min(...) / Math.max(...) / average(...)` triplet
with a single walk that maintains running sum / min / max. The
now-unused `average()` helper is dropped from each file. Pinned by
the existing per-helper unit tests (326 passing in
`src/lib/insights` / `src/lib/analytics` / `src/app/api/analytics`).
2026-05-16 17:13:34 +02:00
Marc-André Bombeck 13b2c13caf fix(charts): drop duplicate y-axis ticks on narrow weight domains
When the visible Y-domain spans less than ~6 units the chart's
integer tick formatter rounded two adjacent gridlines to the same
label — '83 kg' appeared twice on /insights/gewicht when the
domain ran from 82.3 to 85.1. Promote the formatter to a one-
decimal projection when the domain is narrow so each tick reads
distinct without inflating the BP / glucose / steps axes where
integers are correct.

Per .planning/round-v1433-audit-runtime.md F10.
2026-05-16 17:13:21 +02:00
Marc-André Bombeck 9c738b601b fix(monitoring): sample web-vitals at 10% so the beacon stops self-throttling
The web-vitals reporter posted every CLS/LCP/FCP/TTFB/INP measurement
on every page-load, six beacons per navigation. In production this
saturated the route-side per-IP rate-limit (60/min) inside seconds —
the runtime audit captured POST /api/internal/web-vitals 429 for
almost every metric after the first navigation, so most samples
never reached the wide-event sink. Telemetry was self-throttling.

Decide once per mount (memoised) whether the current page-load
reports its vitals, sample at 10%, and either send every metric for
that load or send none. Per-load all-or-none keeps each session's
vitals coherent (an LCP from one navigation stays comparable to the
CLS from the same navigation) while cutting traffic by an order of
magnitude. The route-side rate-limit stays in place as a backstop.

Refs: .planning/round-v1433-audit-runtime.md F19
2026-05-16 17:13:12 +02:00
Marc-André Bombeck 743579ca8f fix(insights): /insights/puls subtitle reads 'Puls' not 'Ruhepuls'
The /insights/puls page reads spot-pulse measurements (PULSE) while
/insights/ruhepuls covers the daily resting-heart-rate stream
(RESTING_HEART_RATE). Both routes shared a single copy thread that
labelled /insights/puls as 'Ruhepuls', so the strip pill said one
thing and the description said another. The Karvonen target band
applies to live pulse just as well as to resting HR, so the wording
swap is the only change — no semantics shift.

Per .planning/round-v1433-audit-runtime.md F7.
2026-05-16 17:12:49 +02:00
Marc-André Bombeck 585637d5ab fix(layout): harden bottom-nav so it cannot overlap the last viewport line
The mobile bottom tab-bar bled into the bottom of the visible area on
/settings/account and /settings/ai per the runtime audit. The bar
previously rendered as bg-card/80 (80% opacity, translucent) and
relied on the inner flex row's h-16 alone for its height — without
a min-height floor on the outer nav, a future variant or any flex-
collapse quirk could let the surface shrink under the 64px floor the
auth-shell counts on.

Pin the bar at min-h-16 on the outer nav (defence in depth against
flex collapse) and switch the surface from bg-card/80 to a solid
bg-card. The safe-area inset and fixed bottom-anchor stay as they
were. Page shells that scroll content beneath the bar are still
responsible for reserving pb-16 + safe-area on their main column;
this change makes the bar itself robust so any future page-shell
oversight produces a hard occlusion (visibly broken on review)
rather than a translucent bleed (silently shipped). Bottom-nav SSR
test stays green (4/4) — five primary anchors, More button,
aria-haspopup, 44px tap-target floor.

Refs: .planning/round-v1433-audit-runtime.md F14
2026-05-16 17:12:34 +02:00
Marc-André Bombeck 02410b2394 fix(insights): BP chart Y-axis unit reads 'mmHg' not 'Hg'
The blood-pressure chart cards on /insights/blutdruck and on the
mother-page trends row passed `yAxisUnit="Hg"` to the underlying
HealthChart, which painted gridline labels as `139 Hg`. mmHg is the
canonical clinical unit; "Hg" alone is incomplete (mercury, not
millimeters of mercury) and reads as a typo. Tile header on the same
page already says mmHg, so the axis was the only surface drifting.

Per .planning/round-v1433-audit-runtime.md F6.
2026-05-16 17:12:17 +02:00
Marc-André Bombeck f9b8f3bde2 fix(onboarding): stop spotlight tour from intercepting dashboard clicks
The v1.4.15 spotlight tour rendered the dim backdrop as a single full-
viewport <button> with a clip-path punching a visual hole around the
target. clip-path only changes paint — the button's hit-box stayed
over the entire viewport, so every click on the underlying page
(Hinzufügen dropdown, sidebar links, header avatar) hit the dim layer
and triggered Skip without forwarding the click to the real target.
New users landing on the dashboard with onboarding_tour_completed=false
were effectively locked out of the most common action.

Replace the single backdrop with up to four rectangular dim panels
(top / bottom / left / right strips around the spotlight rect) and
set pointer-events: none on the tour root. The spotlight area is now
genuinely click-through — the spotlighted target receives clicks
normally — and the dim panels keep the "tap backdrop to dismiss"
affordance via their own onClick handlers. The center-placement
fallback (no spotlight target available) retains the legacy full-
cover dim behaviour. Tooltip opts back into click capture with
pointer-events: auto so its buttons stay reachable.

Adds an e2e regression guard
(e2e/onboarding-tour-passthrough.spec.ts) that mounts the tour by
overriding /api/auth/me with onboardingTourCompleted=false, then
asserts the header Hinzufügen dropdown opens on click while the tour
is up. Covers both branches: click on the spotlighted target
forwards to the underlying button, click on a dim strip skips. SSR
tour test stays green (8/8) — the role=dialog, aria-modal,
data-testid hooks, step counter, and i18n keys are unchanged.

Refs: .planning/round-v1433-audit-runtime.md F2
2026-05-16 17:11:45 +02:00
Marc-André Bombeck 2d630994a2 refactor(insights): fold Math.min/max spreads in the six status helpers
`bmi`, `pulse`, `general`, `weight`, `blood-pressure`, and `mood`
status helpers all built their `summarizeSeries` block with
`Math.min(...series.map(...))` and `Math.max(...series.map(...))`.
The same anti-pattern hit `summarize()` in `src/lib/analytics/trends.ts`
on 2026-05-16 and crashed `/api/analytics` with
`RangeError: Maximum call stack size exceeded` once an
Apple-Health-synced PULSE series outgrew V8's ~125 000-arg ceiling;
`61107e0c` folded that one. The six insight helpers are fed bounded
windows today so they never reached the ceiling, but the spread still
allocates a transient args array on every call and the fold is both
stack-safe and cheaper.

Replace each `Math.min(...) / Math.max(...) / average(...)` triplet
with a single walk that maintains running sum / min / max. The
now-unused `average()` helper is dropped from each file. Pinned by
the existing per-helper unit tests (326 passing in
`src/lib/insights` / `src/lib/analytics` / `src/app/api/analytics`).
2026-05-16 17:11:27 +02:00
Marc-André Bombeck 61107e0c68 fix(api): stop /api/analytics 500 from Math.min/max spread overflow
Production GET /api/analytics returned HTTP 500 for users with very long
measurement histories (request-id d86db8e5… 2026-05-16 14:39:51 UTC).
The wide-event log pinned the failure at Promise.all index 3 (PULSE) with
RangeError: Maximum call stack size exceeded — a power user's Apple-Health
heart-rate stream crossed V8's ~125k function-arity ceiling on the
Math.min(...values) / Math.max(...values) spread inside summarize().

Fold sum, min, and max into a single pass over values so the transient
argument array goes away and the working set stays bounded at the array
itself. Mean is now derived from the same fold's sum/length instead of
a second reduce. No public API change; min/max semantics for an empty
series remain null via the early-exit branch.

Adds a 250 000-row regression in trends.test.ts that raises the exact
production stacktrace before the fix and passes after, plus a new
src/app/api/analytics/__tests__/route.test.ts that pins the route-entry
contract (5 000-row PULSE + zero-row brand-new user) so the upcoming
C1 architectural rewrite can't silently reintroduce a spread anywhere
along the call chain. Touched-file lint and pnpm typecheck clean;
broader src/lib + src/app/api/analytics sweep is 2671 / 1 skipped.
2026-05-16 16:51:58 +02:00
Marc-André BombeckandClaude Opus 4.7 f9346e62a2 docs(planning): v1.4.32 closure
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:17:29 +02:00
Marc-André Bombeck ebe4d50824 Merge remote-tracking branch 'origin/main' into develop
# Conflicts:
#	CHANGELOG.md
#	package.json
#	src/components/insights/insights-layout-shell.tsx
2026-05-16 15:58:55 +02:00
Marc-André BombeckandClaude Opus 4.7 15077482b1 chore(release): v1.4.32
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:57:49 +02:00
Marc-André Bombeck a1d5270070 docs(planning): v1.4.32 implementation handoff before release pipeline
Records the six implementation commits + the full-suite-green
state so a resumer can pick up the CHANGELOG / version bump /
release pipeline without re-discovering the diff. Iceberg notes
cover the latent Prisma-field-name bug fixed in Commit 1, the
default-visible `recentWorkouts` widget, the four metrics
intentionally without an empty-state CTA, and the SVG-polyline
route-preview decision deferring MapLibre / Leaflet to v1.5.x.
2026-05-16 15:55:37 +02:00
Marc-André Bombeck 3da0b38e0f fix(i18n): drop placeholder cta keys on the wave-A empty-state blocks
The four metric sub-pages without a manual-entry CTA
(`hrv`, `restingHr`, `oxygenSaturation`, `activeEnergy`) shipped
with `"cta": ""` placeholders that broke the locale-integrity
sentinel. The template never renders those keys when
`emptyStateCtaType` is null — drop the empty values so every
remaining key carries non-empty text.
2026-05-16 15:54:42 +02:00
Marc-André Bombeck 1f01b941ae feat(insights): chart cards for HRV, resting HR, SpO2, body temperature, active energy
Adds five sub-pages for the wave-A HealthKit metrics the server has
been storing since v1.4.23 / v1.4.25 but never surfaced on the web:

  - /insights/hrv               → HEART_RATE_VARIABILITY
  - /insights/ruhepuls          → RESTING_HEART_RATE
  - /insights/sauerstoff        → OXYGEN_SATURATION
  - /insights/koerpertemperatur → BODY_TEMPERATURE
  - /insights/aktive-energie    → ACTIVE_ENERGY_BURNED

Each page composes the shared `<HealthKitMetricPage>` scaffold —
`SubPageShell` + `HealthChartDynamic` mounted on its own
`ChartOverlayKey` slot + `CoachLaunchButton` + an empty-state CTA
that points at the existing `/measurements?add=<TYPE>` quick-entry
where a manual entry makes sense, or an Apple-Health-onboarding cue
otherwise. The five new chart-overlay-prefs slots (`hrv`,
`restingHr`, `oxygenSaturation`, `bodyTemperature`, `activeEnergy`)
let the chart-cog popover persist per metric.

Insights tab strip picks up the five new pills via the existing
`SUB_PAGE_METRIC` + `InsightMetric` enums. The wave-A categorisation
overlay realigns HRV + resting HR to the `cardiovascular` bucket
per the iOS handoff brief (PULSE stays in `vitals`; spot-pulse is a
point-in-time signal, derived heart-rate variants are the cardiac
risk surface). Six locales updated with the new sub-page titles,
descriptions, chart titles, and empty-state copy.
2026-05-16 15:53:18 +02:00
Marc-André Bombeck 3f5816112b feat(dashboard): recent workouts tile
Adds `<RecentWorkoutsTile>` as a default-visible dashboard widget
(`recentWorkouts`). The tile reads the top three canonical workouts
through `useWorkouts({ limit: 3 })`, renders each as a deep-link
row with the sport icon + the day + the duration, and falls back
to an Apple-Health-onboarding cue when the workout list is empty.

`DASHBOARD_WIDGET_IDS` + `DEFAULT_DASHBOARD_LAYOUT.widgets` carry
the new id; `widgetIdEnum` on `/api/dashboard/widgets` picks it up
from the same source-of-truth array. The Settings → Dashboard
layout editor surfaces it under the existing widget toggle list so
users can opt out without touching the layout JSON. Six locales
updated.
2026-05-16 15:47:44 +02:00
Marc-André Bombeck 61969fb025 feat(insights): workout detail page with HR + route + stats
Adds `/insights/workouts/[id]` on top of the v1.4.32
`GET /api/workouts/{id}` endpoint. The page mounts a header strip
(sport icon + source + date range + duration summary), a stats card
that surfaces every aggregate field present on the row (duration,
distance, active energy, average / max / min HR, steps, elevation,
pace for foot- and ride-paced sports), an SVG-rendered route preview
when the workout carries `WorkoutRoute` GeoJSON LineString
geometry, and an HR-chart placeholder that exposes the aggregates
inline until the per-second sample series wires up in v1.5.x.

Loading state mounts skeletons; the not-found branch surfaces a
neutral notice rather than a 404 page so deep-links from the list
page degrade gracefully when the workout has been deleted upstream.
2026-05-16 15:43:17 +02:00
Marc-André Bombeck beaee5791a feat(insights): workouts list page at /insights/workouts
Adds the user-visible surface for the workout rows the iOS client
already syncs through `POST /api/workouts/batch`. The page lives at
`/insights/workouts`, consumes the v1.4.32 list endpoint through a
new `useWorkouts()` hook, and renders each row as a deep-link into
the detail page with the sport icon, the date, the duration, and
the optional distance + active-energy chips.

The insights tab strip gains a `workouts` pill driven by an
event-style `hasWorkouts` availability signal — the layout shell
probes the list endpoint with `limit=1` so the pill self-gates on
zero workouts. Empty state mounts the shared `MetricEmptyState`
primitive with a Coach prefill that steers the conversation toward
the Apple Health sync onboarding cue. Six locales updated.
2026-05-16 15:38:07 +02:00
Marc-André Bombeck 8d514ed127 feat(api): expose workouts list + detail endpoints
Swap the v1.4.27 cluster picker on `GET /api/workouts` for the
source-priority-aware `pickCanonicalWorkoutRows()` (v1.4.30) so the
per-user ladder governs the canonical pick, fix the latent
Prisma-field-name bug (`distanceMeters` / `energyKcal` → the actual
`totalDistanceM` / `totalEnergyKcal` columns), and align the wire
shape with the iOS handoff contract (`distanceM`,
`activeEnergyKcal`, `avgHr`, `maxHr`).

Add `GET /api/workouts/{id}` for the workout-detail surface — owns
the optional `WorkoutRoute` GeoJSON geometry, exposes `canonicalId`
so deep-link redirects can land on the cluster winner, and seals
the existence channel with a cross-user 404 guard.

Register both routes in the OpenAPI registry so the pre-commit
drift hook catches the new shape going forward.
2026-05-16 15:29:33 +02:00