Commit Graph
485 Commits
Author SHA1 Message Date
Marc-André BombeckandClaude Opus 4.7 008a8fba3e revert(charts): remove gradient background fills (clean lines only)
The Apple-Health-style gradient area underneath each chart line muddied
the visual. Drop the linearGradient defs and the area-fill primitives on
every chart wrapper and let the line stroke speak for itself. Tooltip,
animation, and Dracula tokens stay as they were.

Also delete the chart-gradient module — there are no remaining call
sites once the fills come out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:19:46 +02:00
Marc-André BombeckandClaude Opus 4.7 874878fdc3 chore(planning): record A2 painted-scrollbar fix in shells
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:18:12 +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 15c852f48e chore(planning): record A1 BD-Zielbereich tile sub-values fix
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:16:24 +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 fc328abf49 chore(planning): bootstrap v1.4.18 marathon
Scaffold STATE+ROADMAP for the v1.4.18 cycle. Wave A picks up four
reported items: BD-Zielbereich tile sub-values render "—" instead of
real numbers, `/admin/api-tokens` still scrolls horizontally on mobile
(third attempt — live-verify against prod with Playwright before
touching code), and a chart visual rollback (drop the gradient fill,
drop the mood emoji glyphs, drop the auto-overlay baseline line) with
three opt-in toggles per chart wired through Settings. Wave B is the
achievements expansion: research consumer-health badge systems first,
then ship 15-25 new ones plus 5-8 hidden cards. Wave D runs the usual
review loop, Phase E ships v1.4.18.

Tracked files clean on entry. The four stale dotted-segment route
directories under `src/app/api/export/` and the three v1.4.16 release
reports stay untracked — they belong to the previous milestone, not
this one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:05:08 +02:00
Marc-André BombeckandClaude Opus 4.7 983bb2e5f5 chore(planning): mark v1.4.17 hotfix live in STATE.md and report
- /insights smoke returns HTTP 200 after force-pull on apps-01.
- Image digest moved 05f8a126... -> 936e9cf2...
- /api/version transitioned 1.4.16 -> 1.4.17 at 07:58:12Z.
- GH release published.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 09:59:35 +02:00
Marc-André BombeckandClaude Opus 4.7 da7070e222 style(insights): prettier sweep on legacy-payload hotfix files
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 09:51:02 +02:00
Marc-André BombeckandClaude Opus 4.7 adab80ac81 chore(release): v1.4.17
Hotfix release for the legacy-payload /insights crash users hit on
2026-05-10 the morning after v1.4.16 shipped. See CHANGELOG.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.17
2026-05-10 09:49:39 +02:00
Marc-André BombeckandClaude Opus 4.7 79bfa27d65 fix(insights): handle legacy cached payload without rationale (regenerate CTA)
Cached insight blobs from before the v1.4.16 strict schema landed
crashed /insights with `Cannot read properties of undefined (reading
'replace')` on `stripChartTokens(insight.summary)`. The pre-strict
shape (`{changed, stable, drivers, nextSteps, confidence,
limitations}`) has no `summary`, no `recommendations[]`, and no
`findings[]`, so the rich card had nothing to render and the
chart-token helper crashed on undefined input.

The advisor card now short-circuits to a self-contained legacy-payload
card with a one-click regenerate CTA when the shape is unrenderable.
`isLegacyInsightPayload()` also flags the v1.4.14 pre-strict shape so
the API surfaces the right `legacyPayload: true` flag from a cache
hit. Defense-in-depth: `stripChartTokens()` and `parseChartTokens()`
treat null/undefined as the empty string instead of crashing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 09:49:17 +02:00
Marc-André BombeckandClaude Opus 4.7 57de2177a9 docs(audit): v1.4.16 release summary
Closing brief for the v1.4.16 release: live image digest, what shipped
across waves A-E, what was deferred to v1.5, CI/test status, production
state, docs/landing sync. STATE.md status flipped to finished.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:56:47 +02:00
Marc-André BombeckandClaude Opus 4.7 d443c22d4e chore(release): v1.4.16
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.16
2026-05-10 03:39:53 +02:00
Marc-André BombeckandClaude Opus 4.7 5e893822db docs(planning): mark CRITICAL C1 + C2 wire-up complete
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:35:06 +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 54bbc68eff docs(planning): phase-D reconcile report + v1.5 backlog seed + STATE.md update
Captures the v1.4.16 phase-D reconcile outcome:
- 1 of 3 CRITICAL fixed inline (C3 on-surface comparison toggle).
  C1 + C2 deferred to v1.4.17 with documented rationale (root cause:
  /insights + dashboard preview need API surface changes that exceed
  reconcile scope).
- 9 HIGH fixed (a11y tap targets, i18n leak, security redaction,
  feedback rate-limit, fallback chain enabled flag, medication
  comparison caption); 11 deferred.
- 7 of 8 simplify-yes applied.
- v15-backlog.md seeded with deferred items + Product-Lead C.1-C.11
  roadmap + v1.6+ follow-on initiatives.

Final verification green: typecheck 0, lint 0 errors, test 1539/1539,
integration 59/59.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:21:21 +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 f3025a8dd5 refactor(v1.4.16): apply simplify-review safe suggestions
7 of 8 apply-yes simplify findings landed:

F2 — `generateInsight()` confidenceContext config-bag dropped. No
production caller passed the resolver; the wrapper now derives inputs
from the parsed payload directly (n from metricSource.n, recencyDays
=0, deviationStdRatio=null). Two confidence-wrapper tests rewritten
to test the parsed-payload path. v1.4.17 will re-introduce a
resolver injection when the feedback ratchet ships a real second
caller.

F3 — `aiRecommendationRationaleSchema.referenceId` speculative
optional field removed. No consumer reads it; rec-level referenceId
is the canonical citation pointer.

F4 — `applyLastWorkingCache` doc-comment trimmed: dropped the
"NEW array" defensive note (no caller compares the input ref).

F5 — `<RecommendationFeedback>` re-uses the canonical
`RecommendationFeedbackRequest` types from the validation schema
instead of hand-maintaining the same enum vocabulary. Removes a
silent-drift risk if v1.4.17 adds another time-range value.

F6 — `formatRelativeTime` doc-comment trimmed: dropped the stale
`Intl.RelativeTimeFormat` polyfill claim (baseline-supported since
2018) — kept the genuine WHY (i18n consistency).

F7 — `<RecommendationCard>` 26-line slot-architecture header trimmed
to a 4-line WHY. The slots being narrated as "future" were filled by
B5d / B5e in the same file.

F9 — `pickProviderType` doc-comment trimmed: removed the
mis-anchored PROMPT_VERSION snapshot paragraph (the function only
picks providerType — promptVersion is read elsewhere).

F10 — `legacy-payload.ts` `UnknownRec` interface removed; cast
inlined as `(rec as { rationale?: unknown })`.

F12 — `<ConfidenceMeter>` 20-line band-policy header trimmed to a
3-line WHY. The bandFor() helper + colour-by-band record below are
the documentation now.

(F1 + F8 deferred — both flagged "no" in simplify review and need
Marc's call.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:10:09 +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 2f057f4a07 fix(i18n): trend-card comparison aria-label uses translation key (H3)
The compareDelta `aria-label` interpolated `"vs. last month"` and
`"vs. last year"` as literal English strings regardless of locale.
A DE user got an English screen-reader announcement next to a
visible German caption ("Δ −2.3 kg vs. Vormonat"). Routed through
`t("comparison.captionLastMonth" | "comparison.captionLastYear")`
so screen-reader output matches the visible text in both locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:02:02 +02:00
Marc-André BombeckandClaude Opus 4.7 c3451a4c06 fix(a11y): 44px tap targets + focus-visible on rec-feedback, chart range tabs, export switch (H1/H2/H6/H7)
WCAG 2.5.5 minimum hit-area is 44×44 CSS px and the Wave-C polish set
that as the project floor. Several v1.4.16 surfaces missed it:

- Recommendation thumbs (h-7 = 28 px) — `<RecommendationFeedback>` up
  + down both bumped to `min-h-11 min-w-11` with focus-visible ring.
- Recommendation chevron expand toggle (~24 px, no focus state) —
  `<RecommendationCard>` rec-expand-toggle bumped to `min-h-11
  min-w-11` with focus-visible ring.
- Chart range tabs (7d/30d/90d/All) — `min-h-9 px-2.5` ≈ 36×28 px on
  Pixel 5; bumped to `min-h-11 px-3` across HealthChart, MoodChart,
  MedicationComplianceChart, ComplianceCharts, ComplianceLineChart.
- Medication CSV "include intake history" raw `<input
  type="checkbox">` (16×16, system-blue on iOS Safari) — replaced
  with shadcn `<Switch>` inside a `min-h-11` label so the row is
  properly tappable and visually consistent with every other
  settings toggle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:01:45 +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 c0c1284e27 docs(planning): refresh phase-D findings + add Product-Lead strategic review
Wave-D reviewer outputs for v1.4.16:
- code-review: 0 CRITICAL, 8 HIGH, 9 MED, 10 LOW
- security: 0 CRITICAL, 2 HIGH, 5 MED, 3 LOW
- design (Apple Health benchmarking): 3 CRITICAL ship-blockers (insights
  surface + dashboard preview not mounted on live routes; comparison
  toggle buried 3 clicks deep), 8 HIGH, 22 MED/LOW
- senior-dev: 0 CRITICAL, 3 HIGH (file-size/architecture pressure),
  9 MED/LOW
- simplify: 8 apply-yes / 2 apply-no
- product-lead: full state-of-app + v1.5 roadmap (C.1-C.11) + v1.6+
  follow-on initiatives

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 02:56:49 +02:00
Marc-André BombeckandClaude Opus 4.7 55cc0e49dd docs(planning): mark Wave-B B8 (extended comparison views) complete
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 02:38:40 +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 43204cbb86 docs(planning): mark Wave-B B1b (insights surface visual polish) complete
7 atomic commits on origin/main, +37 net tests (1464 → 1501),
typecheck/integration/lint clean. Page hero, severity-ordered grid,
polished summary typography, loading/empty/error states, dashboard
preview, dark-mode contrast — all live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 02:09:54 +02:00
Marc-André BombeckandClaude Opus 4.7 3b0d21e4e9 test(insights): coverage for polished page + dashboard preview
Adds an SSR integration suite that exercises InsightAdvisorCard +
RecommendationsGrid + RecommendationCard + ConfidenceMeter +
RecommendationFeedback all painting together so the cross-feature
slot positions don't silently regress. The B5c phase report flagged
that no e2e is feasible because /insights doesn't currently mount
the advisor card; the SSR test covers the same surface end-to-end.

Pins the severity-ordering preserves under low-confidence recs (an
urgent rec with 12% confidence still sorts above an important rec
with 80%) and confirms the grid wrapper unmounts cleanly when there
are no recs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 02:07:34 +02:00
Marc-André BombeckandClaude Opus 4.7 5063ad7369 style(insights): dark-mode contrast verification + tweaks
Bumps the page-hero gradient + border opacity from /10 + /20 to /15 +
/25 so the band reads more clearly against the dark Dracula
background; the cumulative effect on the dark page bg now sits at
~17% lightness delta which clears the 3:1 UI-element contrast bar.

Skeleton placeholders went from /50 + /40 + /30 to /60 + /50 + /40
so the loading state is visible on the dark canvas without being so
loud it competes with the live content next to it.

Pins the contrast utility classes via a regression test on the hero
shell so a future refactor can't silently drop them below the
visibility threshold.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 02:06:13 +02:00
Marc-André BombeckandClaude Opus 4.7 d2cdf9d664 feat(dashboard): InsightsCardPreview matches insights page visual language
Replaces the orphan v1.4.0 InsightsCard with a leaner
<InsightsCardPreview> that surfaces only the top 1-2 severity-ordered
recommendations + a "View all" CTA pointing to /insights. Same visual
language as the page (Dracula sparkle glyph, severity-coloured left
border, ring-variant confidence meter inline).

The legacy InsightsCard handled its own data fetching against the
deprecated changed/stable/drivers/nextSteps shape; preview now takes
an `insight: InsightResult | null` prop so the dashboard wires its
existing `/api/insights/generate` data without redundant fetches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 02:05:10 +02:00
Marc-André BombeckandClaude Opus 4.7 0c287f1545 feat(insights): polished loading + empty + error states
Loading state replaces the spinner-only placeholder with a 3-card
skeleton grid that mirrors the final layout, so the page doesn't
visually jump when content lands. Each placeholder pulses with a
100ms staggered delay.

Empty state gains an illustrated sparkle glyph + a centred copy block
(max-w-prose) so the affordance reads as an invitation rather than a
muted dead-end.

Error state moves from a flat banner to a card with a discoverable
retry button (data-slot=insight-retry-button) when an onRegenerate
handler is supplied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 02:02:46 +02:00
Marc-André BombeckandClaude Opus 4.7 b76351e1a8 feat(insights): polished summary typography with inline-chart sparklines
Bumps the InsightAdvisorCard summary surface to text-base / leading-
relaxed and constrains it to max-w-prose so the prose stays readable
on a wide viewport. Wraps the existing chart-token renderer in a
new mini flag so the summary's inline charts shrink to sparkline
form (h-[140px], no range tabs); per-finding inline charts keep the
full chart so the user can still interact with the data series.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 02:01:15 +02:00
Marc-André BombeckandClaude Opus 4.7 9e8be4b359 feat(insights): recommendations grid with severity ordering + animated reveal
Replaces the flat <ol> list inside InsightAdvisorCard with a responsive
2-col grid wrapper that severity-orders the recs (urgent → important →
suggestion → info), paints a Dracula-token left border per severity,
and staggers a 100ms fade-in across the row so the cards sweep in on
first render. Hover on desktop lifts the card by 0.5px and grows the
shadow.

Internal slots of RecommendationCard untouched — the severity border +
hover-lift + animation-delay live on a thin shell wrapper around the
card so the B5c/d/e plumbing (rationale, confidence, feedback) keeps
working unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:59:18 +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 c8e76399ba docs(planning): mark Wave-B B5e (user-feedback loop) complete
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:47:40 +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 5117266756 docs(planning): mark Wave-B B5d (confidence score) complete
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:42:50 +02:00
Marc-André BombeckandClaude Opus 4.7 173c3e1d9c test(insights): rec-card-confidence test plays nice with B5e thumbs
After B5e wired the feedback thumbs into rec-card-feedback-slot, the
B5d test needs the same useAuth + useMutation + tanstack-query SSR
mocks that the existing rec-card.test.tsx carries. The test that
asserted "rec-feedback-slot is empty" is relaxed to "rec-feedback-slot
is still rendered" — B5e legitimately fills it now, and the slot's
mere presence is what B5d needs to guarantee its plug-in point stayed
intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:40:29 +02:00
Marc-André BombeckandClaude Opus 4.7 d343ab5dc2 test(insights): coverage for confidence computation + meter rendering
End-to-end integration test that pins the wrapper-override contract
across the full stack:

  1. MockAIProvider returns a payload where the model claims
     confidence=99.
  2. generateInsight() parses + overwrites with the deterministic
     value (n=9, recencyDays=0, ratio=null → 65).
  3. InsightAdvisorCard renders the parsed payload through
     RecommendationCard → ConfidenceMeter.
  4. The rendered card shows the SERVER-computed band ("medium" /
     yellow), NOT the model-claimed 99 ("high" / green). The
     aria-label announces "Confidence: 65 of 100".

A second case pins the small-data shield: with n=1 the wrapper
hard-caps at <=15, the meter renders the draft pill (red), and the
"Draft" tag is visible in the markup — Apple's quiet-when-unsure
affordance flows through end-to-end.

Coverage now includes:
  - Unit: computeConfidence() — 12+3 fixtures (input matrix +
    monotonicity + integer invariants) (commit 1).
  - Wrapper: 6 cases pinning the override contract (commit 2).
  - Component: 17 cases on ConfidenceMeter band rendering, both
    variants, EN/DE aria-label, clamp behaviour (commit 3).
  - Card wiring: 8 cases on slot fill, low-confidence caption,
    feedback-slot left untouched (commit 4).
  - Integration: 2 cases on full-flow override + draft band
    (this commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:40:29 +02:00
Marc-André BombeckandClaude Opus 4.7 63cfd8e2f7 feat(insights): RecommendationCard renders ConfidenceMeter in named slot
The `data-slot="rec-confidence-slot"` placeholder reserved by B5c is
now filled with `<ConfidenceMeter value={rec.confidence} />`. The
slot stays empty when `rec.confidence` is undefined (legacy cached
payloads predate the field) so a missing value never gets dressed up
as a draft pill.

Below 50, the expanded rationale card surfaces a "Low confidence —
based on limited data" caption so the rec stays visible but framed
as preliminary. The caption suppresses above 50 — the meter alone
speaks. Below 25 the meter itself is replaced by a draft pill
(handled inside `<ConfidenceMeter>`); the caption still applies
because draft <= low.

The B5e feedback-thumbs slot
(`data-slot="rec-feedback-slot"`) is untouched. The wiring test
explicitly asserts the feedback slot is still present and empty so
the parallel-running B5e agent has a stable plug-in point.

Threshold lives in a single named const
(`LOW_CONFIDENCE_CAPTION_THRESHOLD = 50`) co-located with the band
ladder in `<ConfidenceMeter>` so a future tune-pass can adjust the
ratchet in one place.

8 component tests cover: meter renders in slot at 85, slot stays
empty when confidence is undefined, medium/low/draft band rendering,
caption shows below 50, caption hidden above, German caption
translation, feedback-slot untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:40:29 +02:00
Marc-André BombeckandClaude Opus 4.7 7ec10308b8 feat(insights): ConfidenceMeter component (bars + ring + draft pill below 25)
Renders the deterministic 0..100 score from `computeConfidence()` as
either a 5-bar meter (default) or a 28x28 SVG ring. Color bands:

  - >=80: green ("high"), 5 bars / full ring
  - 50..79: yellow ("medium"), 3-4 bars
  - 25..49: orange ("low"), 2 bars / partial ring
  - <25: red "Draft" pill REPLACES the meter — Apple's
    quiet-when-unsure affordance per research §2.A. The rec is
    drafted but the model isn't asserting it.

Bars rise in height across the row (4 / 6 / 8 / 10 / 12 px) so the
"more is more" cue is independent of color, satisfying the
non-color-vision a11y baseline. SVG ring uses stroke-dasharray with
the dracula band-stroke per band so the same color story is reused.

aria-label always announces the numeric score
("Confidence: 67 of 100" / "Vertrauen: 67 von 100"), even in the
draft-pill state, so screenreaders aren't blind to the visual band
distinction.

Inputs are clamped: NaN / Infinity / negative -> 0 (draft); >100 ->
100 (high). The component is otherwise a pure render — no state, no
effects.

5 i18n keys per locale: confidence, confidenceAria (interpolates
{value}), confidenceHigh, confidenceMedium, confidenceLow,
confidenceDraft. EN+DE parity test green.

17 component tests cover all 5 value bands (high/medium/low/draft +
band edges), both variants (bars/ring), aria-label translation in
EN+DE, and clamp behaviour at the negative + >100 ends.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:40:29 +02:00
Marc-André BombeckandClaude Opus 4.7 af21d4d4a8 feat(ai): wrapper overrides recommendation confidence with deterministic computation
After zod parse, `generateInsight()` invokes `computeConfidence()` for
every recommendation and overwrites `rec.confidence` — the model's
self-reported number is discarded. Calibrated probabilities are not
a small-LLM strength, so this is the deterministic substrate the
v1.4.17 feedback ratchet will tune.

The wrapper does not have direct access to the user's bucket-series
data, so an optional `confidenceContext` resolver lets the caller
(typically the route layer) supply per-rec `ConfidenceInputs` derived
from `metricSource.n` + the freshest bucket + the deviation magnitude
vs. the user's 90-day baseline. When the resolver is omitted, the
wrapper falls back to inputs derivable from the parsed payload alone:

  - n: `metricSource.n` (or 0 — triggers the n<3 hard-cap)
  - recencyDays: 0 (defensive default; assumes window ends "now")
  - deviationStdRatio: null (neutral 15-point signal contribution)

The override fires regardless of resolver presence so the contract
is "the confidence value on the response NEVER comes from the LLM".

A new structured Wide-Event annotation
(`ai_confidence_override_event` = `confidence.override.applied`)
carries per-rec `{id, model, computed}` triples so admin observability
can chart drift between the model's claimed confidence and the
deterministic value.

`annotateCitationCoverage()` now runs before `applyConfidenceOverride()`
so the existing citation/rationale-coverage tests can still match
against `mock.calls[0]`. The citation-coverage test that asserted
`toHaveBeenCalledTimes(1)` was relaxed to look up the right call by
meta-key rather than by ordinal.

6 new wrapper tests cover: model value discarded, wrapper-fills when
absent, resolver path overrides default, n<3 hard-cap survives strong
signal, missing `metricSource.n` -> n=0 floor, resolver receives each
rec in payload order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:40:29 +02:00
Marc-André BombeckandClaude Opus 4.7 0cb0373e59 feat(ai): deterministic confidence-score computation per recommendation
Each recommendation now carries an optional `confidence: 0..100`
integer in `aiRecommendationSchema`. New `src/lib/ai/confidence.ts`
exposes `computeConfidence({ n, recencyDays, deviationStdRatio })`,
the deterministic formula the wrapper will use to override whatever
number the model picks.

Score breakdown (sum, max 100):
  - n contribution: log10-saturating curve, capped at 40. n<3 hard-
    caps the whole score at 5*n (max 15) so a three-sample window
    never produces a confident-looking meter.
  - recency contribution: 30 at <2d, decays linearly to 0 at 30d.
  - signal contribution: 30 at |z|>=1.5, scaled linearly to 0 at
    z=0; null ratio (no baseline yet) returns a neutral 15.

Schema is `.optional()` so:
  - The wrapper can fill it post-validation regardless of what the
    LLM emitted — the model's calibrated probability is unreliable.
  - Legacy cached payloads from before B5d landed still parse.

Integer-only and bounded so the meter component renders without
clamping; a renegade provider that emits 101 or 67.5 fails parse and
triggers the wrapper's corrective retry.

15 fixtures cover the input matrix: n hard-cap (n=0..2), n=3 floor,
mid-range (n=14, n=20), saturation (n=100 plateau, n=10000 cap),
recency clamp (45d -> 0), null-ratio neutral, negative-z magnitude,
plus monotonicity-in-n + integer-in-bounds invariants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:40:29 +02:00
Marc-André BombeckandClaude Opus 4.7 71ffe3022f feat(jobs): daily feedback aggregator writes per-(severity x provider) summary to AppSettings
v1.4.16 phase B5e: ship the pg-boss `feedback-aggregator` queue that
runs daily at 04:00 Europe/Berlin (after the rate-limit, idempotency,
audit-log cleanups). Aggregates the last 30 days of
`RecommendationFeedback` rows into per-(severity, metricSourceType,
providerType, promptVersion) buckets and writes a JSON summary to
the singleton `AppSettings.adminAiInsightsFeedbackSummary` row.

Summary shape:
  { generatedAt, windowDays, buckets:
    [{ severity, metricSourceType, providerType, promptVersion,
       helpful, notHelpful, total, helpfulRate }] }

Bucketing is split into a pure `buildFeedbackBuckets()` helper so the
admin's ad-hoc analysis tools can reuse it without going through
Prisma. helpfulRate is rounded to 2 decimals; sort order is
deterministic (severity → metricSourceType → providerType →
promptVersion) so the admin UI's row order stays stable across
aggregator runs.

Single-user-default-on policy (research §3): the aggregator runs
unconditionally for the user's own ratings. Cross-user prompt-tuning
stays admin-only via `requireAdmin()` gating on the consuming
endpoint; the aggregator just produces the data. Prompt mutation
itself is a v1.4.17 ratchet — this commit only delivers the loop
infrastructure.

Worker handler degrades gracefully on failure: warns instead of
throwing so a single-tick error doesn't poison the queue and block
the next cleanup window. Wide-Event annotations: feedback_buckets,
feedback_total_rows, feedback_window_days.

Tests: 5 new unit tests (3 bucketing scenarios + 2 aggregator
default-window + custom-window).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:39:59 +02:00
Marc-André BombeckandClaude Opus 4.7 47ca1e41e0 feat(insights): RecommendationCard renders feedback thumbs in named slot
v1.4.16 phase B5e: fill the `data-slot="rec-feedback-slot"` reserved
by B5c. The RationaleCard now mounts `<RecommendationFeedback>` when
the rec carries every attribute the feedback endpoint validates —
id, severity, metricSource.type, and a metricSource.timeRange that's
in the four-window vocabulary (`last7days` / `last30days` /
`last90days` / `allTime`).

Defence-in-depth via `asFeedbackTimeRange()`: a rec with an
out-of-vocabulary timeRange (e.g. `"lastFortnight"` from a future
provider that drifted) leaves the slot empty rather than rendering a
feedback row that would 422 on submit. Same gate handles legacy recs
without an id — the slot stays empty so plain-string recs from
v1.4.14/v1.4.15 cached payloads can't suddenly produce thumbs that
have nothing to attribute server-side.

Confidence-meter slot (B5d's territory) is untouched — that's
filled by the parallel B5d worker.

Tests: 3 new rec-card scenarios pin the wiring (thumbs render when
fully attributed, hidden when no id, hidden on bad timeRange).
Existing rec-card tests retrofit `useMutation` + `useAuth` mocks so
the component's TanStack Query mutation stays inert during SSR.

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