Commit Graph
365 Commits
Author SHA1 Message Date
Marc-André BombeckandClaude Opus 4.7 73d21881a8 feat(insights): personal Health Score formula library
Composite 0-100 score with three bands (green >= 75, yellow 50..74,
red < 50). Server-deterministic so the same inputs always produce the
same output.

Sub-components:
- bpInTargetRate (30 %) — reuses the existing all-time figure
- weightTrendAlignment (20 %) — slope vs target band over 30 days
- moodStability (20 %) — `100 - CV*100`, requires >= 5 entries
- complianceRate (30 %) — mean of medication compliance30

Null components scale the remaining weights proportionally so the
score never lies about what was actually measured. Pure helpers
(linearRegressionSlope, coefficientOfVariation, weightTrendAlignment,
moodStability, complianceRate) live alongside the composite.

Tests cover strong/mixed/poor scenarios, every null-redistribution
shape (bp null, bp+mood null, all-but-compliance null, all null) and
determinism (same input -> same output).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:31:08 +02:00
Marc-André BombeckandClaude Opus 4.7 ebb2bc0602 feat(coach): mobile rail trays for the AI Coach drawer
The history + sources rails were desktop-only since B2b. B4 surfaces
them on `<lg` via two chevron-button triggers anchored to the inside
edges of the message thread. Tapping a trigger opens a side <Sheet>
that hosts the same HistoryRail / SourcesRail instance the desktop
layout uses, so the user can browse conversations or check what data
the Coach is reading without losing message-thread context.

Refactors the body grid into a separate <CoachDrawerBody> presentational
component so the SSR test harness can pin the trigger slots + their
lg:hidden Tailwind variant without rendering the outer Radix Sheet
portal (which is client-only).

Selecting a conversation from the mobile history tray closes the tray
automatically so the user lands back on the message thread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:22:38 +02:00
Marc-André BombeckandClaude Opus 4.7 ff1e335c19 feat(charts): storyboard annotations on HealthChart
Add an additive annotations[] prop to <HealthChart> that paints a
vertical Recharts <ReferenceLine> per AI-narrated event on the
underlying timeline. The line + tooltip + comparison overlay remain
untouched — annotations sit between the cartesian grid and the data
lines so they read as orientation, never as data.

The /insights page wires advisor.payload.insights.storyboardAnnotations
into the 90-day BP chart, mapping the four canonical categories to
Dracula colours (medication=pink, event=cyan, milestone=green,
warning=orange).

Pure helper resolveAnnotationPositions() handles the date->pointIndex
mapping with a 7-day snap window (so annotations don't stack on
monthly buckets) and a 24-char truncation for the small-viewport
label. The unit suite pins exact-day pinning, near-day snapping,
out-of-window dropping, and label truncation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:17:45 +02:00
Marc-André BombeckandClaude Opus 4.7 f1868453af feat(insights): hero strip weekly-report banner card
Add a slim banner-card on <HeroStrip> that surfaces a fresh weeklyReport
between the title block and the action row. Three actions:

- Read   -> Next.js Link to /insights/report/[week]
- Share  -> navigator.share() with clipboard fallback + sonner toast
- Export PDF -> deep-link to the report URL with ?print=1 so the report
  page auto-fires window.print() after first paint

The banner only renders when the parent passes weeklyReportReady; the
insights page derives that prop from advisor.payload.insights.
weeklyReport so the banner appears the moment a fresh PROMPT_VERSION
4.20.2 generation lands.

EN + DE translations under insights.heroBanner.* (Read / Share / Export
PDF + share success/failure toasts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:14:27 +02:00
Marc-André BombeckandClaude Opus 4.7 8f18c7ff15 feat(insights): /insights/report/[week] printable weekly report
Add a newsletter-style report route at /insights/report/[week] that
parses the YYYY-Www slug, reads the cached weeklyReport block off the
AI advisor payload, and renders Summary / Going-well / Worth-watching /
Tips / Data-quality sections in a print-friendly layout.

Print export uses window.print() with Tailwind print: variants on the
toolbar (hidden) and article wrapper (full-width margins). The route
honours ?print=1 to deep-link the export PDF flow from the upcoming
hero banner card.

Includes:
- src/lib/insights/week-iso.ts — parseWeekISO / toWeekISO / weekISOToRange
- WeeklyReportView wrapper + WeeklyReportPresentation pure layer
- Empty-state with Generate CTA when the cached report's weekISO
  doesn't match the route param
- EN + DE i18n keys under insights.report.* and insights.heroBanner.*

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:11:45 +02:00
Marc-André BombeckandClaude Opus 4.7 6d4cba1aad feat(insights): weeklyReport + storyboardAnnotations schema + prompt
Extend aiInsightResponseSchema with weeklyReport (ISO week + summary +
goingWell/worthWatching/tips bullets + optional dataQualityNotes) and
storyboardAnnotations (date + label + category + detail; max 20).

Both blocks are nullable + optional so legacy 4.20.1 caches round-trip.

Bump PROMPT_VERSION 4.20.1 -> 4.20.2 and add GROUND RULE 10 (weekly
report sections match layout, conservative phrasing, no causal claims)
+ GROUND RULE 11 (storyboard annotations are factual user-logged events
with neutral prose) to both EN and DE prompts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:06:04 +02:00
Marc-André BombeckandClaude Opus 4.7 c83cb2311d feat(insights): wire CorrelationRow + TrendsRow into /insights
Compute the three pre-defined correlation hypotheses
(BP × compliance, mood × pulse, weight × weekday) on the trailing
30-day window in `/api/analytics` and surface the typed result
shapes on the response payload. Each runner is gated on n >= 14
+ p < 0.05; below the bar the result.status === "insufficient"
and the per-card EmptyState paints in place of a misleading
near-zero scatter.

The insights page now mounts:

- `<CorrelationRow>` between the Daily Briefing and the Advisor
  card — the layout slot the artboard reserves for the patterns
  surface.
- `<TrendsRow>` immediately below it, fed from
  `advisor.payload.trendAnnotations` (lifted client-side via
  `trendAnnotationsSchema.safeParse`). Legacy cached payloads
  predating PROMPT_VERSION 4.20.1 surface the per-metric
  empty-state hint instead of a half-rendered annotation.

The wide-event annotation captures per-hypothesis status so admin
observability can attribute coverage gaps without re-querying
the DB.

10 source-scan tests pin the load-bearing imports, the JSX mounts,
the analytics-route surfacing, and the malformed-payload null
fallback in the advisor hook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:53:29 +02:00
Marc-André BombeckandClaude Opus 4.7 42591c6cec feat(insights): CorrelationCard + CorrelationRow components
Two cards-and-row primitives for the Insights redesign:

- `<CorrelationCard>` — single hypothesis. Tone-coloured left bar,
  scatter sparkline (deferred-bundled), confidence chip, source
  chip ("based on N paired readings · last 30 days"), interpretation
  prose, and a disabled "Try a 7-day experiment" CTA with a
  Coming-soon tooltip. Below-threshold results render the
  EmptyState in place of the chart so we never paint a misleading
  near-zero scatter.
- `<CorrelationRow>` — 2-up on `>= md`, single column on `< md`.
  Row-level correlation-disclaimer ("Patterns are observational,
  not causal — talk to your doctor if BP runs high.") sits below
  the card grid once per row, not per card.

13 tests cover OK + insufficient rendering, the disabled CTA's
tooltip wiring, the confidence chip variants, the disclaimer
position, the responsive grid, and the EN / DE locale switch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:49:00 +02:00
Marc-André BombeckandClaude Opus 4.7 2ae69d7e64 feat(insights): TrendAnnotation + TrendsRow components
Add two presentational pieces for the Insights redesign:

- `<TrendAnnotation>` — pure renderer for the AI-authored
  one-sentence annotation that sits below each Trends-row chart.
  Empty state is locale-aware ("Awaiting more data to annotate
  this trend"). Optional confidence chip surfaces a discrete
  low / moderate / high band.
- `<TrendsRow>` — 3-up responsive grid (1 column < md, 3 columns
  >= md) wrapping BP / Weight / Mood mini charts with their
  annotations. Recharts ships through the existing `next/dynamic`
  defer-load pattern so the bundle savings stay intact.

Adds the EN + DE i18n keys (trendsRow, trendAnnotation,
correlationRow). 15 unit tests cover the locale switch, the
empty-state hint per metric, the confidence chip variants, and
the row layout chrome.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:47:08 +02:00
Marc-André BombeckandClaude Opus 4.7 f9afaf77a0 feat(insights): trendAnnotations schema + prompt rule
Extend `aiInsightResponseSchema` with an optional, nullable
`trendAnnotations: { bp?, weight?, mood? }` block — one short
sentence per metric, capped at 200 characters. Each field is
independently optional so the model emits only the metrics it can
ground in the snapshot.

Insight-generator prompt picks up GROUND RULE 9 (EN + DE):
single-sentence, observational, ≤ 200 chars, banned causal
phrases enumerated. PROMPT_VERSION bumps 4.20.0 → 4.20.1 to
signal the format change so cached payloads from B1 still parse
through the `.nullable().optional()` field.

13 tests cover the boundary (200 chars), the empty-string
rejection, the legacy-payload round-trip, and the EN/DE prompt
rule presence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:44:20 +02:00
Marc-André BombeckandClaude Opus 4.7 7261c36633 feat(insights): correlation maths for B3 hypotheses
Add `src/lib/insights/correlations.ts` with three pre-defined
hypothesis runners (BP × medication compliance, mood × resting
pulse, weight × weekday) plus pure Pearson + one-way weekday-ANOVA
helpers. Each runner gates the surface on n >= 14 paired rows AND
p < 0.05; below the bar a typed `insufficient` result lets the
card render an EmptyState rather than a misleading pattern.

Interpretations stay conservative ("a pattern worth watching" —
never "X causes Y"). 95 % confidence intervals come from a Fisher
z-transform on r and a discrete band-from-eta-squared mapping for
the weekday case. No new dependencies — the t-distribution +
F-statistic p-value approximations are inlined (Wilson-Hilferty +
Abramowitz-Stegun).

16 unit tests cover positive / negative / no-correlation / under-n /
non-significant / no-variance branches and the Monday-outlier
weekday flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:41:34 +02:00
Marc-André BombeckandClaude Opus 4.7 e54179997b feat(insights): wire Coach drawer into /insights via hero strip
Closes the loop between the hero strip's "Ask the coach" /
suggested-prompt chips and the Coach drawer:

- `<HeroStrip>` gains an `onAskCoach` prop. When supplied, the
  "Ask the coach" button drops its disabled state + Coming-soon
  tooltip and clicking calls the handler. Older parents that
  haven't adopted the drawer still get the disabled affordance —
  no breaking change.
- `<SuggestedPrompts>`'s `onPick` already routed up via
  `onPickPrompt`; the page wires it to a setter that pre-fills the
  drawer's input AND opens it in the same tick.
- `/insights` owns `coachOpen` + `coachPrefill` state. The drawer
  mounts with `key={prefill ?? "blank"}` so the lazy `useState`
  initialiser fires fresh on every chip transition, sidestepping
  the `react-hooks/set-state-in-effect` rule.

Tests:
- Updates the hero-strip ratchet to allow either disabled (no
  handler) or enabled (handler supplied) states.
- Adds three guards under `insights-polish.test.ts` that pin the
  page-side wiring (CoachDrawer import, hero handler hook-up,
  state ownership).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:32:14 +02:00
Marc-André BombeckandClaude Opus 4.7 8dac3891b8 feat(coach): composer, history rail, sources rail
Fills the three drawer slots from commit 2 with their full
implementations:

- `<CoachInput>` is a Dracula-styled `<textarea>` card. Plain Enter
  and Cmd/Ctrl+Enter both submit; Shift+Enter inserts a newline.
  The mic button is disabled with an "Voice input arrives with the
  iOS app in v1.5" tooltip — the natural shipper of voice is the
  native client. A footer disclaimer ("Coach replies are
  generated. Clinical decisions belong with your doctor.") sits
  beneath the box.
- `<HistoryRail>` reads from `useCoachConversations()`, exposes a
  substring filter, marks the active conversation, and offers a
  hover-revealed delete affordance with a confirm-then-go flow
  driven by `useDeleteCoachConversation()`.
- `<SourcesRail>` lists the five contracts the snapshot helper
  surfaces (BP, weight, pulse, mood, compliance) with a static
  fresh-dot indicator. A v1.4.21 follow-up swaps in
  `<IntegrationStatusPill>` once the rail decides whether to lean
  on `/api/auth/me` or a dedicated `/api/insights/data-sources`
  endpoint.
- `<CoachDrawer>` mounts these as the defaults so callers only
  need to pass open + onOpenChange + prefill; the slot props stay
  as escape hatches for tests.

Tests added: composer keyboard / disabled / streaming states (10),
sources-rail render + locale (5), history-rail render + locale +
empty / active states (7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:30:04 +02:00
Marc-André BombeckandClaude Opus 4.7 195448ae19 feat(coach): drawer shell, message thread, source chips
Lays the visual scaffold for the AI Coach drawer (B2b):

- `<CoachDrawer>` wraps `<Sheet side="right">` and exposes the
  three-column layout from the artboard (history rail · message
  thread · sources rail) on `lg+`. Mobile (<lg) collapses to a
  single column with the message thread in focus and the rails
  hidden — they get reachable via tray menus in v1.4.21.
  History / sources / composer are slot props so the parent wires
  them in commit 3 without coupling them to the shell.
- `<MessageThread>` renders user / assistant bubbles, supports an
  in-flight streaming bubble fed by `useSendCoachMessage()`, and
  preserves a "user-pinned-to-bottom" auto-scroll heuristic so
  reading earlier turns doesn't yank the viewport when a new token
  lands.
- `<SourceChips>` renders the labels-only provenance envelope —
  metric + window + n-count, no raw values — matching the wire
  contract from B2a.
- i18n keys land under `insights.coach.*` for tagline, new chat,
  composer placeholder, history rail, sources rail, metric labels,
  and window labels. Both locales updated.

Drawer state (open / prefill) is consumed via a controlled prop +
lazy `useState` initialiser; the close path resets thread + composer
in the `onOpenChange` handler so we don't trip
`react-hooks/set-state-in-effect`.

Tests cover: source-chip render permutations (8), message-thread
empty / persisted / streaming / error states (8), provenance
locale-aware labels.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:25:34 +02:00
Marc-André BombeckandClaude Opus 4.7 c42c0a50cb feat(coach): TanStack Query hooks + SSE streaming client
Adds the data layer for the AI Coach drawer (B2b):

- `useCoachConversations()` reads `GET /api/insights/chat` for the
  history rail (default page; cursor pre-wired for v1.4.21 infinite
  scroll).
- `useCoachConversation(id)` reads the single decrypted thread.
- `useDeleteCoachConversation()` removes the thread with optimistic
  list cache update + rollback on failure.
- `useSendCoachMessage()` POSTs to `/api/insights/chat`, walks the
  body via `fetch().body.getReader()`, and decodes SSE frames
  (`data: <json>\n\n`) with `TextDecoder`. Token / provenance / done /
  error events update a `streaming` state object that the message
  thread will render as the live assistant bubble. On `done` we
  invalidate the conversation cache + the rail so the next mount sees
  the canonical persisted shape.
- `parseSseChunk()` is exported as a pure helper so the parser is
  unit-testable without a live network stream — 11 cases cover
  single-frame, multi-frame, byte-by-byte interleaving, malformed
  payload tolerance, and the full token + provenance + done round-trip.

No third-party SSE library introduced — `fetch` + `getReader()` is
enough.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:20:04 +02:00
Marc-André BombeckandClaude Opus 4.7 3168ba8e0a feat(coach): GET list, GET single, DELETE conversation endpoints
GET /api/insights/chat returns the caller's conversations newest-first
with cursor pagination (default limit 20, hard cap 50). Each row
carries id, title, timestamps, and a message count so the rail can
render without paying the per-message decrypt cost.

GET /api/insights/chat/[id] fetches one conversation with every
message decrypted in memory before serialisation. Provenance metadata
round-trips alongside the assistant turns.

DELETE /api/insights/chat/[id] hard-deletes the thread; the FK chain
cascades the message rows.

Cross-user lookups map to 404, never 403, so the existence channel
never leaks across accounts.

6 integration tests cover the list ordering, owner-only filter,
decryption round-trip, and the 404 boundary on both verbs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:11:12 +02:00
Marc-André BombeckandClaude Opus 4.7 16f3ece90f feat(coach): add POST /api/insights/chat SSE streaming endpoint
The route gates on requireAuth (cookie or bearer), enforces the daily
token budget, runs the prompt-injection / off-topic refusal scanner,
and only then walks the user's provider chain.

The conversation is created via withIdempotency() when no
conversationId is supplied, so a retry under the same Idempotency-Key
replays the original assistant reply rather than spinning up a second
thread. Existing-thread turns skip the wrap.

Replies stream as SSE: one "token" frame per word-sized chunk, then a
"provenance" frame describing the windows + metrics the assistant
could see, then a "done" frame carrying the persisted message id.
Provider failure cascades to a single "error" frame and the assistant
message is never persisted. Conversation history is capped at 20 turns
in the prompt window via a synthetic summary placeholder so cost stays
bounded.

`buildCoachSnapshot()` reuses the analytics features pipeline so the
Coach narrates the same numbers the dashboard tiles render.
`getCoachSystemPrompt()` extends the strict-insight ground rules with
conversational framing while keeping the zero-hallucination rule.

5 integration tests pin the round-trip, refusal short-circuit, budget
429, foreign-conversation 404, and validation 422 paths against the
postgres testcontainer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:07:47 +02:00
Marc-André BombeckandClaude Opus 4.7 a68557d04b feat(coach): types, persistence, budget, and refusal helpers
Wire the foundation layer for the AI Coach:

- `types.ts` defines the SSE event shapes (token / provenance / done /
  error), the chat-request zod schema, and the conversation/message
  DTOs the route + UI share.
- `persistence.ts` encrypts every message body with the active
  ENCRYPTION_KEYS entry before insert and decrypts on read.
  `summariseTitle()` keeps the rail snippet ≤80 chars with a smart
  word-boundary cut.
- `budget.ts` introduces a per-(user, UTC-day) ledger with a 25 000-
  token daily cap. `enforceBudget()` throws HttpError(429,
  "coach.budget.exceeded") so the route can short-circuit before
  spending. UTC alignment matches the LLM-provider billing cycle.
- `refusal.ts` runs prompt-injection and off-topic detection on inbound
  messages. Pattern bank covers EN+DE injection wording, jailbreak
  variants, and obvious off-topic asks (weather, code-help, recipes).
  Health-allow tokens override the off-topic deny list so mixed
  questions land on-topic.
- i18n keys `insights.coach.refusal*` + `insights.coach.error*`
  added to both locale bundles.
- 28 unit tests cover the helpers with mocked Prisma.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:01:15 +02:00
Marc-André BombeckandClaude Opus 4.7 7d29596998 feat(insights): wire <HeroStrip> + <DailyBriefing> into /insights
Replaces `<InsightsPageHero>` with the new `<HeroStrip>` band and
mounts a full-width `<DailyBriefing>` directly below it, ahead of
the existing advisor + status cards which remain untouched.

`useInsightsAdvisorQuery` now lifts the optional `dailyBriefing`
block off the cached payload and validates it client-side via
`dailyBriefingSchema.safeParse`. A malformed cached briefing falls
back to null so the briefing card paints its empty-state CTA
instead of a half-rendered card. Legacy v1.4.19 cached payloads
(no `dailyBriefing` field) round-trip unchanged.

The CompareToggle migrates from the hero meta band into the
DailyBriefing's metaSlot — keeps the page-level comparison control
adjacent to the briefing it scopes.

The hero greeting reads the user's username (HealthLog has no
separate display-name field) and stops at the first whitespace so
"alice" / "alice.smith" both surface "Good morning, alice".

`insights-polish.test.ts` v1.4.19 A3 ratchet updated: the original
"exactly one onRegenerate prop on the page" assertion was a
string-count check that mis-fires now that the briefing card's
empty-state CTA legitimately wires the same handler. The
load-bearing constraint stays — the advisor card must NOT carry
onRegenerate — and is enforced via a tighter JSX-block check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:44:40 +02:00
Marc-André BombeckandClaude Opus 4.7 59e63a0641 feat(insights): add <HeroStrip> + Dracula gradient utilities
New `src/components/insights/hero-strip.tsx` — replaces the v1.4.16
`<InsightsPageHero>` band with the wider strip from the v1.4.20
redesign:

  - locale-aware time-of-day greeting (morning / afternoon / evening
    / night → 4 i18n keys, with "night" mapping to "Good evening" in
    both locales since 03:00 + Guten Morgen reads odd)
  - briefing-paragraph subtitle (falls back to a generic line when
    no briefing is cached)
  - personal-baseline + 'Generated <relative-time>' meta band
  - 3-button action row: weekly-report (disabled, B4), ask-the-coach
    (disabled, B2), re-run analysis (wired through onRegenerate).
    Disabled buttons surface a 'Coming soon' tooltip via title= so
    we don't drag in a Radix tooltip provider for static labels.
  - <SuggestedPrompts> chip strip below the action band; onPickPrompt
    is a TODO until B2 lands the Coach drawer (parent passes a no-op)

New CSS utilities in `globals.css`:
  - .hero-gradient — Dracula purple → pink linear gradient over card
  - .glow-purple — soft purple aura via box-shadow
  - @keyframes pulseDot + .pulse-dot — for the future "live" dot
    affordance; reduced-motion users get a static dot

Old `<InsightsPageHero>` stays in place (used by the dashboard
preview) and gains a JSDoc @deprecated note pointing to the new
component. EN + DE translations added in lockstep.

Test suite: +22 specs covering greeting buckets per locale + per
hour, paragraph fallback, disabled-button affordance, regenerate
wiring, suggested-prompts mount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:41:33 +02:00
Marc-André BombeckandClaude Opus 4.7 92f332efe6 feat(insights): add <DailyBriefing> card component
New `src/components/insights/daily-briefing.tsx` renders the
full-width briefing card from the v1.4.20 redesign — narrative
paragraph + 0-5 key-finding rows below it, each row carries a
tone-coloured left bar (green/orange/cyan), metric icon, headline,
detail line, and an optional delta badge in the matching tone.

States covered:
  - loaded: paragraph + findings + optional 'Generated <time>' meta
  - empty (briefing=null + !loading): EmptyState with 'Generate
    briefing' CTA wired through onRegenerate
  - loading: animate-pulse skeleton matching the rest of the app's
    loading affordance
  - empty findings: paragraph still renders; the findings block
    hides cleanly so the card doesn't paint a void section header

Optional metaSlot in the card header so the comparison toggle can
migrate from the hero to the briefing card in commit 5.

EN + DE translation keys added (insights.dailyBriefing.*). Test
suite: +18 specs covering rendering + states + locale + tone bars.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:38:52 +02:00
Marc-André BombeckandClaude Opus 4.7 9713bf1606 feat(insights): add <SuggestedPrompts> chip-strip component
New `src/components/insights/suggested-prompts.tsx` renders the
"Try asking" chip row from the v1.4.20 Insights redesign hero strip.
Five default prompts (whyMonday, weightVsPulse, weekVsMonth,
tellMyDoctor, medicationWorking) read from
`insights.suggestedPrompts.*`, click invokes `onPick` with the
localised string so the parent can route to the future Coach drawer.

Mobile-first: chips wrap to multiple rows on narrow viewports.
Dracula purple chip styling matches the existing card vocabulary.
Custom prompts array accepted for tests + future per-user sets.

EN + DE translation keys added in lockstep — i18n-locale-integrity
test stays green. Test suite: +9 specs covering default rendering,
locale switching, custom prompts, and chip-slot pinning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:37:15 +02:00
Marc-André BombeckandClaude Opus 4.7 9873363fa3 feat(insights): add Daily Briefing schema + prompt v4.20.0
Extends `aiInsightResponseSchema` with an optional `dailyBriefing`
block (paragraph + 0-5 keyFindings, each with tone / headline / detail
/ delta / sourceWindow / sourceMetric). Nullable + optional so v1.4.19
cached payloads still parse cleanly.

Bumps PROMPT_VERSION 4.19.0 → 4.20.0 and adds GROUND RULE 8 in both
EN and DE system prompts, instructing the model to emit a briefing
block grounded in the user's own snapshot numbers (no extrapolation,
no medical advice claims, hard-cap at 5 findings).

TDD: new daily-briefing-schema.test.ts covers legacy back-compat,
well-formed parsing, > 5 findings rejection, empty-paragraph
rejection, finding-field validation, and the PROMPT_VERSION 4.20.x
match. Existing medical-reference-prompt.test.ts assertion stays
broad enough to ride the bump.

+31 tests (1672 → 1703); typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:35:26 +02:00
Marc-André BombeckandClaude Opus 4.7 1f0d9ad1f7 style: prettier on touched admin section
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:30:10 +02:00
Marc-André BombeckandClaude Opus 4.7 977f124b9e fix(ui): widen TabsList padding so the focus ring fits inside the clamp
`overflow-y-hidden` on the tabs strip clipped the bottom of the
focus-visible ring (`ring-[3px]`) on triggers because the parent
padding was only 3 px — the 3 px ring ended exactly at the
content-box edge. Bump the padding to `p-1` (4 px) so keyboard users
see the full ring while the y-axis clamp still hides the painted
scrollbar Marc reported.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:26:18 +02:00
Marc-André BombeckandClaude Opus 4.7 5a8ad3d895 fix(charts): drop the redundant resize sync from useViewportWidth
The useState lazy initialiser already runs on the client during
hydration and reads the live `window.innerWidth`, so the post-mount
handler() call was a no-op that triggered a setState inside useEffect.
The eslint react-hooks/set-state-in-effect rule flags this pattern;
removing it lets the resize + orientationchange listeners alone drive
updates without a hydration-time double set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:25:50 +02:00
Marc-André BombeckandClaude Opus 4.7 1258b24864 fix(admin): render token-name timestamps in Berlin time
The auto-issued token-name viewer rebuilt the dd.MM.yyyy HH:mm suffix
from `Date.getUTC*` accessors, producing 19:46 instead of the 21:46 a
Berlin-time admin would expect for a token issued at 19:46 UTC. Route
the parsed Date through `Intl.DateTimeFormat("de-DE", { timeZone:
"Europe/Berlin", … })` so the embedded timestamp matches every other
display surface in the app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:25:28 +02:00
Marc-André BombeckandClaude Opus 4.7 6b35cad079 refactor(v1.4.19): apply simplify-review safe suggestions
Mechanical clean-up sweep across five hot spots flagged by the simplify
audit:

- Lift the 11-key auth.* audit-action label map out of the two admin
  components that rebuilt it on every render. The shared
  useAuthActionLabels() hook in _shared.tsx is now the single home for
  new entries (login-overview + recent-audit-preview consume it).
- Drop the dead chipClass branches in IntegrationStatusPill: only the
  connected state actually paints a Dracula-green chip; error and
  disconnected fall back to the badge variant.
- Add thin DateInput / DateTimeInput wrappers under components/ui that
  bake in the lang={locale} hint so 14 callsites across 9 files no
  longer have to remember the prop. A future date input cannot
  silently regress.
- Remove the leading icon-only flex wrappers left behind when the
  duplicate card titles were dropped on /admin/feedback,
  /admin/danger-zone, and the thresholds editor.
- Strip the five "v1.4.19 X removed because…" narration comments from
  insights/page.tsx and the dashboard. git blame already answers what
  changed and the comments aged into history.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:24:35 +02:00
Marc-André BombeckandClaude Opus 4.7 ef74241a68 fix(measurements): paint Sys/Dia badge on mobile blood-pressure rows
The mobile card list checked m.type against the short forms BP_SYS /
BP_DIA, which never appear in API payloads — the canonical enum is
BLOOD_PRESSURE_SYS / BLOOD_PRESSURE_DIA. The badge condition was
therefore permanently false and the disambiguation tag never rendered
on Pixel 5. Route the label through MEASUREMENT_TYPE_LABEL_KEYS so the
mobile branch shares the desktop table's single source of truth, and
add a regression test that asserts both labels paint in both branches
under EN and DE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:15:56 +02:00
Marc-André BombeckandClaude Opus 4.7 ae2f671321 style: prettier on touched test + e2e files
Run prettier on the test, e2e, and component files this v1.4.19
A8 wave touched. The .planning/* and docs/audit/* prettier
warnings are pre-existing baseline noise — left alone to keep
this commit narrow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:58:30 +02:00
Marc-André BombeckandClaude Opus 4.7 713b494f04 fix(admin): humanise auto-issued token names in viewer
The native auto-login flow names tokens by suffixing the issuing
call's `Date.now().toISOString()`, so the admin viewer rendered
"iOS auto-login 2026-05-05T19:46:20.603Z" — readable as debug
output, not as something a person picked. Added a renderer-side
formatter that detects the trailing ISO timestamp and rewrites
the cell to "iOS auto-login · 05.05.2026 19:46". Manual token
names without an ISO suffix pass through unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:57:04 +02:00
Marc-André BombeckandClaude Opus 4.7 bc81fd7db2 fix(admin): translate native-token audit actions, link recent rows
Two related improvements to the admin recent-activity surface:

- New audit actions `auth.token.autoissue.native`,
  `auth.token.refresh`, and `auth.token.revoke` were leaking
  through both the dashboard preview and the Login Overview
  viewer as raw event keys. Added translations and wired the
  three keys through the action-label map shared by both
  components.
- Recent-activity rows on /admin were a dead-end: a failed
  login row showed "Unknown" + "Failed" with no link, no IP,
  no way to drill in. Each row is now a Link into
  /admin/login-overview, with the IP shown inline at sm+.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:55:34 +02:00
Marc-André BombeckandClaude Opus 4.7 876e074fd8 fix(measurements,settings): visual disambiguation polish
Two related polish items:

- Mobile measurement list rendered systolic and diastolic BP
  rows with the same heart icon, so a 117 mmHg row and a
  79 mmHg row underneath looked identical until you read the
  number. Mirror the desktop "Sys" / "Dia" badges on the
  mobile cards too.
- Telegram settings card was showing two separate badges side
  by side ("Configured" green + "Enabled" outline). They
  carried no visual hierarchy and made the difference between
  the two unclear. Collapsed into a single status pill — green
  "Active" when both bits are set, neutral
  "Configured · Disabled" when the channel is paused.

Also brought a stale settings-sections SSR-smoke test in line
with the F-07 threshold-naming change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:54:18 +02:00
Marc-André BombeckandClaude Opus 4.7 98cd13c8e0 i18n(achievements,admin): drop redundant labels and grammar nits
A few small quality wins bundled together:

- The 1-day achievement descriptions read "for 1 consecutive
  days" / "1 Tage in Folge" — singular/plural mismatch that
  was awkward in both languages. Hand-tuned the five 1-day
  variants to "for 1 day" / "an einem Tag"; 7d / 30d copies
  unchanged.
- The two "Two hundred …" / "Zweihundert …" badges now use
  the numeral 200 to match every other count-based badge.
- /admin/integrations toggle rows ("Umami" / "GlitchTip" lines
  that duplicated the card header) drop the literal product
  name and just show the action: the existing
  `umamiEnabled` / `glitchtipEnabled` description copy is
  promoted to the toggle's primary label. SettingsToggle's
  description prop becomes optional to support that.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:52:31 +02:00
Marc-André BombeckandClaude Opus 4.7 70ebe32b27 fix(admin): drop duplicate card title on single-card pages
`/admin/danger-zone` and `/admin/feedback` are single-card admin
pages where the page-level header already names the section.
The card-level h2 ("Danger Zone" / "Feedback Inbox") was a verbatim
copy of that header taking up vertical space without adding info.
Removed the duplicate headers — the section icon stays so the
card still has a visible affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:49:58 +02:00
Marc-André BombeckandClaude Opus 4.7 3f6e670894 i18n(admin,settings): copy consistency sweep
Several smaller A8 copy nits in one pass:

- Notifications settings subtitle no longer hard-codes "Telegram,
  ntfy, Web Push" — the channel-reliability board only shows
  configured channels, so naming all three made one of them look
  missing.
- Admin /feedback page subtitle widened to "feedback, bug reports,
  and feature requests" so it matches what the inbox actually
  contains; the duplicate card-level title + description on
  /admin/feedback is gone (page header already provides them).
- /admin/services subtitle now mentions integrations alongside
  notification channels (the API and moodLog toggles live there
  too); per-row "system-wide" suffixes dropped — the section
  subtitle already establishes the scope.
- /admin/ai-quality subtitle reads as a sentence instead of the
  jargon "(severity x provider)" expression.
- /admin/feedback empty state is "All caught up" / "Alles erledigt"
  instead of the English-idiom "Inbox zero".
- /achievements subtitle rewritten in both EN + DE to drop the
  dev-speak "from feature rollout onward".
- /admin/api-tokens permission badges show "All" (with a tooltip)
  instead of the bare regex glob `*`.
- Brand spelling normalized: every Glitchtip → GlitchTip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:47:51 +02:00
Marc-André BombeckandClaude Opus 4.7 1f4d6a28a5 fix(settings): unify threshold page naming
The thresholds settings route was carrying three different
names: nav label "Personal targets", page header "Personal
targets", inner card header "Personal thresholds". The URL
slug is `/settings/thresholds`, so settled on "Personal
thresholds" everywhere in EN; DE keeps the established
"Persönliche Zielwerte". While here, dropped the duplicate
title + subtitle inside the editor card — the page-level
header already provides them and the second copy was just
visual noise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:43:04 +02:00
Marc-André BombeckandClaude Opus 4.7 42b0f110ba fix(admin): explicit role-change button label
The user-management role-change button rendered a bare arrow
followed by "User" / "Admin" — direction was ambiguous
("does the arrow mean 'now they are User' or 'send them down
to User'?") and the label carried no aria-label. Replaced the
arrow text with explicit "Make user" / "Make admin"
("Zum Benutzer" / "Zum Admin"), and added the same string as
aria-label so screen readers announce the action verb instead
of the visible-only arrow glyph.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:41:52 +02:00
Marc-André BombeckandClaude Opus 4.7 1ae9b9dcad fix(admin,measurements): humanise raw enum badges
Two badges were rendering raw enum values: backups showed
`WEEKLY_AUTO` / `MANUAL` and the measurement list showed
`WITHINGS` instead of `Withings`. Backup type now uses two new
i18n keys (`Weekly (auto)` / `Manuell`); measurement source
reuses the existing `measurements.source*` keys. Unknown
enum values still fall through to the raw string so any
schema additions remain visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:40:52 +02:00
Marc-André BombeckandClaude Opus 4.7 d2631253a4 fix(insights): match legacy-payload CTA button to its banner copy
The legacy-payload yellow banner read "Insights updated —
regenerate for new explainability features" but the CTA button
underneath said "Start analysis", so the verb in the help text
and the verb on the button disagreed. Switch the legacy CTA
button to the existing `insights.heroRegenerate` key so both
banner and button now talk about regeneration. The empty-state
"Start analysis" CTA stays untouched — that one's never had
analysis to start from.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:38:19 +02:00
Marc-André BombeckandClaude Opus 4.7 ff6e18473a fix(forms): hint browser locale on date and datetime inputs
Native `<input type="date">` and `<input type="datetime-local">`
were rendering `mm/dd/yyyy` even when the German locale was
active, because Chromium picks the input's locale from the
nearest `lang` attribute on the element rather than from
`<html lang>`. Pass `lang={locale}` on every date/datetime input
in the app — onboarding DOB, account DOB, export filters,
doctor-report range, measurement / medication / mood timestamp
fields — so DE users now see `TT.MM.JJJJ`. Display formatting
already routed through `useFormatters()`; this closes the form
half of the same gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:36:23 +02:00
Marc-André BombeckandClaude Opus 4.7 7a6bc812c9 i18n(achievements): replace insulting badge titles
The over-intake and skipped-intake achievements shipped with the
literal titles "Idiot" / "Lazy Boy" in both EN and DE bundles —
the German file never translated them. For a personal health app
the user gets called an idiot any time they double-dose because
they forgot they already took it. Replaced with neutral,
descriptive titles ("Double take" / "Stepped back" — "Doppelt
genommen" / "Pause eingelegt"). Locked in by a regression suite
that scans every badge title against a banned-word list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:32:19 +02:00
Marc-André BombeckandClaude Opus 4.7 dc75fe846a fix(admin): restrict login overview to auth events
The Login Overview page promised "authentication and admin events"
in its subtitle but the underlying audit-log query never set
`filter=auth`, so the table mixed insights.* / admin.* / data.*
rows in with passkey + password logins. Default the page request
to `filter=auth` (an explicit action picker still overrides), and
restrict the action dropdown to actions starting with `auth.` so
the filter UI stays in sync with what the page actually shows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:30:51 +02:00
Marc-André BombeckandClaude Opus 4.7 180f46c24e fix(medications): localise time-window range string
`formatTimeWindowRange` used to hard-code the German "bis ... Uhr"
suffix, so an English-locale user saw "Today, 19:00 bis 23:00 Uhr"
on the medication card and the medication form. Pass the active
locale through and emit "19:00 – 23:00" for English while keeping
the existing German output for DE users (and unchanged for legacy
call sites that still don't pass a locale).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:29:07 +02:00
Marc-André BombeckandClaude Opus 4.7 90a109d0b4 i18n(admin): translate Zielwerte status labels (Low/On Target/Stable/Moderate to DE)
Marc reported the Zielwerte page (`/targets`) still surfaces English
status pills even when the rest of the UI runs in German: "Low /
On Target / Stable / Moderate / Normal" stayed in source language
because the server emits stable English category strings and the
page rendered them verbatim. Card titles ("Resting pulse", "Mood
stability", "Medication compliance" …) had the same issue.

Add two i18n key blocks under `targets.*`:

- `targets.label.<TYPE>` — one entry per measurement type, used by
  `<TargetCard>` to localise the card title. The server keeps
  emitting English labels (so logs / external consumers don't
  churn); the page swaps them in via `t("targets.label." + target.type)`.
- `targets.status.<key>` — every classification category from
  `lib/analytics/classifications.ts`, `pulse-targets.ts`, and the
  inline maps in `targets/route.ts`. A small `STATUS_CATEGORY_KEY`
  map on the page normalises the human-readable English category
  to the i18n key.

Glucose targets keep their existing key path (the server already
emits `targets.glucoseFasting` etc. as the label, resolved at the
top of `<TargetsPage>` before cards mount).

Fall-through is graceful: if a future server adds a category we
have not translated yet, the verbatim English string still
renders — the page never blows up.

Also adds `targets.bmi`, `targets.label.BMI`, `targets.status.normal`,
`targets.status.optimal`, `targets.status.fitness` to the i18n
integrity test's PLACEHOLDER_ALLOWLIST: those terms are identical
in EN and DE (BMI is a global acronym; "Normal" / "Optimal" /
"Fitness" are technical terms German clinics use verbatim).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:21:36 +02:00
Marc-André BombeckandClaude Opus 4.7 6507646a90 style(admin): reduce excessive vertical whitespace on Zielwerte page
Marc reported "relativ viel Platz" wasted between the overview
header and the first cards on `/targets` (Zielwerte). The page
used `space-y-8` (32 px) on the outer wrapper, which leaves an
oversized gap between the intro paragraph and the first card.

Tighten to `space-y-6` (24 px) — same rhythm the admin and
settings pages use, so the whole app shares one Dracula-spacing
beat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:21:36 +02:00
Marc-André BombeckandClaude Opus 4.7 7a70db6c86 fix(admin): hide collapse button when page has only one section
`/admin/api-tokens` carried a Collapse / Expand toggle inherited
from the v1.4 shared admin page where 13 sections lived together.
On the v1.5 dedicated route the toggle just hides the only card on
the page — Marc reported it as "sinnlos" (pointless): the page
title already says "API Tokens", the only thing below it is the
tokens card, and collapsing it leaves an empty surface with the
section heading staring back at you.

Drop the local `expanded` state and the toggle button from the
section. The card always renders, the query always fetches, and the
header is now a plain icon + title row. The shared
`settings.collapse` / `settings.expand` i18n keys stay in messages
because `<LoginOverviewSection>` still uses them.

The matching test asserts no Collapse / Expand / Einklappen /
Ausklappen string appears in either locale, and that no
`aria-expanded` button shows up on the section header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:21:36 +02:00
Marc-André BombeckandClaude Opus 4.7 dd8212e202 fix(admin): api-tokens table truncate-with-tooltip (no scrollbar 4th attempt)
Marc has now reported a residual scrollbar on `/admin/api-tokens`
four times running. Earlier fixes addressed the obvious overflow
culprits (column-hide in v1.4.15, mobile card-list in v1.4.16,
admin-shell mobile-strip `no-scrollbar` in v1.4.18), but the
section itself still leaked a few quirks:

- The mobile token-name span carried `truncate` and `break-all` on
  the same element. `truncate` sets `white-space: nowrap`, which
  beats `word-break: break-all` per CSS spec — `break-all` was dead
  code AND it was misleading. Remove it.
- The desktop table had no per-column upper bound. A long token
  name could expand the row past the card on 768-1024 px viewports.
  Lock the layout with `table-fixed` + `<colgroup>` widths.
- Permission badges had no `max-width` cap. A single long
  permission string (`measurements:write:audit:legacy:extended`)
  could push the cell past the card. Cap each badge with
  `max-w-full truncate` and a native `title` attribute.

Wrap every potentially-long string (token name, username, permission
badge) in a small `<TruncatedCell>` helper that pairs the visual
truncation with a radix tooltip. The tooltip is the discoverable
hover/long-press path so the full value is still readable; the
native `title` attribute is a graceful fallback for screen readers
and right-click → "view full name" on desktop.

Adds an e2e regression that walks every element inside the mobile
card-list and asserts none of them carry `scrollWidth > clientWidth`,
exercised against a worst-case payload (long token name + long
username + long permission string).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:21:36 +02:00
Marc-André BombeckandClaude Opus 4.7 088832aa39 fix(admin): remove spurious scrollbar from feedback tab strip
Marc reported a stray "mini scroll-button" on the right side of the
admin feedback inbox tab strip (Open / Acknowledged / Resolved /
Archived). The four short tabs do not horizontally overflow the
viewport, so the painted bar surprised him every time he opened the
inbox.

Root cause is a CSS overflow coupling: setting only `overflow-x: auto`
silently forces `overflow-y` to `auto` too, because a single axis
cannot be `visible` while the other clips. The strip is locked at
`h-9` (36 CSS px) but its badge children (`py-0.5` + text-xs digits)
ride 1-2 px taller than the strip on some glyph stacks, so the
implicit `overflow-y: auto` paints a tiny vertical scrollbar on the
right edge.

Fix is a single class on `tabsListVariants`: `overflow-y-hidden`
(plus `overflow-y-visible` in the vertical-orientation override so
vertical tab columns are not affected). Horizontal swipe and
keyboard-arrow scrolling still work — only the implicit y-axis bar
is suppressed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:21:36 +02:00
Marc-André BombeckandClaude Opus 4.7 f2b21a47db test(insights): add insights-polish.test.ts (followup to 5360d3c)
Adds the actual test file that 5360d3c's message described — the
previous commit only landed the .planning/* files for A6 because a
parallel agent's `git add` interleaved with mine and reordered the
working-tree state mid-commit.

The test suite itself was already passing locally (9/9) and against
the 1646-test full suite; this commit lands the file so CI sees the
guards.

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