Commit Graph
179 Commits
Author SHA1 Message Date
Marc-André BombeckandClaude Opus 4.7 e632e26854 fix(coach): repair SSE idempotency and error-frame handling
- Drop the withIdempotency wrap on POST /api/insights/chat. The wrapper
  caches response bodies via cloned.text() then replays through
  NextResponse.json(JSON.parse(...)) — turning the SSE wire format into
  a null body on retry. The PWA never sends Idempotency-Key so the bug
  is hidden today; the iOS client would walk straight into it. Dedup
  still holds via the conversationId existence check + 20-turn cap.

- Read the SSE response body even on !response.ok when the content type
  is text/event-stream so the structured error frame the route emits
  surfaces its errorCode to the bubble. Without this the stream was
  read only on 2xx and the rich error channel was dead code.

- Stabilise the useSendCoachMessage opts reference via useRef so an
  inline-literal { onDone } from the drawer no longer re-memoises send
  on every render.

- Drop the unused snapshot parameter from streamProviderError (the
  ReturnType<typeof Object.assign> alias widened to any). Switch the
  error response to status 200 so the streaming client reads the SSE
  body and parses the error frame instead of branching on HTTP status.

Fixes Code-HIGH-1, Code-HIGH-2, Code-HIGH-3, Code-HIGH-4, Security-M-1,
Senior-HIGH-1, Simplify-S-01.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:01:40 +02:00
Marc-André BombeckandClaude Opus 4.7 91ad5ec115 feat(insights): mount Health Score panel in hero strip
The hero strip's right side, empty by design since B1, now paints the
<HealthScoreCard> when `analytics?.healthScore` is non-null. Layout
flips from `flex-col` to `flex-row` on `lg+` so the panel sits beside
the title block on desktop and stacks below on mobile.

The `onAskCoach` prop signature picks up an optional `prefill`
argument so the same handler powers both surfaces — the action-row
button passes nothing (drawer opens blank), the score panel passes
"Why is my health score X out of 100?". One drawer instance, one
state pair, one consistent UX.

Tests: 5 new hero-strip cases pin the score panel slot, the lg-row
layout switch, and the conditional ask-coach button mounting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:42:53 +02:00
Marc-André BombeckandClaude Opus 4.7 236b4986a4 feat(api): emit Health Score from /api/analytics
The route now builds the four-component Health Score input from the
already-fetched BP figure plus a fresh weight + mood + medication
intake fetch (last 30 days, plus a 7-day-shifted snapshot for the
delta). The response gains a `healthScore: { score, band,
components, delta } | null` field; null when no component is
computable so the UI hero panel can hide cleanly.

The wide-event annotation carries `healthScore.score` + `band` +
`delta` so admin-app-logs can attribute coverage without DB queries.

The previous-week BP rate reuses the all-time figure (it's a slow-
moving aggregate; rewinding it cheaply isn't possible without a
full historical re-pair). Delta therefore primarily reflects week-
over-week movement in weight / mood / compliance — the components
that actually move on a weekly cadence.

Integration coverage seeds three synthetic-data shapes against the
postgres testcontainer:
- strong-positive → green band (>= 75)
- empty user → null healthScore
- compliance-only → bp/weight/mood null, compliance weight 1.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:37:20 +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 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 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 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 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 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 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 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 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
Marc-André BombeckandClaude Opus 4.7 335f288d69 refactor(insights): drop duplicate metric tile strip
The /insights page used to lead with a 5-column grid of TrendCards
(weight, BP-sys, BP-dia, pulse, plus body-fat and mood when
present) — exact duplicates of the dashboard tile-strip. Marc said
the tiles "duplizieren Dashboard, nehmen nur Platz weg" and asked
for them gone.

Drop the strip, the helper functions that supported only it
(`getRangeColorClass`, `getRangeHint`, `RangeDisplayConfig`), and
the bookkeeping locals (`bf`, `showBodyFatCard`, `moodSummary`,
`showMoodCard`, `fmt`). Remaining mood references on the page read
`data?.moodSummary` inline; every other helper was used only by the
removed strip.

Net: -157 lines, no behaviour change anywhere except the missing
duplicate strip. Recommendations grid + per-section status / chart
cards below stay intact — those are the actual value of /insights.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:14:05 +02:00
Marc-André BombeckandClaude Opus 4.7 98a3d10705 refactor(insights): single page-level refresh and drop orphan subtitle
The /insights page used to surface the same regenerate handler three
times — the page hero's "Neu generieren" button, the advisor card
header's icon-only refresh button, and the empty-state "Analyse
starten" CTA inside the same card. Marc described the trio as
"gefuehlt drei Buttons, alle machen das gleiche".

Drop `onRegenerate` from the page's `<InsightAdvisorCard>` mount.
The hero remains the single page-level refresh affordance; the
card-level icon button and empty-state CTA disappear because they
short-circuit on `!onRegenerate`. The per-recommendation Regenerate
(inside `<RecommendationCard>`) is unrelated — that's per-card
context, not page-level — and stays as-is per the v1.4.16 spec.

Same patch drops the placeholder "Persoenlicher Berater" subtitle
(`insights.aiOverviewTitle`) the page passed in as the card's
secondary line. With no provider configured, the empty card
rendered just an icon and that subtitle above blank space, which
Marc described as "Titel da aber NICHTS PASSIERT". The CardTitle
("KI-Gesundheitsanalyse") is enough framing on its own; the
subtitle prop on `<InsightAdvisorCard>` becomes optional and
existing callers that still pass one keep their line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:12:24 +02:00
Marc-André BombeckandClaude Opus 4.7 60a91afaee feat(insights): comparison toggle on-surface in hero meta band
Move the Vormonat / Vorjahr comparison toggle from a standalone row
below the page hero into the hero's own meta slot. The previous
layout read as three stacked cards with separate header / control
bands — hero, then comparison toggle, then advisor card — and the
toggle row had no other content, so it just hung there in the gap.

Folding the toggle into the hero clusters every page-level control
(personal-baseline indicator, Generated timestamp, Regenerate
button, comparison baseline) into one band. The hero now exposes a
generic `metaSlot` prop so future page-level controls can join the
band without another component refactor.

Settings → Dashboard remains the power-user backdoor for either
surface; the dashboard's on-surface toggle stays gone (Marc said it
crowded the tile strip too much).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:11:39 +02:00
Marc-André BombeckandClaude Opus 4.7 a8562727a7 fix(dashboard): BD-Zielbereich headline shows all-time, not 30-day
Marc reported the BD-Zielbereich tile pinned at exactly 50/50/50 for
7T, 30T, and the headline ("total") on his real production data — the
fourth attempt at this surface (v1.4.15 → v1.4.16 → v1.4.18 → v1.4.19).

Root cause: the analytics route routed `bpInTargetPct` (the headline)
through `windows.last30Days?.pct` — a literal copy of the `30T`
sub-value. Since `computeBpInTargetWindows` only returned 7d + 30d
windows, the headline could never legitimately differ from `30T`. For
Marc's real data (572 paired readings since 2022, recent 30d = 50 %,
all-time ≈ 11 %) the tile read 50 % / 50 % / 50 % and looked
algorithmically pinned — exactly the symptom he reported.

The previous three passes treated this as either a predicate bug
(v1.4.16 ceiling-vs-narrow-band, real and merged) or a missing
`avg7`/`avg30` wiring (v1.4.18, also real and merged). Both fixes
landed correctly, but neither addressed the headline duplication.

Fix:
  - `computeBpInTargetWindows` now returns a third `allTime` window
    that aggregates EVERY paired reading in the input series.
  - The analytics route fetches all paired BP rows (no 30-day cutoff)
    and routes the headline through `windows.allTime?.pct`. The route
    only re-pairs once internally so the cost is paid once per request.
  - The 7T / 30T sub-values are unchanged (still windowed slices of
    the same input).

Failing test reproduces the bug with Marc's data shape (`allTime` MUST
not equal `last30Days` once older history is present); fix turns it
green. Integration test seeds 40 paired readings against real Postgres
and asserts all three windows compute independently.

Hand-counted against Marc's prod data:
  - 7T:        50 % (1/2) — matches what he sees, real coincidence
  - 30T:       50 % (5/10) — matches what he sees, real coincidence
  - Headline:  ~11 % (62/572) — was 50 %, now correct

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:01:08 +02:00
Marc-André BombeckandClaude Opus 4.7 b5e9a95227 fix(ai): no default-positivity opener about data quality (mention only when limited)
Every insight summary opened with the same "Datengrundlage ist sehr
stark" / "Your data foundation is strong" filler — the user does
not see what fields the snapshot carries, so the opener reads as
awkward stock praise.

Add ground-rule 7 to both locales of the system prompt: don't open
with a compliment about the data quantity or quality, mention data
quality only when it materially limits the analysis (n<7 in the
analyzed window, recencyDays>14 since the last entry, or a coverage
gap). When data is fine, dive straight into the analysis.
Banned-opener phrases listed verbatim in both EN and DE so the
model does not paraphrase the same sentiment.

PROMPT_VERSION bumped 4.16.1 → 4.19.0 so feedback aggregation can
distinguish responses generated under the new rule.

New test file pins the rule, the trigger thresholds, the banned
phrases, and the dive-straight-in instruction in both locales.
Existing PROMPT_VERSION pins relaxed to allow any 4.x.x ratchet so
future minor bumps don't sweep up these assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:58:53 +02:00
Marc-André BombeckandClaude Opus 4.7 3048dd6637 style(prettier): sweep formatting across the tree
Pre-existing format drift accumulated across v1.4.x; running
prettier brings the whole tree back to baseline so future diffs
read clean. No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:05:40 +02:00
Marc-André BombeckandClaude Opus 4.7 cf75579470 fix(dashboard): wrap chart-overlay-prefs PUT in Serializable transaction
Two concurrent toggles on different charts (user opens two tabs,
flips BP "Target range" in one and Weight "Trend" in the other
within ~50ms) could drop one of the updates. Both reads landed on
the same dashboard-layout snapshot, then each wrote its merged blob
back, with the second write clobbering the first chart's change.

Wrap the read-modify-write in a $transaction with Serializable
isolation so the second writer either sees the first writer's
result or aborts. Same pattern any future widget-prefs route should
follow until we move the JSON to a jsonb_set-based atomic merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:03:03 +02:00
Marc-André BombeckandClaude Opus 4.7 fbf14fceb8 fix(mood-chart): make chartKey opt-in and don't leak overlay state across pages
The MoodChart component defaulted chartKey to "mood", which meant
every consumer that omitted the prop — including /insights and the
recommendation-card mini chart — picked up the dashboard's persisted
overlay prefs and rendered the settings cog. Toggling Trend on
/insights would silently flip the dashboard mood chart on next paint
because both reads shared the same persistence key.

Drop the default; the dashboard now passes chartKey="mood" explicitly
and ad-hoc surfaces stay clean (no cog, default overlays). Also wraps
the title row's right-side container with flex-wrap + justify-end so
the 4 range tabs + cog don't push the card horizontally on Pixel-5
when the comparison caption is also in the row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:02:27 +02:00
Marc-André BombeckandClaude Opus 4.7 720e6c89b9 refactor(v1.4.18): apply simplify-review safe suggestions
Seven mechanical cleanups, no behaviour changes:

- Drop the duplicate ChartOverlayPrefs type + DEFAULT in
  chart-overlay-controls; re-export the canonical pair from
  dashboard-layout so a future fourth toggle can't drift between
  the UI and persistence layers.
- useChartOverlayPrefs accepts null/undefined chartKey and short-
  circuits the dashboard-layout fetch when none is supplied. Drops
  the chartKey ?? "bp" fallback and the three discard-the-result
  ternaries in health-chart; mini-mode renders no longer subscribe
  to the BP chart's persisted prefs.
- /api/analytics no longer pair-and-counts BP twice — the windowed
  helper already produces last30Days, so the headline reads it back
  instead of running computeBpInTargetPct on the same input.
- Drop the redundant moodEntries.map shape in the achievements
  route; the prisma select already returns the matching shape.
- Merge the five hidden-metric cases into the no-precondition block
  of isEarnable; the defensive comment admitted they couldn't fire.
- Remove the deprecated getAchievementCategory wrapper (zero callers).
- Strip six "v1.4.18 gradient removed" what-comments scattered
  across the chart wrappers; the polish tests pin the absence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:01:01 +02:00
Marc-André BombeckandClaude Opus 4.7 545f44c074 fix(achievements): redact hidden-locked entries from API response
Hidden Easter-eggs leaked their trigger semantics through the JSON
response: a curious user with DevTools open could read metric names
(nightOwlCount, leapDayCount, etc.), i18n keys (hiddenNightOwl,
hiddenLeapDay, etc.), icon names, and target values without unlocking
anything. The opaque-card UI was already correct, but the wire shape
spoiled the surprise.

Project hidden+locked entries to a sentinel shape before serialising
(achievements.hiddenCard.title key, HelpCircle icon, target 0,
metric totalTakenIntakes), and drop hidden-only metric counters from
the metrics block unless the matching achievement is unlocked. The
iOS branch reuses the same redacted view so native clients can't
short-circuit the surprise via the resolved-strings shape.

Once an Easter-egg unlocks the real fields ship — the unlock IS the
reveal moment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:56:46 +02:00
Marc-André BombeckandClaude Opus 4.7 75c74f1c49 feat(achievements): worker re-evaluates new + hidden achievements on data change
Two trigger fixes so the new achievements actually fire end-to-end.

The doctor-PDF hidden Easter-egg used to watch a non-existent
`doctor-report.export` audit action. The real route emits two:
`doctor-report.generate` (the JSON helper) and
`doctor-report.pdf.generate` (the PDF route). Either now counts.

The locale-flip hidden Easter-egg used to watch
`settings.locale.update`, which no endpoint emits — the locale
field shares the catch-all `profile.update` audit row with every
other profile field. Profile-update now emits a separate
`settings.locale.update` row when (and only when) the locale value
actually changes (compares the new value to the prior persisted
locale, suppresses the row when they match), with the from/to
values in the details payload for traceability.

Already-existing TanStack Query invalidation map covers the new
metrics: `measurementDependentKeys`, `moodDependentKeys`, and
`medicationDependentKeys` already include
`queryKeys.gamificationAchievements()`, so any measurement / mood /
intake mutation triggers a refetch and the route persists the new
unlock to `UserAchievement` (existing v1.4.15 mechanism).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:34:32 +02:00
Marc-André BombeckandClaude Opus 4.7 23514b4800 feat(achievements): page renders unlocked + locked + hidden achievements with progress
Adds an opaque placeholder card for the new hidden Easter-eggs.
Locked hidden achievements paint a `<HelpCircle>` icon and use the
"Hidden achievement" / "Keep tracking — you might just stumble across
it." copy from i18n; the real title, description, metric and target
are never written to the DOM so a curious user can't pop the source
panel to learn the trigger. Once unlocked, the real strings reveal
themselves on the same card with the standard unlocked styling.

Adds the new lucide icons used by the v1.4.18 badges (Smile, Scale,
Moon, Sun, Sparkles, FileText, Languages, CalendarDays). Extends
`CATEGORY_LABEL_KEY` with the new mood + hidden buckets so the
discriminated-union check passes.

Page tests guard:

- locked hidden achievements paint the placeholder, not the real
  title/description (regression: hidden cannot leak)
- unlocked hidden achievements paint the real title (the reward)
- hidden category appears under its own localized heading in EN+DE
- the `data-slot="achievement-card-hidden"` selector exists for E2E

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:34:32 +02:00
Marc-André BombeckandClaude Opus 4.7 c41f9a4307 feat(achievements): evaluators for new achievements (with discovery rules)
Wires the v1.4.18 expansion metrics into the gamification route.

- Fetches `MoodEntry` rows alongside the existing measurement /
  intake / passkey / audit fetches so the mood badges have data.
- Extends the audit-log query with `doctor-report.export` and
  `settings.locale.update` (the new hidden Easter-egg triggers).
- Pushes the raw rows through `buildExpansionMetricValues` —
  mood streak / improvement, weight/BP/pulse counts, consistent-month,
  entry day-streak, weekend warrior, and the hidden trigger counts.
- Computes `EarnabilityFlags` from the same numbers and applies
  `applyDiscoveryFilter` to the evaluated achievement list. Locked
  public badges that the user has *no path* to earn yet are filtered
  out; hidden Easter-eggs and already-unlocked badges always pass.
- Recomputes the headline summary (`unlockedCount`, `totalCount`,
  `earnedPoints`, `totalPoints`, `nextAchievement`) from the visible
  set so the dashboard counters match the rendered list. The
  `nextAchievement` selector skips hidden cards so we never tease
  the user with a "next goal: ?" suggestion.

The iOS format mock had to grow a `moodEntry.findMany` mock; that's
a one-line pure-test change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:34:32 +02:00
Marc-André BombeckandClaude Opus 4.7 19b08442b4 feat(achievements): definitions for v1.4.18 expanded set + hidden Easter-eggs
Lifts the roster from 38 to 59. Adds three buckets:

- mood (4 badges): first entry, 7-day diarist, 30-day diarist,
  brighter-week (mood mean improved by ≥1.0 vs prior 7-day mean)
- vitals counts (7): first/50/200 weigh-ins, first/50/200 BP readings,
  first pulse log
- engagement (4): consistent month (≥25 distinct days in a calendar
  month), entry-streak 7d/30d, weekend warrior

Plus 6 hidden Easter-eggs: night-owl, early-bird, leap-day,
doctor-PDF export, locale-flip, bug-buddy (≥5 reports). Hidden
achievements never leak their conditions to the DOM — locked cards
paint a generic "Hidden achievement" placeholder.

Adds `EarnabilityFlags` + `applyDiscoveryFilter` so a public locked
achievement only renders when the user has the underlying data
(e.g. a mood-streak badge stays hidden until they log their first
mood). Hidden Easter-eggs ignore the flags. Already-unlocked badges
always render so deleting data doesn't make completed achievements
disappear.

EN+DE strings for every new badge plus the hidden-card placeholder
copy and the hidden-unlock toast strings. CATEGORY_LABEL_KEY now
covers mood + hidden so the discriminated-union check passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:34:32 +02:00
Marc-André BombeckandClaude Opus 4.7 d70a9b1ee9 feat(charts): persist per-chart overlay prefs (default off; clean line)
Hook the overlay-controls dropdown into a TanStack Query mutation so
each chart's three toggle states (7-day trend / Trend arrow / Target
range) save per user and survive reloads. The prefs piggy-back on the
existing User.dashboardWidgetsJson blob with a `chartOverlayPrefs`
keyed by chart id, mirroring the v1.4.16 phase B8 comparison-baseline
pattern — no Prisma migration needed.

Wires up:
  - GET /api/dashboard/widgets surfaces the resolved prefs in its
    response so a fresh dashboard load knows which overlays to paint.
  - PUT /api/dashboard/chart-overlay-prefs replaces a single chart's
    prefs without round-tripping the whole layout.
  - Existing PUT /api/dashboard/widgets preserves any chartOverlayPrefs
    the client doesn't send, so saving widget visibility doesn't wipe
    chart prefs.
  - useChartOverlayPrefs hook reads + optimistically updates the cache.
  - HealthChart, MoodChart, MedicationComplianceChart all consume the
    hook, render the controls dropdown next to the range tabs, and gate
    their three overlays behind the persisted toggles.

Default state for every chart, every flag: false. The clean line is
the new default; users opt into overlays they care about per chart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:34:08 +02:00
Marc-André BombeckandClaude Opus 4.7 3e1607471b fix(admin): suppress painted scrollbar on mobile section strip
Production probe at /admin/api-tokens on a Pixel-5 viewport pinned the
scrollbar that kept showing up after two earlier fixes: it lives in
the AdminShell mobile section strip (13 entries, scrollWidth ~1700 vs
clientWidth ~360), not in the api-tokens table. The strip sits right
above the api-tokens card, which is why every previous attempt
targeted the table while the bar stayed put.

Adds a `no-scrollbar` utility in globals.css (scrollbar-width: none +
::-webkit-scrollbar { display: none }) and applies it to the strip in
both AdminShell and SettingsShell. Swipe and keyboard-arrow scrolling
keep working; the painted bar simply doesn't draw.

New AdminShell unit suite locks in the class so a future refactor
can't silently regress it; settings-shell test gets the matching
assertion. The api-tokens e2e spec gains a regression check that the
strip declares `no-scrollbar` and overflow-x-auto together.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:16:56 +02:00
Marc-André BombeckandClaude Opus 4.7 23363ca05e fix(dashboard): wire 7T and 30T sub-values on the BD-Zielbereich tile
The tile rendered the 30-day headline correctly but the `7T:` and
`30T:` sub-values fell back to "—" even when the user had paired BP
readings in both windows. Root cause was that `/api/analytics` only
returned a single `bpInTargetPct`; the tile received `avg7={null}`
and `avg30={null}` and the TrendCard rendered the dash placeholder.

Add `computeBpInTargetWindows()` next to the existing `computeBpInTargetPct()`
helper. It filters the input series by `measuredAt` against the supplied
clock and runs the same pair-and-count logic over the trailing 7-day and
30-day windows. The route fetches the last 30 days of data once, derives
both shares from the result, and surfaces them as `bpInTargetPct7d` and
`bpInTargetPct30d` on the response. The dashboard tile maps them to
`avg7` and `avg30` so the sub-values now show the actual percentages.

Coverage:
- 6 unit tests for the windowed helper (empty input, mixed window data,
  null-7d-with-real-30d, default-clock, Marc's production fixture).
- 2 integration tests against testcontainers seeding 30 days of paired
  BP measurements; both windows produce the expected hand-counted shares.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:14:11 +02:00
Marc-André BombeckandClaude Opus 4.7 c63cddc6dd style(insights): prettier sweep on C1 + C2 wire-up files
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:34:09 +02:00
Marc-André BombeckandClaude Opus 4.7 8a5b6de27e fix(dashboard): mount InsightsCardPreview on root page (wire B1b dashboard preview to live route)
The polished InsightsCardPreview component shipped in B1b commit 5
(d2cdf9d) was an orphan with zero non-test imports. The dashboard read
no advisor payload, so the top severity-ordered AI recommendation +
ring confidence meter + "View all" CTA was unreachable from any live
route. Wave-D design review flagged this as CRITICAL C2.

Wires the preview into the root dashboard page above the chart row,
sharing the advisor cache key with /insights so a regenerate on either
surface refreshes the other without a second LLM call. The preview
self-hides when the user has no provider configured (route 422 → null)
or the advisor returns zero recommendations.

Adds insightsPreview to DASHBOARD_WIDGET_IDS so the layout toggle
persists through Settings → Dashboard. Pins the widget into
DEFAULT_DASHBOARD_LAYOUT (visible: true, tileVisible: false) so users
discover the feature without hunting through settings, and adds a
contract test mirroring the A5 widget-enum-drift guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:33:01 +02:00
Marc-André BombeckandClaude Opus 4.7 aae968a6d5 fix(insights): mount InsightAdvisorCard on /insights page (wire B5c/d/e + B1b polish to live route)
The /insights page rendered only the text-only InsightStatusCard per
section, hiding all of B5c (rec rationale + mini-chart), B5d (confidence
meter), B5e (thumbs feedback) and the broader B1b polish from users.
Wave-D design review flagged this as CRITICAL C1.

Wires the InsightAdvisorCard component into the live route, consuming
the existing /api/insights/generate POST. The route already returns the
24h-cached payload without burning a rate-limit token, so a single
POST-without-force is functionally a GET-or-generate — no new API
surface needed (the reconcile report deferred C1 because it assumed
otherwise).

The cache is shared with the dashboard preview via the new
queryKeys.insightsAdvisor() so a regenerate on either surface refreshes
the other without a second LLM call.

Per-section InsightStatusCards stay below as supplemental detail; the
hero now also wires its regenerate button to the same handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:28:52 +02:00
Marc-André BombeckandClaude Opus 4.7 5f7b9d8652 fix(api): provider-chain GET preserves disabled entries (H2 code-review)
The GET response used to surface the resolveProviderChain()-filtered
list, which DROPS disabled entries upstream. The Settings UI then
seeded `enabled: true` for every returned row, so disabling an entry
made it disappear on next refetch and lose its toggle position —
the toggle was functionally a "remove from chain" button.

Changed GET to read the *persisted* chain via parseProviderChain() so
disabled rows still round-trip with their flag. The active-provider
summary still uses resolveProviderChain so the displayed "Active: …"
line continues to reflect the actual runner ordering (enabled-only).
The Settings UI seeds entries with the wire-supplied `enabled` value
instead of always-true, so re-enabling becomes possible without a
page reload.

4 unit tests pin the GET shape (persisted-chain ordering, disabled
entries surfaced, cached-active overlay, default-chain fallback).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:14:29 +02:00
Marc-André BombeckandClaude Opus 4.7 6863ecb87d fix(api): rate-limit feedback endpoint + drop overly broad cache invalidation (H5/H6 code-review)
H5 — `POST /api/insights/feedback` had no rate limit. The unique
index `(userId, recommendationId, recommendationText)` blocks exact
replays but a malicious client could distort the daily aggregator's
bucket slice by varying recommendationId/text per request. Added
`checkRateLimit("insights-feedback:<user.id>", 60, 60 * 60 * 1000)`.
60/h is generous given a comprehensive insight rarely has >10 recs.

H6 — `<RecommendationFeedback>` invalidated the entire `["insights"]`
prefix on every thumbs click, which matches all 8 insights queries
(comprehensive + 7 per-status). None of them depend on the feedback
row. The 24h server cache absorbs the actual LLM round-trip but the
network thrash + stale-cache refetch was waste. Dropped the
invalidation; user-visible verdict state lives in localStorage +
local React state. v1.4.17's prompt-version ratchet will scope its
own invalidation when feedback feeds the next generation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:11:34 +02:00
Marc-André BombeckandClaude Opus 4.7 56614392be fix(charts): medication-compliance shows comparison N/A caption when toggle is on (H4)
The comparison overlay was added to BP / weight / pulse / mood charts
in B8 but the medication-compliance heatmap doesn't render a
prior-period overlay (the underlying intake-event window comparison
is deferred to v1.4.17). Without an explicit indicator the user
toggling Vormonat would see this card stay static and read it as a
broken toggle. Added a short "Comparison N/A for compliance" caption
under the header when the global compareBaseline is active so the
asymmetry is visible. EN+DE i18n.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:09:41 +02:00
Marc-André BombeckandClaude Opus 4.7 fb12f098e6 fix(security): redact secrets in admin app-logs meta + audit-log details (H1/H2)
`redactEventForEgress()` only redacted `error.message`, `error.stack`,
`warnings`, `http.user_agent` and `external_calls[*].error`. The
free-shape `meta` field and `action.details` blob carry annotated
key=value pairs from `annotate()` calls — including the
provider-runner's `ai_chain_hop_<n>_reason` which captures the upstream
error body capped at 240 chars. If an upstream provider ever echoes a
Bearer token in its error body, the raw token would land in `meta`
without any scrub. Added a recursive `redactDeep()` walker that runs
`redactSecrets()` over every string-shaped value inside both blobs.

Same gap on `/api/admin/audit-log`: the `details` JSON column was
returned verbatim. A user-supplied identifier in a failed-login row
(e.g. a typo'd "Bearer hlk_..." in a username field) flowed through
the deepened B4 admin viewer + CSV export. Run `redactSecrets()` over
each row's `details` at egress so already-persisted rows are scrubbed
on read without a backfill migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:03:54 +02:00
Marc-André BombeckandClaude Opus 4.7 6e74d38213 fix(design): on-surface comparison toggle on dashboard + insights (C3)
The B8 comparison overlay shipped behind a Settings → Dashboard Select
buried 3 clicks deep. The brief asked for "mobile-friendly comparison
toggle (not a fragile hover-only interaction)" and the toggle that
shipped failed that bar — flipping Vormonat / Vorjahr meant a 5-step
round-trip every time.

New `<CompareToggle>` segmented control (None / Vormonat / Vorjahr)
sits next to the dashboard greeting and on the insights page hero.
Persists via the same `/api/dashboard/widgets` PUT the Settings
section already uses, with optimistic update + cache invalidation so
the chart overlays + tile callouts respond instantly. 44 px (min-h-11)
tap targets, focus-visible ring, role=group + aria-label for screen
readers, EN+DE i18n.

6 SSR tests pin segment count, persisted-active state, tap-target
floor, EN+DE labels, and ARIA shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:00:12 +02:00
Marc-André BombeckandClaude Opus 4.7 f9f99ea24a feat(insights): AI narrates comparison when toggle is active
Extend `buildUserPrompt()` to accept an optional `ComparisonSnapshot`
that, when supplied, appends a SYSTEM CONTEXT block listing each
metric's current 30d average, baseline 30d average (from 30 / 365
days back), absolute delta, and percentage delta. The strict system
prompt gains a sixth ground rule that instructs the model to narrate
the most clinically-significant non-null delta in the summary's first
sentence and forbids inventing comparison numbers.

The route reads the user's persisted `dashboardWidgetsJson.compari-
sonBaseline` and builds the snapshot from a per-metric `summarize()`
sweep so the numbers the LLM sees match what the dashboard tile
renders — single source of truth for the comparison delta. Comparison
is default-on whenever the toggle is on per research §7 Q4: there's
no secondary "include in AI" preference, the pulldown is the single
affordance.

PROMPT_VERSION bumped 4.16.0 → 4.16.1 so the Wide-Event annotations
attribute responses to the new prompt revision.

Eight new unit tests pin: empty-comparison block render, EN/DE
locale switch, lastMonth vs lastYear language, the
"summary first sentence" instruction, and the anti-hallucination
"do NOT invent" guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 02:36:35 +02:00
Marc-André BombeckandClaude Opus 4.7 e4a408eaf5 feat(dashboard): tiles show comparison delta callout when toggle is active
Extend DataSummary with `avg30LastMonth` and `avg30LastYear` fields —
30-day means over the [30, 60) and [365, 395) day windows. The
existing `summarize()` helper computes them once for every metric the
analytics API returns, so every dashboard tile picks them up
automatically without extra DB queries.

TrendCard gains optional `compareBaseline` + `compareDelta` props.
When both are set the tile renders a "Δ −2.3 kg vs. last month" line
below the latest value, colour-coded via the existing
`directionSentiment` rules — down on an up-bad metric (weight, BP)
paints green; down on an up-good metric (mood, sleep, steps) paints
orange; neutral metrics stay muted; deltas under |0.05| read as
stable and stay muted regardless.

Mobile-friendly: the callout sits on its own line below the latest
value so the tile stays scannable on Pixel-5 viewports without
horizontal scroll. Suppressed entirely when comparison is off OR
either side is missing data — strict regression guard for the
default-off path.

The dashboard's `tileCompareDelta()` helper resolves the prior-period
field from the active comparison toggle and feeds every TrendCard the
same delta value, so a single toggle flip in Settings → Dashboard
updates every tile atomically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 02:30:25 +02:00
Marc-André BombeckandClaude Opus 4.7 2cf7b746ac feat(charts): comparison overlay shows prior-period as dimmed line with delta tooltip
Add a `compareBaseline` prop to HealthChart and MoodChart that, when
set to "lastMonth" or "lastYear", overlays the prior-period series as
a dimmed dashed line beneath the current data. The shift is computed
in-memory by `shiftDailySeriesForward` — the chart already fetches the
full measurement history so no extra API call is needed.

The tooltip switches its delta line from "vs. your normal" to "vs. last
month / last year" when comparison is active and a prior value exists
for the hovered day, and adds a second row with the prior absolute
value so both numbers are visible. The chart header gains a
"vs. last month / last year" caption (or an "unavailable — no data
yet" fallback when the prior period has no overlap).

The dashboard page and the insights page both read the persisted
preference from `User.dashboardWidgetsJson.comparisonBaseline` so a
single toggle flip in Settings → Dashboard updates every chart on
both surfaces atomically.

Pure shift helper lives at `src/lib/charts/comparison-shift.ts` with
11 unit tests pinning the day-exact forward shift, the average helper,
and the delta computation (null-safe).

Regression guard: when comparisonBaseline is "none" (default), the
chart renders exactly as before — no extra paint, no caption, no
tooltip change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 02:25:46 +02:00
Marc-André BombeckandClaude Opus 4.7 775df8cfe9 feat(comparison): comparison toggle persists baseline preference
Add a "Compare to" pulldown to the Settings → Dashboard section that
lets the user pick None / Last month / Last year. The preference rides
on the existing User.dashboardWidgetsJson blob — research §7 Q3 settled
on the JSON-pivot path so v1.4.16 ships migration-free.

The resolver defaults to "none" for legacy layouts and clamps unknown
values back to "none", so a stale client cannot poison the dashboard
with a baseline the renderer doesn't know how to draw. The PUT
endpoint accepts the new optional comparisonBaseline field; pre-B8
clients keep PUTing without it and see no behavioural change.

EN+DE i18n keys land for the toggle label, the three options, the
"unavailable" fallback, and the chart caption — Vormonat / Vorjahr per
Marc's brief.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 02:16:41 +02:00
Marc-André BombeckandClaude Opus 4.7 4d7d0748fa feat(insights): page hero with gradient header + generation timestamp + personal-baseline indicator
Wraps the /insights heading in a Dracula-gradient hero band with the
"Generated <relative-time>" caption, "Based on your last 90 days"
personal-baseline indicator, and a regenerate slot. Pure shell so the
page only passes timestamps in. Animation uses the existing
animate-insight-in keyframes which already gates on
prefers-reduced-motion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:54:35 +02:00
Marc-André BombeckandClaude Opus 4.7 887928211b feat(admin): AI quality preview showing per-(severity x provider) helpful-rate
v1.4.16 phase B5e: ship the `/admin/ai-quality` route that surfaces
the daily aggregator's helpful-rate per (severity x metricSourceType
x provider x prompt-version) bucket. Renders as a compact 7-column
table with severity tinted by clinical-importance colour and the
helpful-rate column tinted green/yellow/orange by 0.8/0.5
thresholds.

New endpoint `GET /api/admin/ai-quality` (requireAdmin-gated) returns
the raw `AppSettings.adminAiInsightsFeedbackSummary` blob so the
v1.4.17 ratchet that consumes the same shape for prompt-tuning can
read what the worker writes without an envelope-reshape step.

Empty state ("no summary yet" / "buckets[] is empty") rendered as a
quiet message rather than a fake zero row so the operator can tell
"no feedback" apart from "all-zeros". Loading + error banners follow
the existing host-metrics pattern.

Section wiring:
  - new `ai-quality` slug in ADMIN_SECTION_SLUGS (between
    integrations and feedback so the IA flows General → Integrations
    → AI Quality → Feedback Inbox)
  - new Sparkles icon entry in admin-shell
  - new switch case in [section]/renderer.tsx
  - EN+DE i18n keys: admin.section.ai-quality.{title,subtitle} +
    admin.aiQuality.{title,loading,loadError,empty,windowLabel,
    generatedAtLabel,col*}

Tests:
  - 5 new ai-quality-section tests (loading, empty-null, empty-buckets,
    table-render with multiple buckets, error)
  - 1 admin-sections smoke test ensures the empty branch renders +
    1 slug-list parity test

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:44:47 +02:00
Marc-André BombeckandClaude Opus 4.7 8255e3e49a feat(api): /api/insights/feedback persists thumbs with provider attribution
v1.4.16 phase B5e: ship the POST endpoint that backs the per-rec
thumbs-up / thumbs-down UI. Body is the four rec-attributes the
client just rendered (id, text-snapshot, severity, metric-source +
time-range) plus the boolean verdict; server-fills providerType +
promptVersion via `resolveFeedbackAttribution()` so the client cannot
poison the daily aggregator's slice by claiming a thumbs-down was
attributed to a rival provider.

Idempotent via the existing `withIdempotency()` wrapper — a retry
with the same Idempotency-Key replays the cached response with
`X-Idempotent-Replay: true`. The dedup unique key on the model
(userId, recommendationId, recommendationText) returns 409 when the
same rec-text is re-rated — that's by design, the aggregator wants
per-text signal not per-id signal so a regeneration that rewrites
the same id with new text re-rates cleanly.

The attribution helper prefers `chainProviderType` over `providerType`
in the latest insights.generate audit row so when the B5b multi-
provider chain falls through to a secondary, the feedback row
reflects the working provider not the configured one. Falls back to
"unknown" + the current PROMPT_VERSION constant when no audit row
exists yet — the aggregator buckets that separately.

Audit row: action `insights.recommendation.feedback`, details carry
the feedback id, severity, helpful, providerType, promptVersion.

Tests:
  - 7 unit tests for the Zod request schema (valid path, severity
    poisoning, time-range poisoning, empty fields, stringified bool,
    extra-field stripping)
  - 5 unit tests for `pickProviderType()` priority + fallback
  - 4 integration tests: 201 happy path with provider attribution,
    409 dedup, 422 validation, fallback to unknown when no audit row

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:30:06 +02:00
Marc-André BombeckandClaude Opus 4.7 c8b30c112d feat(ai): legacy insight payload detection with regenerate CTA
Cached insight blobs persisted before B5c shipped don't carry the
new `rationale` field — the strict parser would reject them, and
auto-regenerating on every cache-hit would burn rate-limit tokens
silently. Instead the route now detects the legacy shape and
surfaces a `legacyPayload: true` flag in the response so the UI
can render a one-time "regenerate for new explainability features"
CTA. User-initiated regeneration stays the trigger.

`isLegacyInsightPayload(payload)` lives in `src/lib/ai/legacy-payload.ts`
and is intentionally lenient: any rec missing rationale (or the
v1.4.14/v1.4.15 string-only InsightResult shape) flags. Empty
recommendations[] is NOT legacy — it's a legitimate refusal payload.

The annotate() Wide-Event also carries `legacyPayload` on cached
hits so the admin AI quality dashboard can chart the migration
ratio over time. v1.4.17 can flip auto-eviction on once the
ratio drops near zero.

7 new tests in `legacy-payload-detection.test.ts` pin the contract.
Existing 223 AI tests still green; typecheck clean.

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