Commit Graph
420 Commits
Author SHA1 Message Date
Marc-André Bombeck 322d0844ee fix(charts): reserve chart-card dimensions to avoid Recharts -1 size warning
The scatter-correlation card wrapper relied solely on Tailwind's
aspect-ratio utilities to derive its height from width, and explicitly
unset any inline height. When the dynamic-loaded chart mounted in
place of the h-[180px] skeleton, ResponsiveContainer briefly measured
a zero-sized parent before the browser resolved aspect-ratio against
the parent's width, emitting a width(-1)/height(-1) console warning.

Add a min-h-[180px] floor (matching the loading skeleton) so the
wrapper always has a non-zero height at first paint. The aspect-ratio
rules continue to drive the final card shape once layout settles.
2026-05-16 21:53:07 +02:00
Marc-André Bombeck 0729c2028b feat(dashboard): hoist Coach launch provider and add hero CTA
The Coach drawer was previously only reachable from `/insights/**`. The
`<CoachLaunchProvider>` + `<LayoutCoachMount>` now live on the global
`<AuthShell>` so every authenticated route shares the same launch context,
and the dashboard hero strip carries a "Frag den Coach" / "Ask the coach"
CTA next to the existing "Hinzufügen" pill.

Provider hoist
- `<CoachLaunchProvider>` wraps the authed branch in
  `src/components/layout/auth-shell.tsx`; `<LayoutCoachMount>` sits beneath
  the main scroller so the drawer renders once at the shell level.
- `src/app/insights/layout.tsx` drops the provider + drawer mount. The FAB
  stays scoped to `/insights/**` because the floating action would distract
  from the dashboard hero on surfaces with a contextual inline CTA.

Dashboard hero CTA
- New `<Button>` next to the "Add" dropdown calls `coachLaunch.askCoach(null)`
  on the hoisted context. 44 px tap target via `min-h-11` (WCAG 2.5.5),
  shrinks back to 36 px on `sm:` upwards alongside the sibling pill.
- Self-gates on the provider mount + the operator's `coach` feature flag so
  the button silently disappears when the Coach surface is disabled.

The `dashboard.coachCta` i18n key (6 locales) ships in the locale commit
that pairs with this change. New test file pins the hoist contract at the
source level; the existing v1.4.27 R3d MB4 polish test was updated to
reflect the new mount location instead of the old insights-layout one.
2026-05-16 20:48:50 +02:00
Marc-André Bombeck bcca7664a5 refactor(insights): collapse five wave-A vital pills under a Vitals parent
The Insights tab strip carried fourteen pills end-to-end after the
v1.4.32 wave-A HealthKit additions: Overview + thirteen metrics.
On a `<sm` viewport that meant Overview + roughly two-and-a-half
metric pills before the right-edge fade hid the rest.

The five wave-A entries (HRV, Resting HR, Oxygen, Body Temperature,
Active Energy) collapse behind a single "Vitals" / "Vitalwerte"
parent pill that opens a popover sub-list. Each sub-page keeps its
own URL and own page component — only the strip presentation
changes, so deep-links, bookmarks, and the iOS app's surface paths
all resolve unchanged.

Implementation:
- `SUB_PAGE_GROUP` + `SUB_PAGE_GROUP_ORDER` in
  `sub-page-metric.ts` flag a sub-page as belonging to a parent
  pill. Adding a new group is one row in `SUB_PAGE_GROUP` plus a
  matching label key under `insights.tabStrip.<group>Parent.*`.
- `<InsightsTabStrip>` discriminated `TabEntry` union — flat
  `<Link>` pills coexist with `Popover`-driven group entries.
  Availability gating sits on the leaf slugs: the parent pill is
  only rendered when at least one of its children has data.
- Parent-pill active state mirrors the URL — when a user is on any
  vital-sign sub-page, the parent pill carries `aria-current="page"`
  and the `border-primary` style so spatial orientation survives
  the collapse.

Reduces the strip footprint from 14 to 10 entries when every metric
has data — Overview + Blood Pressure + Pulse + Vitals (parent) +
Weight + BMI + Workouts + Sleep + Mood + Medication.

Per `.planning/research/v1434-r-2-carryover-scope.md` §8 and
`.planning/round-v1433-audit-menu.md` §7.1 (item 1).
2026-05-16 20:48:05 +02:00
Marc-André Bombeck fd81d3b806 refactor(settings): merge Sources into Thresholds as "Targets & Sources"
The two former settings sections (`/settings/thresholds` and
`/settings/sources`) covered the same conceptual shelf — per-metric
configuration. Reading them together matches how users actually
think about each metric ("for this metric, which sources do I trust
AND what range counts as healthy?") and collapses two adjacent
sidebar slots into one.

`<ThresholdsSection>` now hosts both editors stacked under one page
header: the per-metric threshold ranges on top, the per-metric source
priority + two-axis device-type ladder below. The two mutation flows
(`/api/user/thresholds` and `/api/auth/me/source-priority`) stay
distinct so a save on one half never disturbs the other.

`<SourcesSection>` gained a `mode` prop. The default `"standalone"`
behaviour is preserved for any caller that still mounts it directly;
the new `"embedded"` mode drops the inner header so the assistive-tech
reading order stays one combined surface, not two.

The `sources` slug was dropped from `SETTINGS_SECTION_SLUGS` (down to
ten sections), and the Sources entry was removed from the in-shell
sidebar + mobile chip-strip. `/settings/sources` stays alive as a
`permanentRedirect` to `/settings/thresholds`, so external bookmarks
(iOS Settings deep-links, docs, anything that linked the URL) follow
through unchanged.

Tests updated: `SETTINGS_SECTION_SLUGS` size assertion drops from
eleven to ten; `<ThresholdsSection>` mount test now expects both
editor mount-points; the section title moves from "Personal
thresholds" / "Persönliche Zielwerte" to "Targets & Sources" /
"Zielwerte & Quellen".

Per `.planning/research/v1434-r-2-carryover-scope.md` §7 and
`.planning/round-v1433-audit-menu.md` §7.1 (item 3).
2026-05-16 20:47:46 +02:00
Marc-André Bombeck c6528b9340 test(import): cover Apple Health export.zip parser + endpoints
Adds 36 unit + contract tests across 7 files:

* `import-apple-health-export.test.ts` — end-to-end parse of a tiny
  hand-authored fixture (spot weight, cumulative steps fold, sleep
  stage, workout with sport mapping, deferred + unknown identifiers,
  clinical record skip). Re-import idempotency: a second run reports
  0 inserts + N updates. Memory ceiling: synthetic 10 000-record
  export keeps heap delta under 100 MB.
* `hk-workout-activity-type-map.test.ts` — every value referenced
  in the map exists in the canonical `workoutSportTypeEnum` union;
  the iOS 18 canonical roster is covered; unknown identifiers fall
  through to `"other"` with a falsy `known` flag.
* `unzip-export-xml.test.ts` — central-directory walker round-trips a
  hand-built single-entry deflate ZIP; extractor throws on a missing
  `export.xml` member.
* `stream-to-disk.test.ts` — boundary parser; named-field capture +
  SHA-256 + text-field collection; size-cap enforcement; boundary-
  missing error path.
* Route tests — auth gate (401), 403 on non-admin to the admin
  variant, 413 on content-length cap, 429 on rate-limit; status
  endpoint admits owner + triggering admin and 404s otherwise.
2026-05-16 20:41:02 +02:00
Marc-André Bombeck c6345b71fa test(queries): pin achievements hook collapse + audit analytics consumers
v1.4.34 IW-F-Perf — three changes.

1) New `use-achievements-query.test.tsx` mounts three consumers under
   one TanStack QueryClient and asserts the shared queryKey collapses
   them into a single network call. Pre-fix the notifier issued its
   own request alongside the card; the regression guard surfaces it
   immediately if a future drift reintroduces a per-user
   discriminator.

2) Refresh `recent-achievements-card.test.tsx` and
   `achievements/__tests__/page.test.tsx` to mock the new shared hook
   directly instead of the raw `@tanstack/react-query` `useQuery`
   shim. The two test suites stay scoped to render-shape, not the
   network plumbing.

3) Capture the `/api/analytics` slim-vs-thick audit in
   `.planning/round-v1434-iwfperf-analytics-audit.md`. The dashboard
   root reads `bpInTargetPct*` + `glucoseByContext` so it must stay
   thick; the checklist is correctly slim by IW2 design but is
   mounted inside the dashboard render tree so its slim call
   duplicates the thick one already in flight. Documented two clean
   migration paths and flagged the work for v1.4.34.x behind IW-B's
   in-flight slim-slice routing.
2026-05-16 20:40:34 +02:00
Marc-André Bombeck 3b77b6a073 refactor(gamification): collapse achievement consumers onto shared hook
v1.4.34 IW-F-Perf — migrate `<RecentAchievementsCard>`, the
`/achievements` mother page, and `<AchievementUnlockNotifier>` from
ad-hoc `useQuery` blocks onto `useAchievementsQuery()`.

Pre-change the dashboard cold mount fired
`/api/gamification/achievements` twice — once for the card, once for
the notifier — because the notifier carried a per-user discriminator
on its queryKey and TanStack treated it as a fresh cache cell. The
shared hook routes all three consumers through the same
`queryKeys.gamificationAchievements()` slot, so a single network
call now backs every surface. The notifier keeps its 2-minute
refetch cadence; long-open tabs still surface new-unlock toasts and
the card picks up the same payload from the shared cache without
issuing a parallel request.
2026-05-16 20:40:16 +02:00
Marc-André Bombeck 30c8555cee refactor(charts): drop the v1.4.33 heatmap classifier-bug fallback
v1.4.33 shipped a defensive fallthrough in `getColor` that detected
the unambiguous "every taken dose landed in veryLate" pattern and
recoloured those cells via the rate-based palette. With the
classifier hardening landed in `compliance.ts` the pattern can no
longer arise from a UTC offset between `takenAt` and the schedule
window, so the workaround is dead code. Replace the long
`looksClassifierBug` block with a single comment pointing at the
root fix and let the timing-based palette run unconditionally when
timing data is available.
2026-05-16 20:25:09 +02:00
Marc-André Bombeck ab1700a843 fix(onboarding): really stop spotlight tour from blocking the quick-add button
The first attempt at the F2 fix (f9b8f3bd) split the spotlight tour
backdrop into four rectangular dim panels around the spotlight rect
and opted each one back into pointer-events: auto with its own
onClick={handleSkip}. The intent was to keep the spotlighted target
clickable while preserving the "tap backdrop to dismiss" affordance.

The flaw: tour-step 1 on the dashboard spotlights the tile-strip,
which sits in the middle of the page. The header "Add" button sits
ABOVE the spotlight. The TOP dim strip (rendered from y=0 down to
where the spotlight begins) therefore covered the entire header
area, so the very target the F2 audit named — the header Add
dropdown — was still being eaten, just by a smaller rectangle.
The four-panel split fixed exactly ONE coordinate (inside the
spotlight rect) and broke every coordinate outside it that a real
user might want to click during the tour: header buttons, the
user-menu avatar, sidebar links, the locale switcher.

This commit ships the contract that Joyride / Shepherd / Intro.js
have all standardised on: the dim is purely visual. The whole
tour layer is pointer-events: none; only the tooltip card opts
back into hit-testing. The four-panel split survives because it
still serves the visual purpose of NOT dimming the spotlighted
target, but each panel is now a <div aria-hidden="true"> with
pointer-events: none — no onClick, no aria-label, no interactive
semantics. The center-placement fallback follows the same shape.
The tooltip's onClick={(e) => e.stopPropagation()} guard came off
because nothing bubbles to the dim any more. Tour state (Skip,
Back, Next) lives entirely on the tooltip's footer buttons,
matching the affordance users expect and keyboard-accessible via
the existing Esc shortcut.

The e2e regression spec at e2e/onboarding-tour-passthrough.spec.ts
gained a third case and tightened the first one. Case 1 now runs
document.elementsFromPoint at the quick-add button's centre, walks
the stack, and asserts that NO element belonging to the tour
overlay has computed pointer-events !== "none". This pinpoints the
regression instead of observing the symptom. Case 2 swapped from
"click a dim strip skips" (an artefact of the failed intermediate
fix) to "the explicit Skip button in the tooltip dismisses the
tour". Case 3 reads computed pointer-events from every
[data-testid="onboarding-tour-dim"] and asserts each is "none" —
the load-bearing invariant the F2 regression violated.

Quality gates: typecheck clean, eslint clean on the touched files,
all 43 onboarding unit tests pass, all 6 F2 e2e cases pass locally
(3 cases x chromium-desktop + chromium-mobile).
2026-05-16 18:42:02 +02:00
Marc-André Bombeck bafac84f8d chore(i18n): retire the now-orphan settings.kiInsights key
The dedicated card H2 was retired in the previous "drop AI prefix"
pass; the `settings.kiInsights` translation key it consumed has no
remaining call-site in source. Drop it from de / en / es / fr / it /
pl. Also update the message-thread test to the new "Insights
provider" copy.
2026-05-16 18:02:59 +02:00
Marc-André Bombeck 81225a764a fix(settings): scroll-snap on the mobile section strip
`no-scrollbar` already suppresses the painted scrollbar so the strip
doesn't look like every settings card has an overflow problem. The
`snap-x snap-mandatory` layer on the strip plus `snap-start` on each
chip means a swipe-flick lands on the next chip's leading edge instead
of the in-between dead zone — the auto-scroll-into-view effect on
mount stays the canonical positioner for the active chip; snap is the
polish layer for manual flicks.
2026-05-16 18:00:38 +02:00
Marc-André Bombeck 0de1e2ebe7 fix(settings): fold the About section into the user-card dropdown
Eleven sections on the settings sidebar was one over the comfort
line. About is three small cards (identity, links, update check) that
collectively get read once or twice a year and didn't earn a top-level
slot. Hide it from the in-shell nav and surface it as "About HealthLog"
in the sidebar user-card dropdown (mobile dropdown mirrors). The
`/settings/about` route stays alive so direct links, the public
`/about` permalink, and the dropdown entry continue to resolve.
2026-05-16 17:59:52 +02:00
Marc-André Bombeck 2620064ef1 fix(a11y): repair non-sequential heading order on three surfaces
Lighthouse v1.4.33 a11y pass flagged "heading elements not in
sequentially descending order" (score 91/94). Three offenders:

- `<TherapyTimeline>` (insights/medikamente sub-page): sr-only
  drug-name heading was an h4, but the parent SubPageShell ships
  the only h1 and CardTitle is a <div>. Promoted to h2 so the
  outline stays h1 -> h2 without skipping levels.
- `<FeedbackInboxDetailSheet>`: metadata + screenshot section
  headings rendered as h4 inside a ResponsiveSheet whose dialog
  title is h2 (Radix default). Demoted to h3 to stay sequential
  beneath the sheet title.
- `<RemindersSection>`: results heading was h4 inside the admin
  SectionFrame's h1. Promoted to h2.

Coach panel headings (IW5 surface) audited but not changed.
2026-05-16 17:57:28 +02:00
Marc-André Bombeck 972c8a56dd fix(nav): drop the redundant "Home" group label from the desktop sidebar
The sidebar has exactly one nav group, and the first entry is already
"Dashboard" pointing to `/`, so the "HOME / Dashboard" pairing used to
read as if there were two separate destinations for the homepage. Drop
the group label and retire the `nav.home` locale key in every bundle.
The collapse toggle keeps its position at the top-right of the strip.
2026-05-16 17:57:21 +02:00
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 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é 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é 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 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 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 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 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 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 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 88bd9dddb7 fix(insights): unblock the tab strip during initial mobile load
Three client-side fixes that collapse the worst-case tap-block
window from the LLM-completion tail (~30 s on a cache-miss) down
to the bounded parallel-fetch window (~400-600 ms). The strip
itself stays interactive in the DOM throughout; the previous
freeze symptom traces to main-thread contention starving WebKit's
gesture-recognition timeout. Per
`.planning/research/v15-insights-blocking-bug.md`.

1. `fetchAdvisor` (`use-insights-advisor.ts`) gets an 8-second
   `AbortController` and converts `AbortError` into the existing
   graceful-null return path so the consuming UI surfaces the
   regen CTA instead of pinning the query in `isFetching: true`
   for the LLM's full completion latency.

2. `<InsightsTabStrip>` is wrapped in `React.memo`; the inner
   `buildTabs(availability)` is `useMemo`'d on `availability`; the
   layout shell `useMemo`'s the `availability` prop on its three
   leaf inputs. Together they break the
   "shell re-renders -> strip re-renders -> 8 pills re-render"
   cascade on every cache-write of analytics or comprehensive.

3. `<CoachDrawer>` is now lazy-loaded via `next/dynamic({ ssr: false })`
   inside `<LayoutCoachMount>` so the SSE machinery, the chat
   reader, the suggested-prompts chip rail, and the persistent
   settings sheet don't run their initialisers on every cold
   /insights mount. The drawer renders nothing until the user
   actually opens it.

Tests pin the memo contract on the strip and the import-cleanness
of the abort path. The wider fix-5 (analytics SQL aggregation per
R-B C1) stays scoped to v1.5.
2026-05-16 14:05:43 +02:00
Marc-André Bombeck e778835c9c i18n: localise the operator-disabled assistant notices
Four user-facing keys land in all six locale files so the
`<AssistantDisabledNotice>` component reads natively localised
copy:

  insights.coach.disabledByOperator
  insights.briefingDisabledByOperator
  insights.statusDisabledByOperator
  insights.correlationsDisabledByOperator

The Coach key nests under the existing `insights.coach.*` object;
the other three sit at the top of `insights.*` to avoid colliding
with the existing string-valued `insights.correlations` key.

The Health-Score explainer has no notice — its suppression is
silent on purpose so the bare delta digit still reads as a clean
KPI.

DE carries the full native phrasing; the established convention
for the other five locales (FR/ES/IT/PL/EN) keeps the user-facing
strings localised even where admin strings still default to
English.
2026-05-16 14:02:22 +02:00
Marc-André Bombeck 3b56bac59a feat(admin): assistant feature-flag toggle panel
New admin slug `assistant` between `ai-quality` (provider config)
and `coach-feedback` (in-app feedback inbox). The panel renders six
`<SettingsToggle>` rows — master plus five sub-flags — and writes
through a dedicated `PUT /api/admin/settings/assistant-flags`
endpoint. Optimistic UI flips on click; failure surfaces a toast
and re-invalidates the read query.

The dedicated endpoint (separate from the generic `/api/admin/settings`
PUT) lets the panel scope its audit trail to a single
`admin.settings.assistant-flags.update` action and lets the
optimistic UI bind 1:1 to a focused request/response shape. The
PUT handler echoes both the raw column values and the resolved
shape so the panel can render the master vs sub-flag distinction
visually.

Sub-toggles grey out when the master is off (UX-wise) so the
operator can see which sub-flags are individually flipped before
unmuting the master. Writes invalidate the runtime
`/api/feature-flags` cache so the operator sees the toggled
surface react within the same session.

i18n: `admin.section.assistant.{title,subtitle}` and
`admin.assistant.*` keys land in all six locales (DE proper, the
five English-leaning admin locales mirror the English copy as is
the established convention for admin strings).
2026-05-16 14:00:10 +02:00
Marc-André Bombeck 03d0b64bb8 feat(insights): gate assistant surfaces on operator feature flags
New client hook `useFeatureFlags()` reads the matrix from
`/api/feature-flags` once per session and feeds every gated mount
on the Insights surface. The hook fails open — any network error
or missing `<QueryClientProvider>` (the legacy presentational test
posture) returns the all-on default so the user never loses an
affordance to an instrumentation gap.

Gated surfaces:

  <LayoutCoachFab>, <LayoutCoachMount>, <CoachLaunchButton>
                                                    → coach
  <DailyBriefing> on /insights/page.tsx             → briefing
  <CorrelationRow> on /insights/page.tsx            → correlations
  <InsightStatusCard> × 6 sub-pages                 → insightStatus
  <HealthScoreDeltaExplainer> ? glyph               → healthScoreExplainer

Master `assistant.enabled = false` forces every sub-flag false in
the resolver, so flipping the master collapses every surface at
once. Each component returns `null` when its flag is off; the
delta digit on the Health-Score card stays visible because the
parent reads the number directly.

New `<AssistantDisabledNotice>` component prepares the empty-state
copy for callers that detect a 403 + `errorCode:
"assistant.disabled.<surface>"` response. The localised strings
land in the dedicated i18n commit.
2026-05-16 13:54:00 +02:00
Marc-André Bombeck 0522652ae2 feat(measurements): wire walking-steadiness + audio-exposure-event through every registry
v1.4.30 commit 7 follow-up. The new MeasurementType values land in
every derived registry the canonical enum drives:

  - measurement-list-meta.ts label keys + icons + colors so the
    measurements list view renders both types (Gauge for steadiness,
    Volume2 for the loud-listening event)
  - 6 locale files (de/en/es/fr/it/pl) carry typeWalkingSteadiness +
    typeAudioExposureEvent keys; English-fallback copy for the four
    non-EN/DE locales matches the existing v1.4.25 W8d pattern
  - chart-tokens.ts ALLOWED_CHART_TOKENS list so the LLM can
    reference `metric:WALKING_STEADINESS` + `metric:AUDIO_EXPOSURE_EVENT`
    in prose; dedicated chart components ship alongside the iOS sync
  - the enum-coverage drift-guard test's EXPECTED_TYPES + PDF
    exclusion set so the canonical enum-extension lint stays green

Full unit suite passes (4035 specs).
2026-05-16 12:23:30 +02:00
Marc-André Bombeck c265103b69 fix(charts): sum cumulative types in the client-side daily aggregator
The 7-day window on the dashboard chart still pulls raw rows from
`/api/measurements` (the server-side `aggregate=daily` path kicks in
only for windows over a week). The client-side daily aggregator then
reduced every per-day bucket with `sum / count`, which averages step
counts across the per-sample rows Apple Health emits instead of
summing them. Steps on a 7-day view rendered as the per-sample
average (hundreds), not the daily total.

The reduction now branches on `CUMULATIVE_HK_TYPES` (the same set
that drives the server-side SQL aggregator in v1.4.29): cumulative
types reduce with `sum`, spot metrics keep the `sum / count`
average.
2026-05-16 11:19:46 +02:00
Marc-André Bombeck b012641804 fix(settings): tighten the dashboard-layout drag-list rows on mobile
Each widget row carried a vertical pair of 44-px arrow buttons —
116-px row, repeated across 15 widgets, ~1 740 px of scroll just
for the reorder list. Three times the height of every neighbour
on the Settings page.

Swap the vertical arrow stack for a horizontal pair on the
trailing edge. `size-11 sm:size-9` preserves the 44-px mobile
tap target and shrinks to 36-px on desktop where pointer accuracy
is high. Row container drops `p-3 gap-3` for `px-3 py-2 gap-2`
with `min-h-12` as the height floor — 48 px, 2.4× tighter.
Label gains `truncate` + `title` so long widget names stay on a
single line.

Column-header spacer widens to reserve the new trailing arrow
column so the Tile / Chart switches stay column-aligned.

The existing `dashboard-layout-section.test.tsx` reads `data-slot`
attributes (unchanged), so the four-test suite stays green.
2026-05-16 10:45:28 +02:00