Commit Graph
292 Commits
Author SHA1 Message Date
Marc-André Bombeck 1895fd3cd8 fix(coach): suggested-prompt chips show user message before thinking placeholder
The send hook now surfaces the user's message as an optimistic bubble
the moment send fires, so the chronological render order matches the
user's mental model: user message → "Thinking…" placeholder →
assistant reply. The optimistic copy drops as soon as the persisted
twin lands via the invalidate-refetch the SSE `done` frame triggers.
2026-05-14 13:19:17 +02:00
Marc-André Bombeck 16fbdbcb87 style: prettier across W7b surfaces 2026-05-14 13:19:17 +02:00
Marc-André Bombeck 84b54d9f11 refactor(insights): mother page hosts overview only; metric depth moves to sub-pages
After W4a scaffolded the routed sub-pages and lifted the sticky tab
strip into src/app/insights/layout.tsx, the mother page becomes the
overview-only surface it always wanted to be: Hero + DailyBriefing +
CorrelationRow + TrendsRow + InsightAdvisorCard + CoachDrawer.

What's gone from the page (now lives on the matching sub-page):
  - Six per-section <HealthChart> / <MoodChart> mounts (bp/weight/
    pulse/mood/meds/bmi) with their target-zone + value-band wiring.
  - Per-metric status-card queries (blood-pressure-status,
    weight-status, pulse-status, bmi-status, mood-status,
    medication-compliance-status).
  - All the per-section <InsightStatusCard> + scatter-correlation
    cards (BP × continuity, weight × BP, mood × BP, mood × weight).
  - The bulky helper functions (getBloodPressureSectionStatus,
    getWeightSectionStatus, …, classifyRangeValue) and their imports
    (getBpTargets, buildWeightBandsFromHeight, pulse-targets, etc.).
  - The inline <InsightsSectionNav> mount — superseded by the strip
    that <InsightsLayoutShell> now mounts.

What stays here:
  - HeroStrip (greeting, weekly-report banner, Health Score panel).
  - DailyBriefing (the AI morning paragraph).
  - CorrelationRow (cross-metric hypotheses).
  - TrendsRow (BP / weight / mood mini-charts above each annotation).
  - InsightAdvisorCard (the rich recommendations grid).
  - CoachDrawer — mother-page-only, per Marc's directive.

The two insights-polish guard tests are re-pointed at the new homes
of their assertions: the tab-strip refresh affordance now lives on
the layout shell (src/components/insights/insights-layout-shell.tsx),
and the comparisonBaseline plumbing migrated into the shared
useInsightsLayoutPrefs() hook.

trends-row.test.tsx wraps every render in a QueryClientProvider —
the v1.4.25 W7 timezone change added a useAuth() call inside
<TrendsRow> for chart-side userTimezone plumbing, which needs a
TanStack-Query client at mount time.
2026-05-14 13:07:33 +02:00
Marc-André Bombeck cb825ec67a feat(insights): new /insights/schlaf sub-page with sleep-stage chart
The v1.4.23 schema added the SleepStage enum + Measurement.sleepStage
column for HealthKit per-stage rows, but the UI never surfaced them.
This commit adds the missing surface.

Components:
  - <SleepDurationChart>: thin wrapper around <HealthChart> with
    chartKey="sleep" so the per-chart cog (trend / target / compare)
    works the same way as the dashboard charts.
  - <SleepStageStackedBar>: horizontal Recharts stacked bar covering
    the trailing-30-day stage distribution (DEEP / REM / CORE /
    ASLEEP / AWAKE / IN_BED). Uses the /api/analytics sleepStages
    payload that v1.4.23 W2 already exposes; no new endpoint.
  - <SleepOverview>: composition that arranges the headline
    "X h Y min per night" card, the stage-composition stacked bar,
    and the duration trend chart. Empty state when the user has zero
    sleep rows points at /settings/devices with a v1.5 Apple Health
    sync teaser.

The sub-page itself (src/app/insights/schlaf/page.tsx) is trivial —
<SubPageShell> + <SleepOverview>. All data-fetching + empty-state
handling stays inside the components so a future v1.4.26 refinement
can iterate without touching the route.

Tests: <SleepStageStackedBar> covers the i18n titles (EN + DE), the
ARIA label that announces the nights count, and resilience against
unknown stage keys.
2026-05-14 13:07:17 +02:00
Marc-André Bombeck 38bbba110a feat(insights): scaffold 6 metric sub-pages with shared layout shell
Carves the v1.4.24 monolithic /insights page into routed sub-pages
under /insights/{blutdruck,gewicht,puls,stimmung,medikamente,bmi}.
Each sub-page renders its metric chart with the per-chart cog
(chartKey from CHART_OVERLAY_KEYS) and the existing AI status card;
empty states surface a CTA where the chart can't compute (BMI without
height, mood with zero entries, medication adherence without a tracked
medication).

The sticky tab strip moves into src/app/insights/layout.tsx via the
new <InsightsLayoutShell>, which also owns the advisor query so the
regenerate affordance stays reachable from every Insights surface.
The tab strip itself is now route-aware (<Link> + usePathname()) and
exposes an "Overview" pill for the mother page. The CoachDrawer stays
in the mother-page body only — per Marc's directive, navigating to a
sub-page must unmount the drawer.

Shared infrastructure:
  - src/lib/insights/sub-page-metric.ts: slug → MeasurementType[] map
    + SUB_PAGE_SLUGS canonical order.
  - src/hooks/use-insights-layout-prefs.ts: reader for the dashboard-
    layout payload so every sub-page picks up the comparisonBaseline
    without re-fetching.
  - src/components/insights/sub-page-shell.tsx: title + optional badge
    + focusable h1 (a11y — screen-reader focus restoration on tab
    navigation).
  - src/components/insights/insights-layout-shell.tsx: the layout's
    client shell that mounts the strip + threads the regenerate handler.

The 7th sub-page (/insights/schlaf) ships in the follow-up W4c commit
together with its sleep-stage chart components.
2026-05-14 13:06:49 +02:00
Marc-André Bombeck eeaa563026 feat(charts): x-axis ticks render in user displayTimezone
Four chart components (HealthChart, MoodChart, MedicationComplianceChart,
ComplianceLineChart) now accept a userTimezone prop, default
"Europe/Berlin" for backwards compat. Inside each chart, the X-axis
tick formatter, tooltip date label, and per-day bucketing all run
through a tz-aware makeFormatters(locale, userTz) instance instead of
the legacy formatDateShort() which pinned to Europe/Berlin.

Mount sites that already pull useAuth() pass user?.timezone:
dashboard /page.tsx, trends-row, recommendation-card,
insight-advisor-card. Ad-hoc usages without a user (storybook-style
or test harnesses) keep the default Europe/Berlin rendering
bit-for-bit.

The HealthChart's daily-aggregation key (toDayKey) was also rewritten
to depend on the userTimezone prop so a 23:50 Pacific/Auckland reading
buckets to the Auckland day instead of the Berlin day for chart data
display. The TanStack Query cache key gains userTimezone so a tz
change inside a session re-fetches and re-buckets correctly.

Tests: 109 chart-component tests stay green.
2026-05-14 13:04:12 +02:00
Marc-André Bombeck 095578fa57 refactor(admin): drop redundant collapse on single-purpose Login-Übersicht page
`/admin/login-overview` is the only card on its dedicated route since
v1.5 phase-4b. The Collapse/Expand toggle inherited from the v1.4
shared-admin page therefore hid the entire page contents on click,
serving no purpose beyond clutter. Drop the `expanded` state, the
header toggle button, and the `expanded && (…)` body wrapper; the
section always renders. The two queries lose their `enabled:
expanded` gate (a no-op now that they always run). The
`settings.collapse` / `settings.expand` i18n keys stay so future
surfaces can opt back in without re-introducing the strings.

A regression test mirrors the `api-token-no-collapse` pattern: no
"Collapse"/"Expand"/"Einklappen"/"Ausklappen" buttons, no
`aria-expanded` on the section heading, and the filter pills paint
unconditionally in SSR.
2026-05-14 12:57:38 +02:00
Marc-André Bombeck d87a6314e6 fix(admin): tidy CSV export — column order + escaping + ISO-with-offset
The legacy export emitted snake_case English headers and raw UTC
timestamps which Excel/LibreOffice misread once the `Z` suffix was
stripped (issue #167 territory) and forced admins to mentally map
`actor_username` → "Marc" every time they triaged a row. The export
now mirrors the on-screen column order — `timestamp → user → IP →
location → provider → outcome` — with `action` + `details`
appended so the full audit payload still rides along. Headers are
translated through the active locale; timestamps go through
`formatInUserTz(..., "iso-with-offset")` so they round-trip
correctly in any spreadsheet; `toCSV` now treats `\r` as an escape
trigger alongside `\n,"` so Windows-newline detail strings stay on
their row.

`buildAuditLogCsvRecords` and `auditLogCsvHeaderLabels` live in
`_shared.tsx` so the column-order/escape contract is pinned by a
pure unit test independent of the React render.
2026-05-14 12:54:26 +02:00
Marc-André Bombeck b5062ead1c feat(admin): add Provider column to Login-Übersicht
The audit log records *how* each login happened in the action name
(`auth.login.passkey`, `auth.bearer.success`, …) but the table
collapsed all of that into a single Aktion label. Admins now see
the credential type as a separate column with a lucide glyph
(KeyRound / Fingerprint / Cpu / Globe) so password-vs-passkey-vs-API
token attempts are distinguishable at a glance — and the column
maps cleanly into the CSV export.

The mapping itself (`providerForAction`) lives in `_shared.tsx`
alongside `useAuthActionLabels` so any later audit-feed consumer
picks up the same i18n + icon contract; a regression test pins
every action → provider edge and every provider → icon edge.
2026-05-14 12:49:51 +02:00
Marc-André Bombeck a19768c5d4 feat(insights): plumb stripChartTokens through every AI-prose surface
The v1.4.22 stripper only ran on the InsightAdvisorCard summary,
findings, primaryRecommendation, and the RecommendationCard top-line
rec text. Every other AI-prose surface bypassed it entirely, so the
v1.4.25 W5b regex extension (capitalised Metric + orphan enum
patterns) silently could not help them.

Wire stripChartTokens() through every render path that pipes AI
output to the user:

- DailyBriefing — paragraph, finding.headline, finding.detail
- WeeklyReport — summary, goingWell[], worthWatching[], tips[],
  dataQualityNotes
- TrendAnnotation — annotation sentence
- RecommendationCard — rationale rows (dataWindow, comparedTo,
  deviation) inside the expanded RationaleCard
- InsightsCardPreview — dashboard mini-card rec text
- Coach message-thread — assistant bubble content (streaming +
  persisted). User bubbles stay untouched on purpose: their content
  is user-typed, not AI-emitted.

User bubbles, evidence-block key/value rows (constrained to the
sentinel grammar by the Coach prompt), and the deterministic
server-generated correlation interpretations stay raw — the leak
surface is AI prose, not those structured fields.
2026-05-14 12:47:40 +02:00
Marc-André Bombeck 6d6f4c4efb fix(admin): restore Standort column on Login-Übersicht
The Standort column existed but rendered right-aligned next to a
right-aligned IP and timestamp, which pushed it into the gutter on
wide audit tables and let admins overlook it entirely. Left-align
the IP and Location cells (timestamp keeps the right edge as the
sort anchor) and prefix the Standort header with a MapPin glyph so
the column is a first-class entry instead of a forgotten trailer.
2026-05-14 12:46:59 +02:00
Marc-André Bombeck 5d3a4c0b13 style(settings): widen Doctor-Report export card to full-row on md+
Doctor-report is the most user-facing export — give it its own row above
the 4-card 2×2 grid so it reads as the primary action. Mobile keeps the
single-column stack since every card is already full-width there.
2026-05-14 12:43:17 +02:00
Marc-André Bombeck 8e4a8080dc i18n(targets): add EN + DE strings for redesigned card primitives
Removes the duplicate `targets.mood` + `targets.moodStability` flat
strings that collided with the new `targets.mood.stability.{stable,
variable,highlyVariable}` object added in Phase 3. The flat versions
were unreferenced — the canonical mood label lookup goes through
`targets.label.MOOD_SCORE` / `targets.label.MOOD_STABILITY` — so
removing them is a no-op for the UI and unblocks the
i18n-locale-integrity duplicate-key test.

Also rolls in the prettier-normalised formatting on the targets
route + target-card files (no semantic change). All i18n keys
added across phases 3-8 are documented inline:

  • targets.summary.{weekTitle,weekTitleAll,streakHighlight}
  • targets.card.{lastMet,streak}
  • targets.consistency.{ariaLabel,daysInRange,daysLogged,
                          dotInRange,dotNearRange,dotOutOfRange,
                          dotNotLogged,insufficientData,relativeDay.*}
  • targets.relativeDay.{today,yesterday,daysAgo}
  • targets.coach.cta
  • targets.mood.stability.{stable,variable,highlyVariable}

Per-target Coach prefills (the W3e plan called for
targets.coach.prefill.{bp,weight,…}) are NOT added as i18n strings
— they're generated by `buildTargetPrompt()` in
src/lib/ai/coach/target-prompts.ts which interpolates live state
values. Keeping the templating in JS avoids 12 long-form bilingual
strings with i18n interpolation gymnastics and lets the prompt
include conditional fragments (streak, cadence) without exploding
the key count.

Verification:
  - pnpm typecheck: clean
  - pnpm test: 2325 / 2325 passing
  - pnpm lint: clean
  - i18n-locale-integrity: 9 / 9 passing (duplicate keys removed)
2026-05-14 12:43:02 +02:00
Marc-André Bombeck f223b4feae feat(targets): mobile-first responsive grid + card reflow
Per Marc's "mobile is co-equal with desktop" directive, every
breakpoint gets a deliberate layout — no afterthought reflow:

  • default (<640px): grid-cols-1, full-width cards, gap-4. The
    per-card Coach CTA spans full width (justify-center) so the
    touch target is generous; the source link sits beneath
    right-aligned.
  • sm (640-1023px): grid-cols-2, gap-4. Card footer reverts to
    horizontal — Coach CTA left, source link right.
  • lg (1024px+): grid-cols-3, gap-6. Three cards per row matches
    the dashboard / insights rhythm and lets the user see all six
    core targets above the fold on a 1440px viewport.

Card internals also reflow:
  • Headline row: icon + label stack with status pill below on
    mobile (sm:flex-row sm:justify-between), inline on sm+.
  • Footer row: flex-col items-stretch on mobile,
    sm:flex-row sm:items-center sm:justify-between on sm+.

New `targets-responsive.test.tsx` pins the breakpoint classes so a
future refactor can't quietly collapse the grid back to v1.4.22's
sm:grid-cols-2 only layout.

Verified by inspection (Tailwind class audit). A Playwright sweep
across 375 / 768 / 1280 / 1440 viewports is queued for the W3e
verification phase.
2026-05-14 12:36:21 +02:00
Marc-André Bombeck 9acee2bd81 feat(targets): MOOD_STABILITY headline switches from σ to verbal label
The card used to render the raw σ value ("0.42 σ") as the big headline
number. Most users cannot interpret a standard deviation; the W3e
research found this was the single biggest "what does this mean?"
friction point in the v1.4.22 page.

Replace with a three-state verbal label keyed by the
`moodStabilityLabel(sigma)` helper landed in the previous phase
(thresholds: < 1.0 stable, < 2.0 variable, ≥ 2.0 highly variable).
Translations under `targets.mood.stability.{stable,variable,highlyVariable}`
in EN + DE.

The raw σ value stays one hover away — the headline carries a
tooltip surfacing "σ = 0.42" so power users keep the precise number
without forcing every visitor to decode it. Headline also gets a
`capitalize` class so the lowercase i18n string ("stable" / "stabil")
renders sentence-cased.
2026-05-14 12:34:47 +02:00
Marc-André Bombeck f2b9f843b1 feat(targets): card layout with status pill + consistency + coach handoff
Replaces the inline 790-line page.tsx composition with the W3e
primitives extracted in the previous phase. The page is now ~270
lines of orchestration:

- TanStack-Query pulls the redesigned targets payload (with the
  v1.4.25 consistency + summary additions).
- A second query reads /api/insights/provider-chain to derive
  `aiEnabled = chainStatus?.activeProvider != null`. The flag threads
  down into every <TargetCard>, which uses it to gate the per-card
  Coach CTA — no broken-button state when the user has no provider
  configured.
- A new `useCoachHandoff()` hook (src/hooks/use-coach-handoff.ts)
  owns the drawer's open + prefill + scope triple. Per-card buttons
  call `askCoach()`; the hook flips state and the drawer mounts
  inline at the page level.
- Cards now render in a fixed order (BP / BP-in-target / WEIGHT /
  PULSE / BMI / MOOD / COMPLIANCE / SLEEP / BODY_FAT / STEPS) — Marc
  directive: stable visual hierarchy beats status-sorting because
  the user's eye lands on the same metric in the same place every
  visit.
- Source-citation URLs stay on the page (declarative routing) and
  thread into <TargetCard> via the `sourceLink` prop.

The old inline TargetCard / RangeBar / TrendIcon / status-category
map are deleted along with the dead `Badge` + lucide imports.
Existing targets-i18n + targets-spacing tests updated to mock the
new useQueryClient + useMutation surfaces that the embedded
<CoachDrawer> needs at SSR; both suites still assert the same
visual contracts and pass.

Wave 2 deliverables also folded into this commit:
- pageSummary mounts via the W3e header above the grid
- consistency strip renders only when insufficientData === false
- recency caption ("last met goal: 2 days ago") renders only when
  lastMetGoalAt !== null
- streak chip ("4-day streak") renders only when streakDays ≥ 3
2026-05-14 12:33:53 +02:00
Marc-André Bombeck 96c5e79539 feat(targets): page-level summary header
Adds a one-line summary above the card grid that surfaces the
account-level state at a glance:

  "4 of 6 targets met this week"  /  "All 6 targets met this week ✓"

When every target hits the bar we surface a calm green check mark; no
animation, no celebration. Per Marc's "muss nicht so sein wie die KI
Sache" — the summary stays scannable and quieter than the v1.4.20
Insights hero strip.

A streak highlight chip ("5-day streak: Blood pressure") renders to
the right when one metric carries a current ≥ 3 day streak. Resolves
via the existing targets.label.{TYPE} i18n map so the metric name
respects the user's locale.

The new <TargetsSummaryHeader> primitive lives under
src/components/targets/ alongside the rest of the W3e extraction.
4 tests cover partial / all-met / streak / empty branches.
2026-05-14 12:30:18 +02:00
Marc-André Bombeck 97583cf891 refactor(targets): extract TargetCard + RangeBar + ConsistencyStrip + TargetCoachButton
The /targets page composed everything inline in a 790-line page.tsx.
Break it into focused primitives under src/components/targets/ so the
W3e redesign can iterate per-card without growing the page file:

- target-card.tsx — the redesigned card composition. Headline + verbal
  status pill, big-number row with inline trend chevron, range bar,
  consistency strip, recency + streak chips (both conditional on real
  data), per-card Coach CTA gated by aiEnabled, source link footer.
  MOOD_STABILITY swaps the raw σ headline for a verbal label via the
  moodStabilityLabel helper. Fixed card order is owned by the page;
  this component is presentational.
- range-bar.tsx — extracted intact from page.tsx. Same behaviour, same
  data-slot. Now carries its own test suite.
- consistency-strip.tsx — NEW. 7 dots (one per Berlin-tz day), each
  filled / amber / red / hollow per the day's band classification.
  Caption flips between "X of 7 in range" and "X of 7 logged" when
  cadence is the more informative signal. Accessible: <ul role="list">
  with per-dot aria-labels seeded from i18n. No empty-state copy
  inside the strip — Marc directive: hide entirely when insufficient.
- target-coach-button.tsx — NEW. Per-card "Ask Coach about this" CTA
  that returns null when aiEnabled is false. No greyed-out button,
  no tooltip placeholder — the gate is total per Marc's
  "no broken-button state" directive.

Supporting helpers (each unit-tested):
- src/lib/targets/mood-stability-label.ts — pure σ → verbal-label
  threshold logic (< 1.0 stable, < 2.0 variable, ≥ 2.0
  highlyVariable). Thresholds documented in the helper and confirmed
  in the W3e plan.
- src/lib/ai/coach/target-prompts.ts — 8 metric × 2 locale templates
  for the per-card Coach prefill. Falls back to a general template for
  glucose contexts. PROMPT_VERSION unchanged (the assistant-facing
  prompt does not shift; only user-side prefills do).
- src/lib/ai/coach/target-scope.ts — per-target CoachScope.sources
  mapping. Derived metrics (BMI → weight, MOOD_STABILITY → mood) point
  at the underlying source; unmapped targets return an empty array
  so the drawer falls back to defaults.

i18n keys added: targets.consistency.* (cap + dot aria + relative day
+ insufficient-data), targets.card.{lastMet,streak}, targets.relativeDay.*,
targets.summary.{weekTitle,weekTitleAll,streakHighlight},
targets.coach.cta, targets.mood.stability.* — all EN + DE.

Tests: 17 cases across 5 suites (consistency-strip render variants,
target-coach-button gate branches, range-bar zone painting,
target-card full-data + insufficient-data + aiEnabled-off +
MOOD_STABILITY verbal-headline, mood-stability-label thresholds).

Page.tsx still uses the old inline composition for now — Phase 5
wires it to the new components.
2026-05-14 12:29:02 +02:00
Marc-André Bombeck 80544b73cb style: prettier on v1.4.25 W3 files
`pnpm format:check` flagged two files touched in the previous W3 +
W3b commits — `src/components/insights/insights-tab-strip.tsx` (the
new tab strip) and `src/lib/charts/x-axis-density.ts` (the rebuilt
tick-density helper). Prettier normalised the Tailwind class-name
ordering and the multi-line function signature; no behaviour change.
2026-05-14 12:07:23 +02:00
Marc-André Bombeck 26568c18a8 feat(admin): server-default timezone setting for new signups
v1.4.25 W7 phase 5 — proposal §5 admin half.

GET /api/admin/settings now returns defaultUserTimezone (null = use
Europe/Berlin fallback). PUT accepts the field:
  - empty string clears the column (resolver falls back)
  - non-empty must pass isValidTimezone(); otherwise 422
  - successful writes invalidate the server-default resolver cache
    so the registration handler sees the new value on the next
    signup without waiting 60s for the TTL

The admin form gets a third row in the General Settings section
between 'default language' and the toggles, mirroring the user
picker UX (native select over Intl.supportedValuesOf, fallback
'Europe/Berlin (default)' option clears the override). 3 new i18n
keys per locale (defaultUserTimezone +
defaultUserTimezoneDescription + defaultUserTimezoneFallback);
descriptions follow proposal §5: 'Used for new accounts that don't
carry a browser-detected zone. Does not affect existing users.'

Tests: 3 new cases in src/app/api/admin/settings/__tests__/route.test.ts
covering valid IANA write, empty-string clear, and invalid-zone
rejection.
2026-05-14 12:05:46 +02:00
Marc-André Bombeck 65ce9fd4c8 feat(profile): timezone picker on account settings page
v1.4.25 W7 phase 4 — proposal §5.

Adds a per-user timezone picker to /settings#account between
date-of-birth and language. The control is a native <select> over
Intl.supportedValuesOf('timeZone') with a 'use browser timezone'
button that pre-fills detectBrowserTimezone(). Older engines without
Intl.supportedValuesOf fall back to a free-text input — every Chrome
99+/Safari 15.4+/Firefox 99+ ships the API so the fallback is
defensive only.

Form save uses Promise.all over two PUTs:
  - /api/auth/profile (email, height, dob, gender)
  - /api/auth/me/timezone (single field, dedicated route)
The split keeps the timezone validation + resolver cache
invalidation on a clean path; profile errors don't poison the
timezone write and vice versa.

The new route PUT /api/auth/me/timezone:
  - requireAuth gates the cookie + Bearer paths
  - 422 on any string the runtime's Intl.DateTimeFormat rejects
  - 200 on success, returns the persisted value
  - audits 'user.timezone.update' with previous + next
  - invalidates the resolver cache so the next read sees the new
    value within one tick instead of waiting 60s for the TTL

i18n: 4 new keys (timezone, timezoneHint, timezoneDetect,
timezoneInvalid) in messages/de.json + messages/en.json. Hint copy
follows proposal §5: 'Used for chart axis labels, reminder times,
export timestamps, and AI Coach context. Stored data is unchanged.'

Tests: 6-case suite on the new route covers auth, valid + invalid
zones, empty / missing field, and whitespace trimming.
2026-05-14 12:01:58 +02:00
Marc-André Bombeck 75cf0b60dc style(insights): widen health-score card so label list fits the ranking dot
Marc reported the German "Einnahmetreue" component label visibly
overlapping the band-coloured component-value pill at the right of
the row on `lg+` viewports. The card's `lg:w-[220px]` width left only
~64px for the label column after the progress bar (`flex-1`) and the
right-side value pill (`w-8`) consumed the rest — fine for short
labels like "BP" / "Mood" but cropped for the longer German strings.

Two coordinated changes:
  - Card width: `lg:w-[220px]` → `lg:w-[260px]` (~18 % wider on `lg+`).
  - Label column: `w-16` (64px) → `w-24` (96px) with a `truncate`
    fallback for any future localisation that pushes the label past
    96px.

Net result: the label column has ~50 % more pixel budget than before,
which puts "Einnahmetreue" comfortably inside its slot. The card's
internal grid (label / bar / value) preserves the same gap rhythm so
the visual proportions stay familiar.
2026-05-14 12:01:32 +02:00
Marc-André Bombeck 60541803f2 style(insights): trends-row equal-height + mood-chart gridline density parity
Two coupled fixes pinned by Marc on the v1.4.25 W3 walkthrough:

1. Trends row equal-height
   The three trend cards (BP / Weight / Mood) on /insights sit in a
   `grid-cols-1 md:grid-cols-3` grid. The `min-h-[300px]` floor
   guarantees a sensible mobile height, but the AI annotation prose
   varies card-to-card on desktop, leaving the tallest card pushing
   only its own row member down — siblings stayed short. Added
   `md:auto-rows-fr md:items-stretch` on the grid container and
   `h-full` on each direct child so the row pins every card to the
   tallest member's height.

2. Mood chart gridline density parity
   The mood chart's YAxis pins ticks to [1,2,3,4,5] (mood scale). By
   default Recharts syncs CartesianGrid coordinates with YAxis ticks,
   so the mini variant painted five horizontal lines while the
   BP/Weight/Pulse minis painted six (their auto-generated YAxis
   ticks land on ~six bands in typical health ranges). Added a
   `horizontalCoordinatesGenerator` that emits six evenly-spaced
   lines inside the plot area so the trends row reads as a single
   rhythm.

Both changes are presentational — no chart data shape changes, no
behaviour changes, no API touched.
2026-05-14 12:00:45 +02:00
Marc-André Bombeck 72e64c816d feat(insights): extract tab strip + relocate regenerate button to top-right
Marc directive 2026-05-14: the page-level regenerate affordance has to
stay reachable while the user is reading the per-metric cards. Up to
v1.4.24 it lived inside the hero band's action row, which scrolled
away as soon as the user moved past the briefing.

What changed:
  - New `src/components/insights/insights-tab-strip.tsx` carries the
    six metric pills + the regenerate icon button. The strip is
    `sticky top-0` with `bg-background/95 backdrop-blur` so it lives
    above every section as the user scrolls.
  - Right slot: a 44×44 icon-only `<RefreshCw>` button with
    `aria-label={t("insights.regenerateAnalysis")}` and a sonner
    success toast keyed to the falling edge of `regenerating`
    (rising-edge guard via a ref so the toast only fires when an
    in-flight regen finishes, not on every render).
  - The hero strip's `onRegenerate` / `regenerating` props are gone;
    its action row is now Weekly-report + Ask-the-Coach only. The
    JSDoc comment block points to the new tab strip for the
    regenerate affordance.
  - `src/app/insights/page.tsx` mounts `<InsightsTabStrip
    onRegenerate={advisor.regenerate} regenerating={...} />` and
    drops the inline `InsightsSectionNav` function (its logic moved
    into the new component verbatim).
  - Two new translation keys added under the `insights.*` namespace:
    `regenerateAnalysis` ("Analyse neu starten" / "Re-run analysis")
    drives the aria-label + title; `regenerateSuccess` ("Analyse
    wurde neu erstellt" / "Analysis refreshed") drives the toast.

The existing hero-strip test that pinned the regenerate slot was
flipped — the slot must NOT render under any prop combination. The
insights-polish test that asserted `<HeroStrip onRegenerate={advisor.regenerate}>`
was rewritten to assert the wiring is on `<InsightsTabStrip>` and that
the `<HeroStrip>` JSX block carries no `onRegenerate` prop.
2026-05-14 11:56:59 +02:00
Marc-André Bombeck ca03c6099f chore(release): v1.4.24
Security + accessibility hardening release. Closes the highest-priority
findings from a focused security audit of the v1.4.23 release:

- Bearer auth fail-closed when a route declares no scope (api-handler)
- Public /api/send Umami proxy now rate-limits 120 req/min/IP
- Proxy nonce generation switched to btoa(...) for Edge portability
- Session-expiry delete race uses deleteMany + catch (no more 500s)
- POST /api/import returns the standard apiError envelope on 422
- Runtime Docker image pins Prisma 7.8 + worker deps to minors
- Notifications page renders a real load-error alert
- Accessibility: switch aria-labels, htmlFor pairing, mobile admin
  Overview link, deep-link anchors on notification settings
- Chart overlays: per-chart comparisonBaseline pref, BMI/bodyFat/
  sleep/steps overlay keys, TrendCard layout tolerates long labels

Verification: pnpm typecheck && pnpm lint && pnpm test (2244/2244)
&& pnpm format:check all green.
2026-05-12 22:33:35 +02:00
Marc-André BombeckandClaude Opus 4.7 f1e6630958 chore(format): prettier sweep across develop
Mostly table column normalisation under docs/audit/ + planning, object-
literal expansion in src/lib/ai/coach/snapshot.ts after the W4 F6 Apple
Health metric block landed, and small wrap/unwrap line adjustments in
the device + APNs routes / tests touched across W3/W4/W5 sessions.
No behavioural change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:27:51 +02:00
Marc-André BombeckandClaude Opus 4.7 dfffe6bb3b refactor: simplify batch (S-02 to S-04)
Three small reuse wins from the W6 simplify review:

- openapi/registry.ts swaps the lazy require() for a static import.
  The ESLint disable + as-cast carried no real benefit; the only
  callers always loaded the route table anyway.
- Extract useCoachPrefs() into a shared hook so the settings sheet
  and message thread share one queryKey + one fallback shape. Removes
  the drift between the two existing inline blocks.
- Replace the 9-deep wantsX boolean ladder in coach snapshot with a
  single CoachScopeSource -> MeasurementType[] map. Drives both the
  WHERE type IN (...) build and the appleHealthBlocks gate, so adding
  a new metric is one entry instead of three parallel edits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:23:43 +02:00
Marc-André BombeckandClaude Opus 4.7 3ee6dab6fa fix(coach): skeleton + save toast on prefs sheet
Render a skeleton shell while the persisted Coach prefs are loading
so the form never paints DEFAULT_COACH_PREFS as a ghost between mount
and the query resolving. Emit a sonner toast on save success before
closing the sheet so the action is visibly acknowledged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:23:22 +02:00
Marc-André BombeckandClaude Opus 4.7 a2dfc5e256 fix(measurements): show source badge on mobile card too
The Apple Health pink chip rendered only inside the desktop `<Table>`
cell. iOS users — the audience that actually consumes Apple Health
data — land on the mobile card branch and saw no source indicator at
all, risking duplicate hand-entries on top of synced rows. Add a
compact badge inline with the timestamp on the mobile card so the
distinction lands at every viewport. Component test asserts the
`measurement-source-badge` testid appears twice (desktop + mobile)
so the next layout refactor can't silently drop one branch.

Also extends the `POST /api/devices` unit test mocks for the
NotificationChannel auto-upsert added in the previous commit, plus a
new test that pins the same-user apnsToken collision path returning
201 idempotently (the cross-user path keeps its 409).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:16:18 +02:00
Marc-André BombeckandClaude Opus 4.7 54865075a5 fix(admin): surface coach-feedback in sidebar nav
The coach-feedback slug was registered in ADMIN_SECTION_SLUGS and the
page rendered correctly via /admin/[section], but the entry was never
added to the hand-maintained ADMIN_SECTIONS array. Operators could only
reach the view by typing the URL directly. Add the entry next to
ai-quality so the two AI-quality views sit together, and add a drift
guard test that walks ADMIN_SECTION_SLUGS and asserts every slug has a
matching nav entry. Same drift class would recur on the next admin
section addition without the guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:09:26 +02:00
Marc-André BombeckandClaude Opus 4.7 05c7f1474d feat(coach): per-message thumbs feedback + admin aggregate view
The v1.4.22 prose rewrite shipped without a way for users to signal
whether the new tone is landing. v1.4.23 H7 closes that loop with a
light-touch thumbs row + an admin first-week dashboard.

Schema: extend `RecommendationFeedback` with a polymorphic
`target_type` column (default "recommendation" so legacy rows stay
representative) plus a nullable `reason` field. New index on
(target_type, created_at) so the aggregator can scan per-surface
buckets without a full sweep. Migration `0040_recommendation_feedback_target_type`.

Route: POST /api/insights/chat/messages/:id/feedback. Body
{ rating: "helpful" | "unhelpful", reason?: string }. Verifies
ownership (404 on cross-account messages — no existence leak),
decrypts the message body for the dedup key, and writes the row with
target_type="coach" + a metric-source string encoding the user's
active prefs (`coach:tone=warm:verbosity=default`). Per-user 60/h rate
limit. 409 on a P2002 (already_rated) so a double-click is a no-op.

Aggregator: new `buildCoachFeedbackBuckets()` slices Coach rows by
(promptVersion, tone, verbosity). The summary blob the worker writes
to AppSettings.adminAiInsightsFeedbackSummary now carries both
`buckets` (pre-existing) and `coachBuckets` (H7 addition). Targets
the same singleton row so no second worker handler is needed.

Admin view: `/admin/coach-feedback` (new section slug) renders the
Coach buckets as a small DataTable (prompt version × tone × verbosity
× helpful × not-helpful × n × helpful-rate). Helpful-rate column
tinted via Dracula tokens (green ≥80%, yellow ≥50%, orange below).
First useful question this answers: "Is the v1.4.22 prose rewrite
landing well, or did the warm tone overshoot?" If the helpful-rate
for PROMPT_VERSION 4.23.x drops below 50% within the first 100
ratings, v1.4.24 walks the persona back.

UI: `<ThumbsUp>` / `<ThumbsDown>` buttons (lucide icons, no emoji)
under each persisted assistant bubble. Streaming bubbles + refusals
+ error bubbles skip the row so users can't rate before the message
lands on disk. Localised "Thanks for the signal." after submit.

OpenAPI: spec carries the new POST route + the request schema.
Coverage now includes the H4 + H7 routes — three additions over
v1.4.22 baseline.

Tests +9 unit (aggregator Coach-bucket slicing, admin section
render-paths) plus an integration round-trip for the route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:51:27 +02:00
Marc-André BombeckandClaude Opus 4.7 3f60c81b9e feat(coach): per-user prompt-tuning surface (settings cog returns)
The v1.4.22 B5 audit removed the placeholder cog from the Coach
drawer header because it had no real wiring. v1.4.23 H4 returns
the cog with an actual settings surface backed by a per-user
preferences row.

Schema: nullable JSONB column `users.coach_prefs_json` (migration
0038_coach_prefs). Null = legacy v1.4.22 defaults; non-null carries
`{ tone, verbosity, excludeMetrics, showEvidenceByDefault }` validated
by the Zod schema in `src/lib/validations/coach-prefs.ts`.

Routes: GET + PUT `/api/auth/me/coach-prefs`. PUT validates the body,
persists the canonical defaulted form, and emits an
`auth.me.coach-prefs.put` annotation with the four-knob meta. OpenAPI
registry covers both verbs; the spec regenerates without drift.

Coach prompt builder: `getCoachSystemPrompt(locale, prefs)` prepends a
per-user OVERRIDE paragraph for non-default tone (neutral / concise)
and verbosity (brief / detailed). Concise tone forces brief verbosity
regardless of the explicit selection — concise == short. Default
prefs leave the prompt unchanged so the v1.4.22 train stays in place.

Snapshot builder: reads the prefs row BEFORE any measurement query
and intersects `excludeMetrics` with the resolved scope. The model
never sees data the user opted out of — that's the load-bearing
order: filter first, query second.

UI: settings cog in the drawer header opens a right-edge `<Sheet>`
with three Select pickers (tone, verbosity) and one Switch column
(per-metric exclusions + showEvidenceByDefault). Save calls the PUT
route and invalidates the `["coach-prefs"]` query so the next reply
honours the new defaults without a reload. EN + DE i18n keys for
every label and hint.

Message-thread reads the same `["coach-prefs"]` cache and toggles the
`open` attribute on the evidence `<details>` element when
`showEvidenceByDefault` is true.

Tests +24 unit (coach-prefs schema, system-prompt prefix, snapshot
mock for new prefs row). Integration round-trip seeds a session,
PUTs a non-default prefs shape, asserts the GET reflects the saved
values + the DB row carries the canonical defaulted form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:35:45 +02:00
Marc-André BombeckandClaude Opus 4.7 9413d29303 refactor(coach): drawer prefill becomes a controlled prop
The v1.4.20 implementation reset the composer by mounting the
`<CoachDrawer>` with `key={prefill}`. That weaponised React's
key-reset to flush internal state every prefill change — Sr-HIGH-4
flagged it as a smell because the next refactor that touches the
drawer's mount point silently loses the reset semantics.

Replace with the controlled-prop pattern React documents at
https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes:
new `useResettableValue` hook reads the controlled prefill, mirrors
it via a sibling useState pair, and seeds the composer with the new
value when the prop changes. Render-phase setState — ESLint-clean
against `react-hooks/set-state-in-effect`. The user can still type
freely; the drawer only resets when the parent changes which
suggested-prompt chip is active.

`nextResettableValue` exposes the comparison contract as a pure
helper so the unit test can pin the (Object.is) semantics without
standing up a React renderer.

The `<CoachDrawer key={…}>` is gone from the insights page mount.

Tests +5: pure-function coverage of unchanged value (no reset),
prefill change (reset), null↔empty transition (reset), suggestion
chip → cleared prefill (reset), and NaN-to-NaN edge case (no reset
under Object.is).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:24:56 +02:00
Marc-André BombeckandClaude Opus 4.7 f2bc4f478a feat(coach): Apple Health metric slots + PROMPT_VERSION 4.23.0
Extends the strict insight schema, Coach snapshot pipeline, and
provenance envelope with the seven Apple Health categories that landed
in Wave 2 (HRV, sleep, resting HR, steps, active energy, flights,
distance, VO2 max, body temperature). All fields are additive and
optional — web-only accounts pay zero extra SQL and the prompt's new
GROUND RULE 12 instructs the model to stay silent when the snapshot
doesn't carry the HealthKit blocks.

Ratchets PROMPT_VERSION 4.22.0 → 4.23.0 and refreshes the
sourceMetric / trendAnnotations enums in both EN + DE. Two new test
files pin the prompt body and the snapshot fan-out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:07:03 +02:00
Marc-André BombeckandClaude Opus 4.7 86a80c5280 feat(ui): Apple Health source badge + restore-route enum sync
The measurements list now paints `APPLE_HEALTH` rows with a Dracula-
pink chip whose accent matches the iOS app's brand. The localised
label ships in both English and German via the new
`measurements.sourceAppleHealth` key; the helper falls back to the
SCREAMING_SNAKE value for unknown sources so a missing key would
surface immediately instead of silently rendering blank.

Admin backup restore guards (`MEASUREMENT_TYPES`,
`MEASUREMENT_SOURCES`) gain the seven new HealthKit-shaped types and
the new source value so a restore from a backup that contains Apple
Health rows doesn't get rejected as malformed.

A new badge regression test mirrors the existing BP-badge guard:
asserts the localised label paints, the Dracula-pink utility classes
land, and the bilingual locale still renders the same brand spelling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:22:58 +02:00
Marc-André BombeckandClaude Opus 4.7 95209ebf79 feat(schema): Apple Health measurement types + sleep stages + dedup index
Extend MeasurementType with the seven HealthKit-shaped values the iOS
batch ingest needs (HRV, resting HR, active energy, flights climbed,
distance, VO2 max, body temperature) and add APPLE_HEALTH to the
MeasurementSource enum. Sleep duration unit shifts from hours to
minutes so per-stage HKCategorySample rows persist without precision
loss; the new SleepStage enum mirrors the existing GlucoseContext
column shape and a CHECK constraint scopes it to SLEEP_DURATION rows.

The composite unique index `(user_id, type, source, external_id)`
gives the new batch endpoint an idempotent dedup key while the legacy
`(user_id, type, measured_at, source)` index continues to back the
manual UI's no-duplicate-at-the-same-wall-clock guard. NULL externalId
rows stay distinct under Postgres semantics so manual entries never
collide.

Migration is strictly additive: new enum values append, new columns
are nullable, no rows mutate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:15:56 +02:00
Marc-André BombeckandClaude Opus 4.7 bbdc3fbd00 style: prettier on W5 reconcile touchpoints
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:30:53 +02:00
Marc-André BombeckandClaude Opus 4.7 6c0c69cbce fix: MED-cluster — SameSite=Strict, exact-match PUBLIC_PATHS, Berlin tz, streaming race
Six MEDs grouped per the W5 reconcile brief:

- Sec-MED-1: `hl_onboarding` cookie now `SameSite=Strict` (no
  cross-site flow depends on it; sibling `healthlog_session` stays
  `Lax` for the Withings OAuth callback).

- Sec-MED-2: `/onboarding` PUBLIC_PATHS match tightened from loose
  `startsWith("/onboarding")` to exact path + subroute guard
  (`pathname === "/onboarding" || pathname.startsWith("/onboarding/")`).
  A future `/onboarding-export` route can no longer inherit the
  silent-pass-through. Same guard mirrored on the redirect short-
  circuit. Two new proxy tests pin the contract.

- Code-MED-3: lift `berlinDayKey()` from
  `src/app/api/analytics/route.ts` to a shared
  `src/lib/analytics/berlin-day.ts` and route the targets
  sparkline-bucketing through it. The targets route used to bucket
  by UTC `YYYY-MM-DD` so a 23:30-Berlin reading on Tuesday landed
  in Wednesday's bucket. Four new unit tests pin DST + UTC-midnight
  edge cases.

- Code-MED (streaming race): on slow connections SSE `done` fires
  before the invalidate-refetch resolves, producing a 200-500ms
  duplicate-bubble render. Add a 150ms grace window on the
  message-thread that suppresses the persisted twin while the
  streaming bubble is still in flight.

- Code-MED-2: drop dead i18n keys `insights.coach.settings` and
  `insights.coach.settingsTooltip` (W3 removed the cog; the keys
  had zero call sites and would carry confusing legacy if v1.4.23's
  prompt-tuning panel picks a different key shape).

- Code-LOW-5: centralise `["user", "dashboardWidgets"]` query key
  in `queryKeys.dashboardWidgets()` and migrate the three call
  sites (dashboard, insights, settings/dashboard-layout-section).
  Same query-key-collision discipline as `queryKeys.analytics()`.

Closes Sec-MED-1, Sec-MED-2, Code-MED-2, Code-MED-3, Code-MED-3
(streaming race), Code-LOW-5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:23:04 +02:00
Marc-André BombeckandClaude Opus 4.7 1631d8042c refactor: simplify-pass apply-yes cleanup
Five independent atomic edits from the W5 simplify review, grouped
in a single commit since none pull weight as a compound theme:

- coach-input: collapse `!disabled && value.trim().length > 0`
  triplication onto a single `canSubmit` (S-01).
- hero-strip: extract the duplicate weekly-report Button shell into
  a shared label fragment with the Link wrapping conditionally
  (S-02).
- api-token-overview-section: extract `<TokenStatusBadge size>` so
  the desktop table and mobile card list stay in lock-step on the
  revoked / expired / active 3-way ternary (S-03).
- trends-row: hoist the dynamic-load chart skeleton to a shared
  `ChartSkeleton` component (S-04). No chart-visual change —
  placeholder only.
- correlation-row: collapse the grid className ternary onto `cn()`
  with a conditional `md:grid-cols-2` (S-05).

Test count unchanged (2105).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:17:29 +02:00
Marc-André BombeckandClaude Opus 4.7 65a8b5a8b6 fix(coach): pin medical disclaimer at bottom of message thread
v1.4.22 B4 moved the disclaimer ("Coach replies are generated.
Clinical decisions belong with your doctor.") into the sources-rail
footer. The rail only paints at `xl+` viewports; on `<xl` (every
laptop and every mobile) the disclaimer was reachable only by
opening the chevron tray. That dropped the medical-disclaimer reach
from "every Coach session" to "the subset of users who tap a
chevron" — material regression for clinical-adjacent UI.

Pin a small persistent line at the bottom of the message thread so
the disclaimer paints regardless of viewport. The rail footer stays
for desktop users; the redundancy is intentional.

Closes Design-H3, Design-M4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:15:27 +02:00
Marc-André BombeckandClaude Opus 4.7 651a529cdd fix(insights): collapse BP tile to single secondary row at <sm
The BD-Zielbereich tile is the only tile that ships an `avgAllTime`
sub-value (W2 A1+A2). Combined with the comparison-delta callout
above it, the tile rendered four stacked rows below the headline on
mobile: the comparison delta, then 7d/30d/all-time on a single
flex row that wrapped to three lines at 280 px (Galaxy Fold). The
BP tile read 2× as dense as every other trend card.

At `<sm`: hide the all-time span on the 7d/30d row, hide the
standalone comparison-delta callout, and render a single combined
secondary row beneath: "All-time 11% · Δ −0.4 last month". Full
layout returns at `>=sm` for users with the screen real-estate.

Closes Design-H2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:14:47 +02:00
Marc-André BombeckandClaude Opus 4.7 363e73cbf2 chore: voice + filename hygiene + wave-4 state wrap
Maintainer-voice sweep across `src/`. ~190 references to the
maintainer by first name in code comments and JSDoc neutralised to
"the maintainer", "the live tenant", "users", or "the user" depending
on context. Three sed passes covering ~50 distinct patterns
(`Marc reported`, `Marc wants`, `Marc kept`, `Marc rolled back`, etc.).
Test fixtures (`displayName: "Marc B."`, `username: "marc"`,
`<HeroStrip userName="Marc">`) kept as opaque test data per the brief.

CHANGELOG normalisation. The v1.4.14 + v1.4.15 entries paired a
German bullet with its English translation by historical design — the
in-app copy is English-default and the user docs are English, so the
mixed-language style was a holdover from the early-marathon notes.
A one-off node script extracted the `_..._` English paragraph from
each bilingual bullet and rewrote the entry as that text. ~44 bullets
reduced; one Docs bullet that lost a leading word in the regex
fixed by hand.

CLAUDE.md → CONTRIBUTING-AI.md rename so the filename is no longer
AI-vendor-specific. `CONTRIBUTING.md` reference updated. `AGENTS.md`
stays in place for multi-agent compatibility (the conventions doc is
shared with Codex / Cursor / etc.).

Wave-4 state wrap. STATE.md now records C1–C5 + D + D2 as complete;
v1421-backlog.md grew a "Closed in v1.4.22" header listing the 20
items the marathon landed; `.planning/phase-W4-report.md` documents
the per-commit decisions, test deltas, and the 23 items deferred to
v1.4.23 with reasoning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:46:32 +02:00
Marc-André BombeckandClaude Opus 4.7 1499e6f83c i18n+ui: backlog quick-wins sweep (F-32/33/34/35/44/45/47/50/58/62 + 2 mediums)
Cluster of the v1.4.21 carry-over items that each landed in ≤5 LOC.

- F-32 — drop the duplicate help paragraph beneath the dashboard
  settings header; the page-level description already covers it
- F-33 / F-34 — replace `&` with "and"/"und" in the measurements and
  mood subtitles
- F-35 — unify the test-notification button label
  (notificationTestSend) with the user-facing notificationStatus.sendTest
- F-44 — drop the `LOKI_ENDPOINT` env-var name from the user-facing
  monitoring help text; reads as "Loki integration" instead
- F-45 — admin/users table column reads "Name" instead of repeating
  the page title "Users"
- F-47 — /notifications event-matrix first column reads "Event" instead
  of repeating the page title "Notifications" — new notifications.eventColumn key
- F-50 — soften every DE error-toast "Bitte erneut versuchen" to
  "Bitte versuche es erneut", matching the friendlier tone we already
  use in the Coach error path
- F-58 — admin/reminders section header swaps Clock for Bell so the
  card icon matches the admin-shell nav tile
- F-62 — DE dashboard `greetingSalutation` reads "Hallo, {name}"
  instead of the English "Hi, {name}"
- D-CR-M-03 — formatTokenName regex broadens to non-Z ISO offsets so a
  `+02:00` timestamp suffix renders as the locale-aware chunk like a
  `Z` suffix already does
- D-DSGN-M-04 — moodLog "Copy" button uses common.copy instead of
  stripping the `!` off common.copied

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:40:05 +02:00
Marc-André BombeckandClaude Opus 4.7 15796c148b fix(auth): move onboarding redirect from AuthShell useEffect to proxy
The post-hydration redirect for users with `onboardingCompletedAt ===
null` caused a brief dashboard flash before the client-side effect
could fire. The redirect now lives in `src/proxy.ts` so it lands on the
first server response.

The proxy runs in the Edge runtime and can't reach Prisma, so the auth
routes (login / passkey-verify / register / password / /api/auth/me)
mirror the DB `onboardingCompletedAt` column into a non-httpOnly
`hl_onboarding` cookie. The cookie is a UX hint, not a security
signal — a user editing it locally only skips the dashboard flash;
they still can't bypass any server check. `/api/onboarding/complete`
clears the cookie so the next navigation drops the redirect
immediately, and `destroySession` + expired-session purge wipe it
alongside the session cookie.

The e2e onboarding-flicker spec can return to testing the actual
incomplete-onboarding case: the proxy reads the cookie (absent for
the fully-onboarded storage-state user), not the mocked `/api/auth/me`
response, so the test now sets `onboardingCompletedAt: null` honestly
instead of pinning it to a non-null value as a workaround.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:29:17 +02:00
Marc-André BombeckandClaude Opus 4.7 1643a78f63 fix(admin): drop whitespace-nowrap on api-tokens date cells (5th attempt)
The /admin/api-tokens page has been painting a residual horizontal
scrollbar for four releases despite column-hide, mobile card-list, and
the admin section-strip `no-scrollbar` fixes. A fresh production probe
confirmed the actual culprit: the "Last used" and "Created" `<td>`s
carry `whitespace-nowrap`, and `formatDateTime` renders strings like
"05.05.2026, 21:46" (~110px) that exceed the 12% colgroup allotment
(~84px on a 700px content area). `whitespace-nowrap` wins over
`table-fixed`'s width contract, so the table's intrinsic width exceeds
100% and the wrapping `overflow-x-auto` paints the scrollbar.

Dropping the two `whitespace-nowrap` classes lets the date+time wrap
to two lines on narrow viewports. The card grows by one row but no
scrollbar — and at >1024px desktop the unwrapped string already fits
in the 12% allotment so the visual is unchanged for the common case.

Picked option 1 of the three W1a probe found (drop nowrap, switch to
formatDateShort, or hide "Created" at md): smallest two-line diff, no
i18n surface impact, no information loss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:22:32 +02:00
Marc-André BombeckandClaude Opus 4.7 b845cf5dae refactor(coach): drawer polish — Gravatar avatar, disclaimer move, cog removal
Three contained UX fixes that line up with the new prose-first
conversation feel:

- User bubble avatar pulls the user's Gravatar at the same 32-px
  dimensions as the Coach avatar; falls back to initials when no
  Gravatar is configured.
- Disclaimer ("Coach replies are generated. Clinical decisions belong
  with your doctor.") moves out of the composer and into the sources
  rail footer, replacing the now-redundant "Coach reads only your
  connected data" caption.
- Settings cog removed from the drawer header. The placeholder
  tooltip read as a dead button and a real settings surface lands in
  a later patch — better to drop the icon cleanly than wire it to
  nothing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:12:26 +02:00
Marc-André BombeckandClaude Opus 4.7 bc87fa3904 feat(coach): collapsible evidence-block disclosure under assistant bubbles
Render the v1.4.22 keyValues array as a click-to-expand "Worauf
bezieht sich das?" / "What I'm looking at" disclosure under each
assistant message. Closed by default; rows show label, value, unit,
and window with Dracula muted-foreground accents and a chevron that
rotates on open. Source chips stay above the disclosure so users see
both abstraction layers, and the whole block hides when keyValues is
empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:08:54 +02:00
Marc-André BombeckandClaude Opus 4.7 59a4a89b62 fix(insights): strip metric tokens in recommendation prose + DE component labels
Two related leak fixes traced to the same root: every other AI-prose
surface ran user-visible text through `stripChartTokens()`, but the
recommendation card's always-visible text was rendered directly. The
model embeds `metric:WEIGHT` and friends in the rec text expecting an
inline mini-chart, but the chart slot lives in the EXPANDED rationale
block — so the token had nowhere to be consumed and leaked verbatim
to the DOM.

  - `<RecommendationCard>` now wraps `norm.text` in
    `stripChartTokens()`, matching the contract every other
    rec / finding / summary surface already uses.
  - DE Health-Score component labels surfaced English nouns ("Mood",
    "Medis", "BD") instead of full German. Rename to "Blutdruck",
    "Stimmung", "Einnahmetreue", "Gewicht" so the four sub-bars read
    natively German. The "Mut" the maintainer reported on prod was
    voice-to-text rendering "Mood".
  - i18n-integrity test pins the four component labels so a copy-paste
    regression can't reintroduce the leak.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:54:21 +02:00
Marc-André BombeckandClaude Opus 4.7 4d3b12936a feat(insights): rename "Muster" + lift section tabs above the hero
Two related polishing changes for the Insights surface:

a) Correlation row title rename. "Muster" / "Patterns" reads as a
   loose grouping; the row actually shows pre-defined statistical
   relationships between two metrics. Rename to "Zusammenhänge" /
   "Relationships" so the title carries the same conceptual weight as
   the surrounding "Trends" / "Daily Briefing" headers without
   borrowing the "Trends" word from the row above it. Picked
   "Zusammenhänge" over "Trends" because the Trends row already uses
   that word and a correlation is conceptually a relationship between
   two series rather than a single-series trend.

b) Section tabs lift above the hero strip. `<InsightsSectionNav>` was
   mounted below the advisor card so the user had to scroll past the
   hero before discovering the metric-tab nav. Lift it to the top of
   the page so the tabs are the first thing visible — the hero +
   daily briefing render directly below, and clicking a tab still
   scrolls to the matching section. Sticky scroll-anchored behaviour
   preserved (incremental over a full SPA-tab rewrite) so today's
   reactive surface keeps working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:52:07 +02:00
Marc-André BombeckandClaude Opus 4.7 36b9db34c5 fix(insights): row-fill rule + equal-height trends + hide-on-insufficient-data
Three layout problems landed together because they share the same
root: the `/insights` grids assumed every row would be full, so when a
section dropped a card the layout left a half-row of empty space.

  - Row-fill rule: every grid on `/insights` now picks `xl:grid-cols-2`
    only when 2+ cards actually render. With 1 card the grid collapses
    to a single full-width column. The BP-medication grid, the weight-
    correlation grid, and the medications-per-day grid all carried the
    half-empty-row bug; all three are normalised.

  - Trends row equal heights: each `<TrendsRow>` card is now a flex
    column with `min-h-[300px]` so a multi-line AI annotation under one
    chart doesn't push that card taller than its neighbours. The
    visual rhythm holds even when the prose varies in length.

  - Hide insufficient-data correlation tiles: `<CorrelationRow>` drops
    any hypothesis with `status !== "ok"` instead of painting a
    placeholder. When zero hypotheses qualify, the entire row hides
    itself (no header, no disclaimer). 1 ok card spans 100 % width, 2
    or 3 ok cards split 50/50 on `>=md`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:49:55 +02:00