Commit Graph
148 Commits
Author SHA1 Message Date
Marc-André Bombeck 52edf85f02 chore(insights): retire the InsightAdvisorCard surface
Drop the InsightAdvisorCard component, its four integration tests
under src/components/insights/__tests__, the page-level mount on
/insights, the orphan i18n keys (advisorTitle, aiOverviewTitle), the
e2e advisor-card spec, the dashboard.spec.ts advisor assertion and
every related comment. The InsightsTabStrip regenerate icon stays
untouched as the sole power-user refresh affordance. The iOS-consumed
POST /api/insights/generate endpoint and its request/response shape
stay byte-stable.
2026-05-16 01:19:14 +02:00
Marc-André Bombeck cad53a68a3 chore(insights): retire the weekly-report surface
Drop the entire weekly-report path: the /insights/report/[week] route,
the WeeklyReportView component, the hero strip's banner mount and
"Wochenbericht erstellen" action button, the ISO-week helpers, the
schema slot, the AI prompt rule + OUTPUT FORMAT block, the openapi
field, the i18n keys and every associated test. Coach now owns the
sole hero-row action. iOS contract is unaffected — no native consumer
reads any weekly-report endpoint or schema slot per the v1.4.28 iOS
contract inventory.
2026-05-16 01:14:26 +02:00
Marc-André Bombeck ac80c099e2 fix(insights): unstick scroll on tab-strip and mother-page navigation
`/insights` landed at the wrong vertical position when the user
returned from a sub-page, and vertical swipes that began on the
sticky `<InsightsTabStrip>` were eaten by the strip's
`overflow-x-auto` scroll container so the page "felt stuck"
mid-gesture. Two roots, two fixes:

1. Move the horizontal pill scroll from the outer `<nav>` onto the
   inner pill-row `<div>` (the inner row already declared
   `overflow-x-auto`; the outer one is redundant once it's gone).
   Add `touch-action: pan-y` to the outer `<nav>` so vertical
   swipes pass through.

2. Defer the `<SubPageShell>` scroll-reset to the next animation
   frame so the sub-page's first paint settles before the scroll
   lands. Mirror the same deferred reset on the `/insights` mother
   page so a back-navigation lands cleanly at the top instead of
   inheriting the sub-page's vertical position through cached
   scroll state.

A Playwright spec at `e2e/insights-scroll-restoration.spec.ts`
locks both regressions: (a) /insights → /insights/<sub-page> → back
lands within 50 px of the top, (b) the outer `<nav>` declares
`touch-action: pan-y` and no longer owns the horizontal scroll.

iOS contract: none — both surfaces are web-only.
2026-05-16 00:31:05 +02:00
Marc-André Bombeck 267868c013 chore(cleanup): retire orphan exports and the stale legacy insights prompt module
Drop fourteen exported helpers and constants with zero call sites:
countMessages, buildGeneratePrompts, _resetSafetyContractsCacheForTests,
forEachLocaleAndRule, getPulseRange, trendLinePoints,
CHART_MINI_HEIGHT_PX, formatDateWithWeekday,
resolveGeneralStatusLocale, isSubPageSlug, recordHeartbeat,
EVENT_TYPE_LABELS, cleanupExpiredRateLimits, and sleepStageEnum.

Also retire the v1.4.20 legacy insights prompt scaffold —
getInsightsSystemPrompt, InsightsOutput, and the two dead
INSIGHTS_SYSTEM_PROMPT_EN/DE constants — plus the matching vi.mock
key in the generate route test that no longer maps to a live export.
Drop the empty __testables block in glp1-snapshot.ts, the legacy
helper reference in the insight-generator JSDoc, and the two stale
v1.4.16 InsightsPageHero comments in hero-strip + its test file.

Consume the MedicationComplianceChart compareBaseline prop via an
explicit void so the destructure documents the intentional accept-and-
defer pattern instead of looking like a missed wire-up.

Every symbol verified zero-caller via grep across src/, scripts/, and
__tests__/ before drop.
2026-05-15 21:16:35 +02:00
Marc-André Bombeck 3b045d6fba chore(insights): rename the advisor title key to neutral phrasing across six locales
Replace `insights.aiAnalysisTitle` with `insights.advisorTitle` across
all six locale bundles and the four call sites in
`insight-advisor-card.tsx`. EN renders "Personal health advisor", DE
"Persönlicher Berater" matching the card subtitle vocabulary, and
FR/ES/IT/PL pick up native phrasings instead of the English fallback
that previously leaked verbatim on those locales. The card now reads
in the same voice as the rest of the Coach and advisor surfaces.
2026-05-15 20:54:54 +02:00
Marc-André Bombeck 0b3f9e3e2c chore(misc): auth padding, daily-briefing links, tab fade, trends min-h, list metadata, passkey badge
v1.4.27 MB7 — residual surface polish bundled into a single
commit:

- CF-61: the auth login + register cards drop `p-8` to `p-6 sm:p-8`
  so Galaxy Fold / Pixel 5 keep the card breathable instead of
  surrendering half the visible viewport to padding.
- CF-68: each daily-briefing key-finding row wraps in a `<Link>` to
  the matching routed sub-page so the whole card is tappable on
  mobile. Metrics without a dedicated sub-page (HRV, resting HR,
  steps, …) render the legacy static row.
- CF-70: the sleep-stage window-toggle row bumps from `gap-1` to
  `gap-1.5` so the active pill border doesn't fuse with the
  inactive neighbour on Pixel 5.
- CF-71: the trends-row card `min-h-[300px]` floor moves behind a
  `md:` gate so mobile single-column doesn't eat dead space below
  a short chart.
- CF-72: the insights tab-strip paints a right-edge fade gradient on
  `<sm` so the user reads "there's more to scroll" when the pill
  row overflows.
- CF-46: the measurement-list filter row stacks vertically on `<sm`
  so the `w-48` SelectTrigger doesn't crowd the count caption to a
  2-line wrap.
- CF-76: the measurement-list mobile-row badges bump from
  `text-[10px]` to `text-[11px]` so the metadata holds the
  legibility floor.
- CF-75: the passkey mobile card promotes the device-type from
  plain text to an outline Badge so the chip row reads consistent
  with the desktop table column.
- MA2-F8: the medikamente page card header wraps the title in
  `min-w-0` + `truncate` so long medication names don't push the
  streak badge off the card on Galaxy Fold.
2026-05-15 19:23:34 +02:00
Marc-André Bombeck 071f26bf0f chore(insights): polish VO2 strip, status show-more, and trend-card secondary row
v1.4.27 MB7 — three related insights surface polish items in one
commit:

- CF-38: the VO2 stat strip extends the 2-column phone grid all the
  way to `<lg` so tablets keep the 2×2 layout, and every chip floors
  at `min-h-[64px]` so the row paints at a consistent height even
  when one stat has no trend arrow.
- CF-39: the insight-status-card body collapses to `line-clamp-3`
  with a "Show more" toggle when the assessment text is long
  enough to actually clip. Short bodies render plain so the user
  never sees a useless toggle.
- CF-41 + CF-67: the trend-card mobile-secondary row retires and the
  all-time aggregate renders inline alongside the `7d` / `30d`
  pair on every viewport. The flex-wrap already handles the overflow
  on narrow tiles, and the standalone comparison-delta block drops
  its `hidden sm:block` gate so the callout reads on every screen.
2026-05-15 19:14:08 +02:00
Marc-André Bombeck 650d4f8e45 feat(coach): re-pin the message-thread scroll when the soft keyboard opens
Subscribe to window.visualViewport.resize so the last bubble stays
visible when the phone's soft keyboard slides in. Without the listener
the scroller's scrollHeight reflects the layout viewport rather than
the visible region, leaving the tail of the thread hidden behind the
keyboard. The handler only re-pins when the user was already at the
bottom, matching the existing auto-scroll contract.
2026-05-15 19:07:42 +02:00
Marc-André Bombeck 40916d3127 feat(coach): swap the sources-rail toggle to a shadcn Checkbox
Replace the raw <input type="checkbox"> in the sources rail with a
shadcn-style <Checkbox> backed by radix-ui. The new primitive ships
a real focus ring, a Space-toggles keyboard contract, and a touch-
friendly hit target so the rail behaves correctly on the right-edge
side-tray surface mobile users open via the chevron button.
2026-05-15 19:07:07 +02:00
Marc-André Bombeck 2fb964ef9d feat(insights): gate the sub-page-shell focus-on-mount
v1.4.27 MB7 / CF-35 — the routed insights sub-pages used to focus the
heading on mount so a screen-reader landing announced the page title.
On mobile that fought the soft-keyboard: navigating between sub-pages
while a form was open dismissed the keyboard mid-typing. The
`focusOnMount` prop defaults to false, so only sub-pages that
explicitly want the screen-reader landing opt in. The `scrollTo({ top: 0 })`
reset stays unconditional because that's a sighted-user affordance,
not an a11y one.
2026-05-15 19:06:10 +02:00
Marc-André Bombeck f9558ce097 feat(insights): widen the Health Score card on tablets and rebalance the hero split
v1.4.27 MB7 / CF-34 — the score card swaps the fixed
`lg:w-[360px] xl:w-[400px]` width for a basis-based column so the hero
strip can split at `md+` and let the score occupy a generous-but-fluid
share of the band. The hero strip's `flex-row` flips activate one
breakpoint earlier (`md:`, alongside the legacy `lg:` modifiers) so
tablets receive the side-by-side layout instead of stacking the score
below the narrative copy.
2026-05-15 19:02:50 +02:00
Marc-André Bombeck 79dbdfbd2b feat(coach): hide the drawer window pill on phone viewports and pin title truncation
The bottom-sheet header already carries the avatar, title, new-chat,
settings, and close buttons on <sm viewports — adding the four-state
window pill there shrunk the title to a single character before
truncation. Wrap the pill in a hidden sm:block container so phone
users reach the same override via the sources-rail picker. Pin
min-w-0 on the SheetTitle so very long conversation titles always
clip cleanly inside the header.
2026-05-15 19:02:35 +02:00
Marc-André Bombeck 246c1def21 feat(coach): mount the drawer at the insights layout with a launch context
Promote the Coach drawer mount from the mother /insights page up to
/insights/layout.tsx so every routed sub-page can reach it. A new
CoachLaunchProvider exposes useCoachLaunch() for the hero strip,
suggested-prompt chips, and the seven sub-pages; the layout renders
the drawer beside the provider so navigation across Insights surfaces
no longer unmounts the conversation.

Each sub-page (blutdruck, gewicht, puls, stimmung, medikamente, bmi,
schlaf) mounts a new CoachLaunchButton that pins to the bottom on
narrow viewports and renders as an inline header action on lg+.
2026-05-15 18:59:55 +02:00
Marc-André Bombeck bfb5ba7290 feat(coach): tap-friendly composer hint and accurate evidence aria-expanded
- New `<Popover>` primitive (Radix-backed, Dracula surface tokens)
  mirrors the existing `<Tooltip>` wrapper. Tooltips do not
  tap-toggle reliably on touch; popovers do.
- The Coach composer info-icon swaps off `<Tooltip>` onto
  `<Popover>` so the long-form "Enter to send, Shift+Enter for a new
  line" copy is reachable via a tap on mobile while the keyboard
  hover-preview semantic is preserved on desktop. The `aria-label`
  on the trigger keeps screen-reader users announced.
- The composer textarea now carries `enterKeyHint="send"`,
  `autoCapitalize="sentences"`, and an opt-in `autoFocusOnOpen` prop.
  The drawer flips that prop on when it mounts the fallback
  composer, so the textarea claims the cursor the first time the
  drawer opens; subsequent re-renders of an already-open drawer do
  not steal focus back.
- Coach evidence `<details>` is now controlled by local state. The
  summary carries an accurate `aria-expanded` that flips in
  lock-step with the disclosure; the native Enter / Space toggle
  semantics are preserved.
2026-05-15 18:42:15 +02:00
Marc-André Bombeck 48261b6797 refactor(coach): retire the redundant settings-sheet close-X
The primitive `<SheetContent>` close-X is an absolutely positioned
`opacity-70 rounded-xs` button that clashes with the rest of the
sheet's in-header chrome. Hide it via `showCloseButton={false}` and
render a matching ghost-icon `<SheetClose>` inline in the header
so the close affordance shares the same baseline, ghost variant,
and 44 px tap floor as the coach-drawer header pattern. The
settings-sheet test mock picks up a `SheetClose` passthrough so the
existing skeleton + form assertions keep working.
2026-05-15 18:35:02 +02:00
Marc-André Bombeck beb2b40f7d chore(coach): lift the rail-tray chevrons out of the overlay
The mobile history + sources tray triggers used to mount as absolute
chevron buttons over the message thread, so they (a) overlapped the
first message bubble, (b) sat at a 28 px tap target (`h-7`), and (c)
relied on `backdrop-blur` to stay legible — which still failed on
busy threads.

Lift them into a sub-header strip above the thread: a flex row with
two ghost buttons at `min-h-11`. The strip itself carries `xl:hidden`
since both rails are inline at xl+; the history trigger keeps its own
`lg:hidden` because the history rail mounts inline already at lg+.

While the rail surface is in scope, drop the `opacity-0
group-hover:opacity-100` reveal on the history-rail per-row delete
button — touch surfaces have no hover, so the affordance was
invisible on mobile. The button now sits at `size-11` always-visible;
the confirming state still styles via `data-confirming`.

Test re-baselined to assert the new strip wrapper + `min-h-11` floor.
2026-05-15 18:34:53 +02:00
Marc-André Bombeck 4455472921 chore(insights): apply the tap-target floor across the insights surface
Lift every interactive element on the insights hero + advisor + coach
chrome to clear the 44 px WCAG 2.5.5 floor on touch.

- `hero-strip.tsx`: drop `size="sm"` from the action buttons (weekly
  report, ask the Coach, weekly banner read/share/export). The default
  primitive height now sits at 40 px so the explicit `sm` was the only
  thing keeping these below the floor.
- `suggested-prompts.tsx`: chip floor `min-h-9` → `min-h-11`.
- `insight-advisor-card.tsx`: regenerate icon buttons drop their
  `h-7 w-7` override so the `size="icon"` primitive default (40 px)
  takes over; legacy-payload CTAs drop their `size="sm"`/`h-7` overrides.
- `coach-drawer.tsx`: window pill `h-7` → `h-11`; the three header
  icon buttons (new chat, settings, close) `size-9` → `size-11`.
- `coach-input.tsx`: composer hint icon trigger `h-7 w-7` → `h-11
  w-11`. The Tooltip → Popover swap is owned by MB3 — distinct lines.
- `message-thread.tsx`: thumbs feedback row `py-0.5 text-[11px]` →
  `min-h-11 py-1.5 text-xs`. Same surface row, padding does the lift.
2026-05-15 18:32:28 +02:00
Marc-André Bombeck 6a76f7463e feat(coach): extend the snapshot window enum with lastYear
Adds a year-in-review window between last90days and allTime so the
Coach drawer can ground a long-horizon question (seasonal mood
patterns, year-over-year weight trajectory) without falling back to
the unbounded `allTime` window. The new enum value flows through the
scope schema, the snapshot builder's window-to-day-count mapping,
the provenance window-set seeding so the source-chips render the
year-in-review chip, and the Coach source-chips Record.

Extends the snapshot test with a `lastYear`-specific assertion that
pins the scope window and the provenance envelope.
2026-05-15 17:35:35 +02:00
Marc-André Bombeck 32a023dfff chore(ui): apply M7 + L1 + L3 + L4 polish across medication form, motion-reduce, details aria, therapy timeline
W10 design-review carry-over items. M7 lifts the medication-form
day-of-week buttons, the more-horizontal trigger, and the schedule
label-input gaps to the 44 px / space-y-2 standard used by every
other settings surface. L1 pairs every animate-spin/pulse/bounce
with motion-reduce:animate-none across the component tree so users
with prefers-reduced-motion get a still surface. L3 adds the
aria-controls + panel-id pairing to the inventory + coach-evidence
<details> blocks for parity with the Health Score provenance
accordion. L4 inserts a sr-only h4 per therapy-timeline entry so
screen-reader users can jump between medication blocks.
2026-05-15 17:13:42 +02:00
Marc-André Bombeck 2960f7352c chore(i18n): retire 414 dead translation keys across six locale bundles
Sweeps 154 dead keys per locale bundle (~924 strings across six
locales) flagged by the v1.4.27 dead-key audit. Drops the
insights.*Status* trio (loading / noKey / unavailable) across six
metrics, the legacy `insights.aiInsights` / `dashboard.insightsPreview`
roots that B1 replaced with the gated tab strip, the
`insights.healthScore.askCoach` / `coachPrompt` pair retired with the
inline button, the orphan onboarding.v2.* stub set, plus the
ad-hoc `medications.glp1.drug.*.class` and `notifications.eventPersonalRecord*`
strings that lost their callers in v1.4.25/W17.

Also drops the stale prefill-format test under
`health-score-card.test.tsx` that asserted the now-removed coachPrompt
key shape — the negative-assertion test for the retired inline button
stays in place.

The locale-integrity test already validates the new shape: no
re-baselining needed because the test asserts parity + no-empties
rather than a key-count threshold.
2026-05-15 17:11:26 +02:00
Marc-André Bombeck 5886fea8d7 fix(tests): repair hero-strip and telegram-test-locale assertions after R3a
Two test-contract fixes for fallout from concurrent R3a contributors:

- hero-strip.test.tsx — collapses the two onAskCoach forwarding tests
  into a single regression guard that asserts the inline Health Score
  card Ask-the-Coach button stays retired and the hero strip's
  action-row button carries the affordance.

- telegram-test-locale.test.ts — the route's POST handler takes zero
  arguments (apiHandler wraps an async () => function). Drops the
  Request argument from all seven call sites.
2026-05-15 17:00:13 +02:00
Marc-André Bombeck f3ff262451 feat(insights): gate sub-pages and tab pills on metric data availability
Brand-new accounts (no manual logs, no Apple Health upload yet) used to
land on chart skeletons over blank target bands when they clicked any
metric tab. Apple Health hides those surfaces entirely until the first
observation lands — match the pattern.

Introduce `src/lib/insights/metric-availability.ts` as the single
decision point. `hasMetricData(metric, inputs)` reads `summaries[METRIC].count`
for sensor metrics (PULSE / WEIGHT / BMI / BLOOD_PRESSURE_* / SLEEP_DURATION
/ VO2_MAX / STEPS / ACTIVE_ENERGY), the `hasMood` / `hasMedication`
booleans for event-driven metrics, and BMI inherits from WEIGHT.

Wire three render gates:

- `<InsightsTabStrip>` filters its pills via the new `availability`
  prop. Backward compatible: undefined `availability` keeps every pill.
- `<InsightsLayoutShell>` fetches `/api/analytics` +
  `/api/insights/comprehensive` once per route and threads the inputs
  into the strip; both queries share their cache keys with sub-page
  consumers so the payload lands single-fetch.
- Each `src/app/insights/{slug}/page.tsx` early-returns an
  `<EmptyState>` with the right CTA — `/measurements/new` for sensor
  metrics, `/mood` for Mood, `/medications` for Medication,
  `/settings/data-sources` for Sleep.

Auto-light-up: once the iOS native client uploads its first measurement
the same gate flips on the next refetch — no per-metric feature flag.

References the new `insights.emptyState.<metric>.{title,description,cta}`
translation keys (B6 lands them in messages/*.json):

- insights.emptyState.bloodPressure.{title,description,cta}
- insights.emptyState.weight.{title,description,cta}
- insights.emptyState.pulse.{title,description,cta}
- insights.emptyState.mood.{title,description,cta}
- insights.emptyState.medication.{title,description,cta}
- insights.emptyState.bmi.{title,description,cta}
- insights.emptyState.sleep.{title,description,cta}
2026-05-15 16:54:51 +02:00
Marc-André Bombeck 5b8a47b4ff fix(insights): strip the duplicate briefing paragraph and rebalance the Health Score column
The /insights hero strip already renders briefing.paragraph as its
subtitle directly above the Daily Briefing card; the card then mounted
the same paragraph a second time inside its CardContent before the
key-findings list. The user read the same string twice within 200 px.

Drop the leading paragraph render from <DailyBriefing>; the card now
opens directly on the structured key-findings list (the part the hero
subtitle cannot surface). The empty-state branch keeps its 'Generate
briefing' CTA so the populated → empty flow remains intact.

Rebalance the Health Score card so it reads as a co-equal column of
the hero rather than a small inset:
  - lg:w-[260px] → lg:w-[360px] xl:w-[400px]
  - score number text-4xl → text-5xl sm:text-6xl
  - progress bar h-1.5 → h-2
  - inline Ask-the-Coach button retires; the hero strip's existing
    action covers it. onAskCoach stays on the prop signature so callers
    don't break.

Fold in BL-P4-9 L2: disclaimer text-[10px] → text-[11px] (the 10 px
size was borderline against the 12 px mobile floor flagged in the
W10 design review).

Tests updated: daily-briefing test asserts the paragraph slot is
absent in the populated branch; health-score-card tests pin the new
no-button render across English and German locales.
2026-05-15 16:48:03 +02:00
Marc-André Bombeck fe18a8810c feat(coach): replace the textarea hint footer with an info-icon tooltip
The composer rendered the keyboard-shortcut hint ("Enter to send,
Shift+Enter for new line") as a prose `<span>` directly under the
textarea. Combined with the disclaimer and the send button, the
footer ate ~140 px of vertical room for what is essentially a single-
sentence affordance.

Replace the `<span>` with an `Info` icon wrapped in a Radix `<Tooltip>`
sat left of the send button. The existing `composerHint` translation
surfaces both as the trigger's `aria-label` (so screen-readers still
hear it on focus) and as the tooltip body for sighted users on hover.
The slot `data-slot="coach-input-hint"` stays so consumers can still
target the affordance.
2026-05-15 16:47:25 +02:00
Marc-André Bombeck 4d88764e4f fix(coach): collapse the evidence disclosure to closed-by-default
The assistant bubble's evidence `<details>` mounted open whenever the
user had `coachPrefs.showEvidenceByDefault = true`, which surfaced the
raw measurement values (e.g. "Blutdruck letzte 30 Tage: 226") under
every reply. The disclosure is now always collapsed; the user expands
by click. The persisted pref stays in the schema for backward
compatibility but no UI reads or writes it, so the value is dead-code-
tolerated for one release and can be dropped in the next migration.

Drops the `evidenceDefaultOpen` prop wiring from `<MessageThread>` and
the corresponding `<Switch>` in the Coach settings sheet. Adjusts the
sheet test that previously asserted the toggle was rendered.
2026-05-15 16:45:55 +02:00
Marc-André Bombeck e8fb0a7521 feat(audit): persist ASN and carrier on login audit rows
Adds two nullable columns to AuditLog (`asn Int?` + `carrier String?`)
via an additive `IF NOT EXISTS`-guarded migration. The audit-log
write path now resolves the location and the ASN row in parallel and
writes whichever fields the resolvers could fill in a single update.

A new `runGeoBackfill` helper walks rows where `location IS NULL`
AND `ipAddress IS NOT NULL` AND `createdAt > now() - 30 days`,
capped at 5 000 rows per pass. Useful for picking up rows that
landed during an `ipwho.is` outage or before the GeoLite2 MMDBs
were bundled into the image.
2026-05-15 16:43:03 +02:00
Marc-André Bombeck 9afb31bd15 chore(dashboard): retire the standalone InsightsCardPreview surface
The dashboard preview duplicated the much-richer /insights advisor surface
that already lives one click away (Daily Briefing, hero strip, correlation
row, full advisor, weekly-report banner). The preview also competed
visually with the GLP-1 tile for the same vertical real estate above the
chart row.

Drop the import + render + isChartVisible gate in src/app/page.tsx, drop
the insightsPreview widget entry from src/lib/dashboard-layout.ts, drop
the matching row in src/components/settings/dashboard-layout-section.tsx,
and delete src/components/insights/insights-card.tsx and its test file.
The advisor query hook stays on /insights — the dashboard no longer
mounts it. Update the dashboard-layout contract test to assert the
widget id is absent.

F7 weekly-report scan: ran grep -rinE 'Wochenreport|weekly report|/insights/report' across src/ and messages/. All weekly-report click affordances on /insights are correctly wired (HeroStrip Generate button → currentWeekHref, WeeklyReportBanner → /insights/report/[week], InsightAdvisorCard banner → /insights/report/[week]). The /insights/report/[week] route mounts WeeklyReportView and renders the full surface. No dead click affordance was located inside the 30-minute scan budget. The retired InsightsCardPreview was the only dashboard-anchored insight card with a CTA — its removal in this commit is the most likely reason the maintainer perceived a dead click. F7 deferred to v1.4.28 with a maintainer-screenshot ask; recorded in .planning/v1428-backlog.md.

i18n keys still referenced after this change (B6 sweep will reconcile):
none from this commit. Keys 'dashboard.insightsPreview' and 'insights.aiInsights' are now unreferenced in code and become candidates for B6's dead-key sweep.
2026-05-15 16:38:22 +02:00
Marc-André Bombeck 35cb488b6d fix(design): lift personal-record badge to WCAG AA contrast
The PR pill painted `text-dracula-green` (#50fa7b) on a
`bg-dracula-green/10` chip background. In dark mode that measured
around 2.6:1 — well under the 4.5:1 minimum WCAG 2.1 AA requires
for small text. Switch to the semantic `--success` token, which
resolves to a darker forest green on the light card (#14720a) and
to Dracula's neon green on the dark card, and lift the chip tint
from /10 to /15 for a sturdier edge. Both modes now clear AA.
2026-05-14 22:28:19 +02:00
Marc-André Bombeck c80e0a089d fix(insights): exact Student's-t p-value via incomplete beta
Pearson's two-sided p-value previously relied on the standard-normal
approximation of the t-distribution tail. The approximation is generous
in the wrong direction at low df: at df=12 a true p of 0.04 reports
back as p of 0.025 and the card sneaks past the p<0.05 surfacing gate.
v1.4.23 H6 sidestepped the issue by raising MIN_PAIRED_N from 14 to
20, but the gate was always defence-in-depth rather than correctness.

Auto-discovery work planned for v1.5/v1.6 widens the metric pair grid
past the three pre-defined cards, so the false-positive cost of the
normal approximation starts to compound. The fix lands the rigorous
path now: a regularised-incomplete-beta evaluation of the Student's-t
survival, via the Numerical Recipes Lentz continued-fraction
recurrence, with a Lanczos lnGamma prefactor. The two-sided p is
I_x(df/2, 1/2) for x = df / (df + t^2); the symmetric-tail identity
I_x(a,b) = 1 - I_{1-x}(b,a) keeps the recurrence in its convergent
half-plane.

Reference values agree with R's pt() to ~1e-4 across the surfacing
range. Three new tests pin: r=0.5 df=18 lands in [0.005, 0.06], r=0
df=18 returns p=1 via the symmetric-tail branch, r near 1 df=18
returns a strictly positive p (the old normalCdf call underflowed to
0). The existing noise-fixture test had to be regenerated against a
truly orthogonal LCG-derived stream — the previous fixture produced
r=-0.49 which the exact survival correctly flags as significant
(the normal approx was loose enough to mask it).
2026-05-14 21:40:44 +02:00
Marc-André Bombeck e51a4f53bb feat(insights): PR badge on metric trend tiles when achieved in last 30 days
Adds a small text-only PR pill that consumers can compose next to
a metric label. The badge pulls /api/personal-records?metricType=
and renders only when the most recent record landed in the last
30 days — older PRs are still visible on the dedicated /insights
PR list but the dashboard surface keeps a fresh-only filter so the
pill is a noticeable signal rather than a permanent fixture.

i18n keys insights.personalRecord.badge and insights.personalRecord.tooltip
ship in all six locales. The badge component is locale-aware
through the standard useTranslations / useFormatters hooks and
exposes a withTooltip prop so standalone uses (doctor report,
correlation cards) can skip the Tooltip provider.
2026-05-14 18:59:14 +02:00
Marc-André Bombeck 0e51007974 feat(insights): VO2 max chart-row card on Insights cardio surface
W8d shipped the opt-in VO2 max trend tile on the dashboard; this is
the deeper Insights surface. Renders one HealthChart for the trend
plus a compact stat strip (latest / min / max / 30-day average with
the comparison delta vs. the prior period) on /insights/puls because
VO2 max sits in Apple Health's Heart category — cardio fitness, not
its own root concept.

Implementation notes:

  - vo2Max joins CHART_OVERLAY_KEYS so the per-chart cog persists its
    overlay-prefs independently from the pulse chart sitting directly
    above it on the same page.
  - The card stays mounted at zero samples so a brand-new account sees
    the "no data yet" hint rather than a missing surface.
  - Compare-delta caption only renders when the user has a comparison
    baseline active AND the prior-period bucket has data; otherwise the
    slot is suppressed.
  - HealthChart is dynamic-imported with the same skeleton placeholder
    the trends row uses so Recharts stays out of the Insights bundle.

Translations land in all six locales; FR / ES / IT / PL carry English
placeholders matching the v1.4.25 convention. RTL test exercises the
stat-strip slots, the empty-state branch, the compare-delta gates, and
the German label rendering.
2026-05-14 17:50:30 +02:00
Marc-André Bombeck 4ec67056e5 refactor(health-score): hoist COMPONENT_ORDER, memoise asOf formatter
COMPONENT_ORDER moves to module scope so the provenance row order is
allocated once per process. The asOf date formatter is memoised on the
locale via useMemo so the per-row map no longer constructs a fresh
Intl.DateTimeFormat instance for each row. The defensive try/catch
around new Date() drops at the same time — the constructor doesn't
throw on bad ISO input, the Number.isNaN(getTime()) guard already
catches every failure mode.
2026-05-14 17:08:26 +02:00
Marc-André Bombeck 8f3a95463d fix(design): Dracula tokens + Romance BP abbreviations + therapy-timeline polish
- GLP-1 medication card streak flame: `text-orange-400` →
  `text-dracula-orange`. The hardcoded Tailwind shade was skewing
  warm against the rest of the card.
- /insights/medikamente compliance counters: `text-green-500` /
  `text-orange-500` / `text-red-500` → `text-dracula-green` /
  `-orange` / `-red`. Same drift in three sibling spans on one
  surface; replace as a set.
- Side-effect affordance: `Smile` icon read as a positive
  affordance for a *negative* logging surface. Switch to
  `Stethoscope` so the icon matches the clinical context.
- Health Score componentBp: FR `BP` → `TA`, IT `BP` → `PA`,
  ES `BP` → `PA`, PL `BP` → `RR` — the native compact forms
  used in clinical practice. DE "Blutdruck" + EN "BP" unchanged.
- Therapy-timeline `<li>` key: array index → composite
  (`kind-date-medicationName`). Prevents DOM-reuse bugs when a
  dose change is logged mid-window and reorders the list.
- Therapy-timeline inline icons: `aria-hidden="true"` on the
  `<Pill>` injection glyph and the dose-arrow `<ArrowUp>` /
  `<ArrowDown>` so screen readers stop announcing "image"
  mid-sentence.
- Medication-form Dialog tap targets: the schedule-row dropdown
  trigger (`h-7 w-7`) and the New-schedule button + the four
  schedule inputs (`h-8`) promoted to `h-11` so the dense
  schedule grid honours the 44px floor. The labels stay compact;
  the controls grow.
2026-05-14 16:57:56 +02:00
Marc-André Bombeck 72fcdc656f fix(a11y): WCAG touch-target floor + ARIA pairing across W4–W9 surfaces
Extends the W8.3 44px touch-target rule and the W8e accordion ARIA
contract to surfaces that shipped without them:

- Insights tab-strip pills: pills are primary navigation; promote to
  `min-h-11` so a thumb tap on "Stimmung" cannot bleed into the
  neighbour two pixels below.
- Injection-site picker: raise the invisible hit-target radius from
  11 to 12 SVG units so the rendered diameter (\~25.6 CSS px)
  clears the WCAG 2.5.8 Level AA 24×24 floor. The abdomen left/right
  pair at \Δx=24 caps the upper bound — larger circles would
  collide.
- Source-priority reorder arrows (per-metric + device-type axes):
  promote four `h-7 w-7` icon buttons to `h-11 w-11`.
- Sub-page heading: replace the bare `focus-visible:outline-none`
  with the same ring vocabulary used by insights pills + the Coach
  drawer so sighted-keyboard users get a visible indicator on
  programmatic focus.
- Source-priority per-metric + device-type accordions: pair
  `aria-expanded` with `aria-controls` + matching `id` on the
  panel, mirroring the W8e Provenance contract.
- Maintainership banner dismiss button: 22×22 hit zone replaced by a
  44×44 inline-flex button that keeps the same icon size.
- Injection-site picker group: `aria-label` was leaking the
  cadence-preset string; introduce
  `medications.injectionSitePickerAriaLabel` and translate it
  across all six locales.
- Body-outline SVG: hardcoded English "Body outline" replaced by
  `medications.injectionSiteBodyOutlineAriaLabel` with native
  translations in DE/FR/IT/ES/PL.
2026-05-14 16:55:10 +02:00
Marc-André Bombeck e14466e238 fix(i18n): sleep headline suffix translates across all six locales
The 'per night' caption beneath the sleep headline was branching on
`locale === "de"` and falling through to English for FR/IT/ES/PL.
Move the string into `insights.sleep.headlineCaptionSuffix` with
native translations (de: pro Nacht, fr: par nuit, it: per notte,
es: por noche, pl: na noc) and resolve it through `t()`.
2026-05-14 16:47:06 +02:00
Marc-André Bombeck bd1cb2c109 fix(insights): sleep-stage chart strokes resolve through Dracula tokens
CartesianGrid and axis labels were rendering un-themed because
`stroke="hsl(var(--border))"` evaluates to `hsl(#44475a)` —
invalid CSS — and the SVG fell back to currentColor. The Dracula
tokens are already hex, so the bare `var(--border)` /
`var(--muted-foreground)` form is the correct contract.
2026-05-14 16:45:08 +02:00
Marc-André Bombeck 2fd31f20b9 style(health-score): drop double divider above provenance accordion
The components list above the accordion already paints a top
border; a second border on the accordion wrapper made the divider
stride read visually heavy. Drop it so the accordion sits flush
under the four sub-bars.
2026-05-14 16:25:03 +02:00
Marc-André Bombeck ae700eddfd a11y(health-score): aria-labelledby pairing on provenance panel
Pairs the panel <section> with the toggle button via id +
aria-labelledby so screen readers announce 'Driven by' as the
panel's accessible name. The toggle already advertises
aria-expanded + aria-controls; the labelledby pairing closes the
loop the WAI accordion pattern recommends.

The drift-guard test that pinned strict attribute order on the
panel id now tolerates additional ARIA attributes between id and
data-slot via a flexible regex.
2026-05-14 16:22:57 +02:00
Marc-André Bombeck b48d05a8be feat(health-score): inline provenance accordion with source attribution
Adds a tap-to-expand 'Driven by' panel under the four component
sub-bars inside <HealthScoreCard>. Each row pairs the existing
value bar with a second weight-share bar tinted in the Coach
provenance accent plus a source pill (manual / withings /
appleHealth / mixed / none). Rows sort by effective weight
descending so the biggest contributor leads; null components sink
to the bottom and dim to 50 % opacity.

A 'Provisional' header badge fires when fewer than half of the
configured inputs carry data, and a role=status 'mixed sources'
banner surfaces above the rows whenever at least one component
blends multiple ingest paths.

i18n keys land under insights.healthScore.provenance.* across all
six locales (en / de / fr / es / it / pl with formal register).
The locale-integrity drift-guard auto-discovers the new keys.
14 new RTL-style SSR tests pin the contract: aria-expanded toggle,
aria-controls/id pairing, sort order, source-pill localisation,
mixed banner gating, empty-state dimming, provisional threshold,
asOf tooltip forwarding, and footnote rendering.
2026-05-14 16:21:32 +02:00
Marc-André Bombeck 1149ae5021 chore(insights): remove @deprecated InsightsPageHero (zero callers since v1.4.20)
`<HeroStrip>` (v1.4.20 phase B1) replaced this component on every
surface. Last production import was retired with the dashboard rewrite
and only the deprecated source + its self-test remained. Comment
markers in `hero-strip.tsx` already note the migration.

Pre-existing typecheck failures in the working tree (locale enum drift
from concurrent W9e i18n work in medication-card / medication-form /
target-card) are unrelated to this delete and stay as-is.
2026-05-14 15:29:57 +02:00
Marc-André Bombeck 98f7d11861 chore: resolve parallel-agent merge conflicts + sweep up loose edits
7 conflict files resolved by taking the upstream/HEAD side (which carries
the W4d/W5/W6c/W3f feature additions). Stashed-side changes that were
genuine new features re-land in subsequent commits if any were lost.

This sweeps up the loose state the W4e+W5e+W5d combined agent left
mid-stash. Resolves develop's working tree for clean push.
2026-05-14 14:19:08 +02:00
Marc-André Bombeck 7846d68dd5 feat(insights): sleep-stages chart renders per-night stacked bars (7/14/30d toggle)
Marc directive 2026-05-14 — the /insights/schlaf chart now shows one
bar per night with DEEP / REM / CORE / AWAKE / IN_BED / ASLEEP stacked,
matching Apple Health's sleep tab. The 30-day average-composition
read is preserved as a fallback when the API doesn't yet ship
perNight (legacy clients during rollout).

Plumbing:
- src/app/api/analytics/route.ts: computeSleepStageBreakdown() also
  builds a per-Berlin-tz-day perNight array, sorted ascending. Empty
  days drop out — the chart treats them as gaps.
- src/components/insights/sleep-stage-stacked-bar.tsx: rewrites
  the visualisation. Default window = 7d; 14d + 30d toggles in the
  card header (same min-h-11 touch targets as the dashboard range
  tabs). Y-axis renders hours, tooltips show per-stage minutes +
  percent + nightly total.
- Mobile-first: window-toggle pills reflow under the title on <sm
  screens, sit inline on sm+. Bars use Recharts vertical layout
  (per the directive — no horizontal reflow on mobile, just tighter
  XAxis labels via preserveStartEnd).

i18n: no new keys — reuses insights.sleep.stages.* + the existing
compositionTitle / compositionSubtitle / compositionAriaLabel slots.

Tests: 4 new cases under sleep-stage-stacked-bar.test.tsx cover
- 7d default + three toggle buttons rendering
- empty-state (perNight=[] AND stages={} → 'sleep-stage-empty' slot)
- legacy fallback (no perNight → aggregate row still renders)
- sparse perNight (gaps don't crash the chart)
Existing 4 tests (title + aria + de locale + unknown stage key)
keep passing after perNight prop addition.
2026-05-14 14:08:56 +02:00
Marc-André Bombeck d74aa2066b style(glp1): prettier formatting across W4d surfaces 2026-05-14 14:03:43 +02:00
Marc-André Bombeck 2c7c3cc9f3 feat(insights): GLP1 therapy timeline on /insights/medikamente
Phase 8 of W4d GLP-1 full integration. New TherapyTimeline component
mounts on /insights/medikamente below the compliance grid. Renders a
chronological merge of every GLP-1-relevant event the user has logged:
dose changes (with up/down arrow against the previous step),
injections (with optional site), inventory events, and side-effect
days collapsed from MoodEntry tags.

Self-hides for users without a GLP-1 medication (the API returns
`hasGlp1: false`), so /insights/medikamente keeps its v1.4.24 look
for everyone else.

New GET /api/insights/glp1-timeline aggregates the data. Caps at 60
entries by default (configurable to 200 via ?limit) to keep the list
readable; older events surface via the existing intake-history page.

DE + EN copy for the surface lives under insights.therapyTimeline.
The medikamente sub-page description gains a one-liner explaining
when the timeline appears.
2026-05-14 13:49:35 +02:00
Marc-André Bombeck 36bf96059e style(coach): prettier formatting on W5 surfaces 2026-05-14 13:37:41 +02:00
Marc-André Bombeck b50ebccf0b feat(coach): distinct daily-limit vs provider-rate-limit error UX
`coach.budget.exceeded` (per-user daily token cap, returned as JSON
429) now surfaces the dedicated "Daily limit reached; resets at 00:00
UTC." copy. `coach.provider.rate_limited` (every provider in the
fallback chain landed on 429, streamed as an SSE error frame) surfaces
"Provider temporarily rate-limited; retry in ~5 min." Other provider
failures keep the generic copy. The chat route emits the new
rate-limited code; the send hook decodes the JSON 429 envelope's
structured `error` field so the drawer can route the bubble copy.
2026-05-14 13:35:57 +02:00
Marc-André Bombeck c7eb226969 feat(coach): composer textarea auto-grows from 1 to 6 lines, Claude-web pattern
The composer starts at a single-line height (matching the disclaimer
text on the left of the row) and grows with content up to six lines.
Past that point the textarea scrolls internally so the rest of the
drawer never moves. Implementation is the standard scrollHeight
measurement — reset to "auto", read scrollHeight, clamp to
[lineHeight + padding, 6 × lineHeight + padding]. The pure
`computeAutoGrowHeight` helper backs the effect for unit testing.
2026-05-14 13:32:28 +02:00
Marc-André Bombeck 70fe5a55c2 refactor(coach): drop non-functional mic icon from composer
The mic was rendered + disabled with a "voice arrives with iOS"
tooltip, drawing taps that did nothing. Voice input remains scheduled
for the iOS client (v1.5); the web composer no longer surfaces a
placeholder. Frees the row for the Enter-to-send hint to breathe.
2026-05-14 13:31:12 +02:00
Marc-André Bombeck 81b1d79232 style(coach): unify X / cog / new-chat header buttons — same size + alignment
All three header actions (new chat, settings, close) now share the
same ghost / size-icon / 36-px hit-target shape with a 16-px icon and
identical color treatment. The Sheet's default absolute-positioned
close X is gone; the inline close button sits on the same baseline as
the others so the cluster reads as a single set.
2026-05-14 13:30:12 +02:00
Marc-André Bombeck 330be965ac feat(coach): default analysis window as user preference + per-conversation override
`coachPrefs.defaultWindow` joins the existing prefs blob (tone,
verbosity, excludeMetrics, showEvidenceByDefault). The picker lives in
the Coach settings sheet; the drawer header carries a small pill that
overrides the saved default for the current chat only. Closing the
drawer drops the override so the next session starts on the saved
preference. Backwards-compatible: legacy rows missing the key resolve
to "allTime", matching the v1.4.24 behaviour. The chat route folds the
saved window into snapshot scope when the client doesn't supply one.
2026-05-14 13:28:10 +02:00