Commit Graph
2687 Commits
Author SHA1 Message Date
Marc-André BombeckandClaude Opus 4.7 c6e3ac69e8 fix(achievements): cap consistentMonthCount and clarify mood-improvement window
consistentMonthCount kept growing every calendar month with ≥25
active days even though the only achievement it backs unlocks at
target 1. The unbounded count served no UI purpose, churned the DB
on every poll, and inflated the wire payload over time. Cap at 1
once any month hits the threshold.

Also rewrites the moodImprovementHit doc-comment to accurately
describe the sliding-window semantics. The code slides over distinct
logged days, not 7 calendar days, so a user with sparse logging
compares the 7 most-recent logs against the 7 before that regardless
of calendar gap. The previous comment claimed calendar-day windowing
which the code never implemented.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:03:52 +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 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 6912d4e2b6 test(achievements): coverage for evaluators + UI states
Three new layers of coverage for the v1.4.18 expansion set:

- Unit (expansion-metrics.test.ts) — drives synthetic data through
  every new evaluator (count by type, mood mean improvement, weekend
  warrior, hidden Easter-egg triggers including the Berlin TZ
  classifier for night-owl / early-bird) and the earnability flag
  builder. 13 new assertions.

- Integration (achievements-expansion.test.ts) — round-trips real
  prisma rows (MoodEntry's YYYY-MM-DD `date` string, Measurement
  type discrimination, audit log row) through
  `buildExpansionMetricValues` so a schema drift in any of the row
  shapes surfaces here rather than at runtime. We don't drive the
  full HTTP route in this spec because of pre-existing
  medication_schedules.days_of_week migration drift.

- E2E (achievements.spec.ts) — the existing sidebar smoke gets a
  sibling describe-block exercising all four card states (unlocked /
  locked-earnable / hidden-locked / hidden-unlocked) and asserting
  hidden-locked cards never leak their real strings to the rendered
  HTML, which is the entire point of the "hidden" state.

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 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 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 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 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 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 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 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 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 10a67ff6c8 feat(insights): RecommendationCard with expandable rationale + mini-chart of data window
Each recommendation now lives in its own collapsible card. Collapsed
state shows: severity badge + rec text + named slot for the future
B5d confidence-ring + chevron toggle. Expanding reveals the
Oura-style "Contributors" panel:

  - 3 rationale rows (Window / Compared to / Deviation)
  - mini-chart pinned to the rec's data window via HealthChart's
    new `mini` + `windowOverride` props (or MoodChart's mirror props
    for mood-typed metrics)
  - citation footnote (B5a) when referenceId resolves
  - named slot for the future B5e feedback-thumbs

Default state: collapsed. ChevronDown / ChevronUp toggle aria-expanded
and a 200ms ease-out animation reveals the rationale block.

Recs without rationale (legacy payloads) and plain-string recs render
as a non-expandable list item with the inline citation footnote
preserved — defence in depth so a stale cached payload still
surfaces its source even though the rationale block is empty.

InsightAdvisorCard renders the new component and surfaces the
legacyPayload CTA when the route flagged the cached blob as pre-B5c
shape — clicking the regenerate button is the single trigger; we
never auto-regenerate.

InsightRecommendation type widens to optionally carry rationale +
metricSource + severity + id (mirrors the strict
aiRecommendationSchema). Optional in the UI type because legacy
cached payloads predate them; the server-side schema enforces
presence on fresh generations.

Layout slots are deliberately named so B5d (confidence) and B5e
(feedback) plug into named positions without touching this file:

  - data-slot="rec-confidence-slot"  → B5d ConfidenceRing
  - data-slot="rec-feedback-slot"    → B5e RecommendationFeedback

11 new tests in `recommendation-card.test.tsx` pin the contract
(severity badge, default-collapsed, rationale row labels in both
locales, mini-chart routing for BP / mood, slot positions, legacy
fallback). Existing 6 citation-footnote tests still green via the
inline-footnote fallback path.

7 new EN+DE i18n keys under `insights.recommendation.*`:
  rationale, rationaleWindow, rationaleComparedTo, rationaleDeviation,
  rationaleExpand, rationaleCollapse, legacyPayloadCta.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:17:15 +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
Marc-André BombeckandClaude Opus 4.7 13f1ae5c0b feat(ai): corrective retry covers missing rationale fields
When a first-attempt response omits the rationale object (or emits
empty comparedTo / deviation strings), the corrective reprompt
now names the three rationale fields explicitly so the model has
a targeted fix instead of guessing what changed. The reprompt also
restates that rationale.dataWindow MUST equal metricSource.timeRange
— bracket-mismatch is the most likely model-side bug now that the
shape is new.

The wrapper's post-validation `annotate()` block additionally
emits three new Wide-Event meta fields:

  - ai_rationale_total_recommendations
  - ai_rationale_with_rationale
  - ai_rationale_missing_recommendation_ids

Strict-parsed payloads always have rationale on every rec (the
schema rejects otherwise), so the missing-ids array is an
observability tripwire — it will only populate if a legacy payload
ever rides through .passthrough() on a back-compat path.
v1.4.17 admin AI dashboard plots rationale-coverage alongside the
existing citation-coverage breakdown.

4 new tests in `rationale-wrapper.test.ts` pin the contract:
- corrective retry message names dataWindow / comparedTo / deviation
- empty deviation triggers retry (not just absent rationale)
- annotate() carries the new ai_rationale_* keys on success
- rationale-coverage = totalRecommendations on a fully-shaped payload

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:17:15 +02:00
Marc-André BombeckandClaude Opus 4.7 c39a527b8e feat(ai): system prompt requires rationale per recommendation
The output-format block now advertises the new `rationale` shape
(dataWindow / comparedTo / deviation) and a fifth GROUND RULE
mandates that every rec carry one. The dataWindow MUST equal
metricSource.timeRange so the UI's mini-chart can pin to the same
window the rec was derived from — bracket-mismatch prevention is
cheaper to enforce in the prompt than to detect post-parse.

Both EN + DE prompts updated symmetrically. PROMPT_VERSION stays
at 4.16.0 since B5a already bumped it for this milestone — the
content extension is a same-version revision, not a behavioural
shape change that warrants 5.0.0.

6 new prompt-assertion tests pin the contract:
- output-format block names the three rationale fields
- enum values (last7days...allTime) mentioned for both locales
- ground rule "MUST carry a rationale" present in EN + DE
- "factual" / "sachlich" language guard against placeholders

Existing 213 AI tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:17:15 +02:00
Marc-André BombeckandClaude Opus 4.7 8a438a0680 feat(ai): schema requires rationale (window + comparedTo + deviation) per recommendation
Each recommendation now carries a 3-field `rationale` object that
powers the upcoming Oura-style "Contributors" expand-card:

  - dataWindow: which time window the rec is based on (matches the
    metricSource.timeRange enum so the UI mini-chart can pin to it)
  - comparedTo: what the user's data is being compared against
    (the user's own baseline or a guideline ceiling)
  - deviation: size + direction of the deviation that triggered
    the rec

Empty `comparedTo` or `deviation` is rejected so the model cannot
emit a placeholder rationale card. Optional `referenceId` rides
along for defence-in-depth; the rec-level referenceId stays the
canonical citation source.

`findRecommendationsMissingRationale()` lets the route detect
legacy (pre-B5c) cached payloads so the UI can show a "regenerate
for new explainability features" CTA. User-initiated regeneration
stays the trigger; nothing auto-runs.

10 new tests in `rationale-schema.test.ts` pin the contract:
- rationale required on every rec
- comparedTo / deviation reject empty strings
- dataWindow rejects unknown enum values
- legacy payload detector returns missing rec ids only
- mixed payloads flag only the legacy entries

Existing test fixtures in `generate-insight.test.ts`,
`reference-id-schema.test.ts`, `citation-enforcement.test.ts`,
and `citation-coverage-logging.test.ts` updated to include
rationale on every well-formed rec — typecheck-clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:17:15 +02:00
Marc-André BombeckandClaude Opus 4.7 901f44e6dc feat(charts): mood chart polish with emoji glyphs at data points
Same Apple-Health visual leap as the other charts, plus the mood-
specific affordance: an emoji glyph at every daily score so a week's
worth of mood reads at a glance without consulting the legend.

What changed:
- ComposedChart replaces LineChart so a gradient-filled Area paints
  under the score line. The mood gradient uses the lavender token
  (--dracula-lavender / #d6acff) which matches the existing line
  stroke. Sibling 0×0 SVG mirrors the gradient for SSR-discoverability.
- Personal baseline reference line: median mood over the visible
  window (≥5 points required). Faint dashed line labelled "Your
  normal" / "Dein Mittel".
- Rich tooltip via RichChartTooltip — date label, score value with
  text-label ("4.0 (Good)"), delta-vs-baseline sub-line.
- Emoji dot per score: 1 → 😖, 2 → 🙁, 3 → 😐, 4 → 🙂, 5 → 😄.
  The Recharts `dot` prop accepts a function returning an SVG node;
  we render <text> centred over (cx, cy) so the glyph sits on the
  line. data-slot="mood-emoji-glyph" + data-score for visual probes.
- 600ms ease-out animation on first render, suppressed when
  prefers-reduced-motion is set.
- Sparse-data placeholder via ChartEmptyState for <3 data points.

1 new test pins the gradient-defs SSR contract; existing
mood-chart-aggregation tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:30:40 +02:00
Marc-André Bombeck 7f1a4ded25 fix(validation): moodEntry.tags must parse as JSON string-array (v1.4.15 H2)
`MoodEntry.tags` is stored as a JSON-array-as-string in
`mood_entries.tags`. The backup schema accepted any string here, so a
malformed blob (or a tampered backup) would persist into the DB and
crash downstream readers that blindly `JSON.parse` it. Restore-time
validation now refines the field to one of:

  - null (or omitted) — no tags on this entry
  - "" — legacy moodLog wire format (treated as null)
  - a JSON string that parses to an array of strings

Anything else fails Zod with a 422 from the restore endpoint instead
of bubbling out of `prisma.createMany` with an opaque message. New
unit-test file covers the three accept cases plus three reject cases
(non-JSON, JSON object, JSON array of non-strings).
2026-05-10 00:27:20 +02:00
Marc-André BombeckandClaude Opus 4.7 74c2eb8bff feat(charts): BP/weight/pulse polish (gradient, baseline, rich tooltip)
The HealthChart wrapper is the single component the dashboard uses for
every numeric vital — BP systolic/diastolic, weight, pulse, body fat,
sleep, steps. This commit upgrades it once with the v1.4.16 visual
leap so all those metrics jump together.

What changed:
- ComposedChart replaces LineChart so a gradient-filled Area can
  paint under the line. Each metric gets its own
  chart-gradient-inline-{type} <linearGradient> definition; default
  35 percent top alpha → 0 percent bottom matches the Apple Health
  Trends look. A sibling 0×0 SVG mirrors the gradient so SSR markup
  carries the slot for visual-regression and e2e probes.
- Personal baseline reference line: 90-day rolling median per type,
  painted as a faint dashed line labelled "Your normal" / "Dein
  Mittel". The new computePersonalBaseline() helper returns null when
  fewer than 5 points exist so a baseline never renders off noise.
- Rich tooltip via the new RichChartTooltip primitive: rounded card
  with drop shadow, per-row coloured dot, tabular-nums value cell, and
  a delta-vs-baseline sub-line ("+3 mmHg vs. your normal"). Auxiliary
  rows from the moving-average / trend overlays are filtered out so
  the card stays focused on the primary metric.
- 600ms ease-out animation on first render via Recharts'
  isAnimationActive prop, suppressed when prefers-reduced-motion is
  set at the OS level (new src/lib/charts/reduced-motion.ts helper
  with 4 unit tests).
- Sparse-data placeholder via ChartEmptyState when fewer than 3
  daily points exist — a near-empty chart looks broken, the
  placeholder reads as intentional.

In-target zone shading was already wired via the existing valueBands
prop and stays untouched. The dashed peer's labelling is unchanged.

7 new tests pin the contract (4 baseline-helper, 2 SSR markup, 1
gradient-id namespacing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:26:13 +02:00
Marc-André BombeckandClaude Opus 4.7 2611bb4fae feat(db): User.aiProviderChain for ordered fallback config
Add a JSONB column on the users table that stores an ordered list of
provider preferences for multi-provider redundancy. Each entry carries
{ providerType, priority, enabled }; credentials still live in the
dedicated encrypted columns so AES-256-GCM key rotation keeps working
unchanged. Null preserves the legacy single-provider resolution path.

Ships a defensive parser/serializer pair with a sensible default chain
(codex -> openai -> anthropic -> local -> admin-openai) so a malformed
JSON on disk falls back to the default rather than 500-ing insight
generation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:20:12 +02:00
Marc-André BombeckandClaude Opus 4.7 4cc3d8d45e feat(admin): app-log preview surfaces last 1h of structured events with filter + JSON inspector
Wires the in-memory ring buffer (`src/lib/logging/in-memory-buffer.ts`)
into `transports.emitEvent()` so every wide event that survives the
sampler also lands in a 500-entry per-process buffer. Adds the
`app-logs` admin section slug, the renderer case, and the route entry
exports needed for the new section component to mount.

The buffer is volatile and per-process — a worker-only event won't
show up in the web buffer, and a restart drops everything. The section
header copy spells that out so admins know to keep Loki configured for
durable diagnostics. Egress through `/api/admin/app-logs` runs every
event through `redactSecrets()` so a stray Bearer / hlk_ / sk- token
in an `error.message` never leaks to the admin UI; storage stays raw
to keep the diagnostic value when shipped to Loki.

The companion buffer + transports tests live under
`src/lib/logging/__tests__/in-memory-buffer.test.ts` and
`transports-buffer.test.ts` (committed separately as part of the
buffer scaffolding). The route-level tests in
`src/app/api/admin/app-logs/__tests__/route.test.ts` cover newest-
first ordering, traceId / level / action filters, the `limit` cap,
secret redaction on egress, and the Zod-rejected invalid `level` case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:09:22 +02:00
Marc-André BombeckandClaude Opus 4.7 a66c128dd9 feat(insights): citation footnote on recommendations with medical reference
When a recommendation carries a `referenceId` pointing into the
curated medical-reference bundle, render a small footnote underneath
linking to the guideline. Format: "Source: ESH 2023 — ESH 2023
Guidelines for the Management of Arterial Hypertension" (locale-
routed title), with a tiny external-link icon, opens in a new tab via
target="_blank" rel="noreferrer". Per the v1.4.16 research doc §7
the URL never opens in an in-app webview.

`InsightResult.recommendations` widens to accept either the legacy
plain-string shape OR a structured `{ text, referenceId? }` object so
existing rendering paths keep working while the new citation surface
plugs in through the same component. `getMedicalReferenceById()`
returns undefined for unknown ids — the UI silently drops the
footnote in that case rather than rendering a broken link (defence
in depth on top of the schema validation).

i18n keys `insights.recommendation.source` and
`insights.recommendation.viewSource` land in both EN and DE.

6 component tests pin the no-footnote / EN-locale / DE-locale /
unknown-id-defence / multi-rec paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:04:24 +02:00
Marc-André BombeckandClaude Opus 4.7 53aade981a feat(ai): post-validation logs citation-coverage rate per insight generation
After the schema parse and citation cross-check pass, the wrapper
counts how many recommendations make a normative claim ("target",
"should", "normal range", "above", "below") and how many of those
carry a `referenceId` pointing into the curated medical-reference
bundle. The result lands in the active Wide-Event meta annotation
under `ai_total_recommendations`,
`ai_normative_recommendations`,
`ai_cited_normative_recommendations` and
`ai_uncited_normative_recommendation_ids` so the admin AI quality
dashboard can chart citation-coverage over time.

Observational only in v1.4.16 — a normative rec without a referenceId
is logged, never raised as a parse error. Phase B5c will flip the
check to required for severity >= "important".

15 unit tests pin the keyword heuristic (EN + DE), the coverage
counts across cited / uncited / observational mixes, and the
`generateInsight()` integration that emits the annotation only on
success (skipped on schema-failure throw).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:51:59 +02:00
Marc-André BombeckandClaude Opus 4.7 f1bd8014d8 feat(jobs): host-metric sampler captures load + memory every minute, 7d retention
Powers the 2-hour host-load chart Marc asked for above the
/admin/system-status section. The pg-boss `host-metric-sample` queue
runs `* * * * *` and writes one HostMetric row per tick.

Sampler implementation:
  - `os.loadavg()` → load_avg_1/5/15 (rounded to 2 decimals)
  - `os.totalmem() - os.freemem()` → mem_used_bytes / mem_total_bytes
  - On Linux: parses /proc/diskstats and sums sector counters across
    physical block devices (skipping loop/ram/dm-/md/sr/fd/nbd/zram
    pseudo-devices that double-count). Multiplies by the canonical
    512-byte sector size for raw bytes
  - On macOS / Windows / inside containers without /proc: disk fields
    are NULL, the chart gracefully degrades

The disk-stats reader is injected so the unit test can stub it without
touching `node:fs`. `runHostMetricTick()` does the insert + the
`deleteMany` for rows older than HOST_METRIC_RETENTION_DAYS (default 7,
env override capped at >= 1d to guard misconfigs).

Wired into reminder-worker.ts alongside the other recurring queues.
The worker handler swallows errors to a Wide-Event warning rather than
re-queueing, because a single missed tick costs a minute of chart data
— not worth a retry storm.

8 unit tests pin retention env handling, the disk-stats null/throw
fallback paths, and the create + deleteMany call shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:50:08 +02:00
Marc-André BombeckandClaude Opus 4.7 7fbfca672e feat(ai): system prompt includes curated medical references for citation grounding
Builds a SOURCES block dynamically — only references whose
applicability overlaps the metrics in the current snapshot get
listed, so a weight-only call doesn't burn tokens on ESH BP guidance.
Each line carries the id (lowercase, parser-validated), org, year,
title (locale-routed) and direct URL so the model can cite by id.

Adds a GROUND RULE that tells the model to set
`recommendation.referenceId` to the matching SOURCES id when making a
target-range or normative claim, and to leave it empty for purely
observational comparisons. The schema check from the previous commit
rejects fabricated ids — defence in depth on the prompt instruction.

Plain `getStrictInsightsSystemPrompt(locale)` stays unchanged for
back-compat with the existing assertions and any call-site that
hasn't migrated yet. The new
`buildSystemPromptWithReferences(locale, metrics)` is the path
forward.

10 unit tests pin the SOURCES-block contents, locale-routed titles,
deduplication when metrics repeat, and that the existing scope-
hardened guards (refusal pattern, citation cross-check) survive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:49:25 +02:00
Marc-André BombeckandClaude Opus 4.7 466b8b5604 feat(ai): schema accepts validated referenceId pointing into the medical-reference bundle
Recommendations now carry an optional `referenceId` that the parser
checks against the curated bundle from the previous commit. A
fabricated id ("invented-guideline-2099") fails parse so the model
cannot synthesise a citation for a guideline that does not exist —
defence in depth on top of the prompt-side instruction.

Optional in v1.4.16 so existing payloads still validate; v1.4.16
phase B5c ratchets it to required when severity >= "important". Also
bumps PROMPT_VERSION to 4.16.0 for the system-prompt update landing
in the next commit.

8 unit tests pin the accept-known / reject-fabricated / mixed-payload
behaviour and the round-trip preservation of referenceId through the
strict response schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:47:48 +02:00
Marc-André BombeckandClaude Opus 4.7 27f3933344 feat(ai): curated medical-reference bundle for citation grounding
Recommendations that assert a normative claim ("BP target < 140/90",
"BMI 18.5-24.9") need to point at the guideline they came from. This
adds the static reference bundle the schema and prompt will hang
citations off in the next two commits.

Bundle covers AHA / ESH / ESC / WHO / DGE / ADA across blood pressure,
weight, pulse, mood and medication buckets. Each entry carries an EN
+ DE title, a direct https URL, the publication year, and the metric
buckets it applies to. `selectReferencesForMetrics()` picks the
overlap so a snapshot focused on BP only sees the relevant references
in the system prompt.

15 unit tests pin shape, slug uniqueness, https URL validity, year
sanity, bucket coverage and the selector helpers — so a typo in a URL
or a missing bucket fails CI before the model ever sees it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:46:21 +02:00
Marc-André BombeckandClaude Opus 4.7 2da0703a46 fix(geo): preserve umlauts in city names (login-overview no longer renders "Nrnberg")
In /admin/login-overview I caught a row that should have rendered
"Nürnberg, DE" showing up as "Nrnberg, DE" instead — the umlaut was
gone. The geo source (ipwho.is) returns the city as proper UTF-8 over
the wire, and the audit-log render path is a straight pass-through, so
the corruption had to be in the geo helper itself.

Two defensive fixes, in lib/geo.ts:

1. Decode the response body as UTF-8 explicitly via `arrayBuffer()` +
   `TextDecoder('utf-8')` instead of `Response.json()`. The latter
   defers its charset decision to whatever the upstream
   `Content-Type: application/json; charset=…` claims, which an
   intermediate proxy can strip or rewrite — exactly the kind of
   transit-layer surprise that produces "Nrnberg" out of "Nürnberg"
   when the byte stream stays UTF-8 but the parser thinks it's
   latin-1. Reading via TextDecoder bypasses that entirely; a
   malformed byte surfaces as a parse failure, not silent character
   loss.

2. Send `Accept-Language: de, en;q=0.5` on the lookup request.
   ipwho.is and ip-api both honour it, and without the hint ip-api
   falls back to the English ASCII fold ("Nuremberg" instead of
   "Nürnberg"). German is preferred (DACH-skewed user base) with
   English as a safety net for cities that don't have a German name.

Also audited the rest of src/ for ASCII-fold patterns that could chew
umlauts elsewhere — `String.prototype.normalize("NFD")`, slugify
calls, `[^a-zA-Z0-9]/g` strips: zero hits. So this is the only fix
needed for now.

Regression tests: roundtrip 7 known-umlaut cities (Nürnberg, München,
Düsseldorf, Köln, Würzburg, Bückeburg, Weißenfels) plus eszett, plus
a Content-Type-without-charset case to lock in the explicit UTF-8
decode, plus an Accept-Language assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:37:27 +02:00
Marc-André BombeckandClaude Opus 4.7 af77e5ecf8 fix(insights): trend on "all" filter computes meaningful delta (split-half comparison)
Marc reported clicking "All" on a chart's range buttons displayed
"+0.0 kg/Woche" even when his weight clearly drifted across years.

Root cause: `weeklyDelta = (lastTrend - firstTrend) / windowDays *
7` is mathematically correct but the per-week rate becomes
vanishingly small for multi-year windows (~0.07 kg/week for
10 kg over 3 years), and the 1-decimal `fmt.number(delta, 1)`
formatter rounds it to "0.0".

Fix: extract a pure `computeWindowTrend()` helper at
`src/lib/analytics/window-trend.ts`. For windows ≥ 90 days we
additionally surface a split-half delta — the mean of the
second half of the visible series minus the mean of the first
half. That number cannot round to zero unless the metric truly
didn't move, and it's exactly what Marc wants to see when he
asks "what changed across all my data?". For short windows the
per-week rate stays the only display because it's still the most
actionable metric there.

The chart trend line surfaces both: "WEIGHT: +0.1 kg/week
(0.1 %/week) · Total +5.4 kg (6.7 %)" on the All view of a
multi-year account.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:31:18 +02:00
Marc-André BombeckandClaude Opus 4.7 93e712dc60 fix(dashboard): top-tile selector actually filters the tile-strip
Marc reported the toggles in Settings → Dashboard didn't filter
the dashboard tile-strip on reload — the v1.4.15 A4 claim was
correct in spirit but broken in the API contract.

Root cause: `widgetIdEnum` in
`src/app/api/dashboard/widgets/route.ts` was missing
`achievements` even though the default layout has it. Every PUT
that included the achievements widget therefore failed schema
validation with 422 — the toast surfaced "Layout konnte nicht
gespeichert werden" but the user never noticed because the toggle
visually flipped (local draft state) before the network call. On
reload the saved layout was the previous one, so the filter looked
like a no-op.

Fix:
- Extract `DASHBOARD_WIDGET_IDS` as the single source of truth in
  `src/lib/dashboard-layout.ts`. The Zod enum in the API route is
  built from it so the two lists cannot drift again.
- Hide the tile-strip wrapper entirely when the user toggled off
  every tile (Marc's constraint: "immer die gesamte Spalte breit
  und immer der gleichen Höhe ausfüllen" — an empty grid wrapper
  was leaving a thin gap on the page).

Tests: contract test pinning that every widget id in
DEFAULT_DASHBOARD_LAYOUT round-trips through serialize → resolve,
plus an explicit assertion that `achievements` is in the default
layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:31:00 +02:00
Marc-André BombeckandClaude Opus 4.7 4df6dac036 fix(charts): "7-Tage-Trend" label + numeric trend indicator on every chart incl. mood
Marc reported v1.4.15 A4 didn't fully land:
- DE label still read "7-Tage-Schnitt" / "7T-Schnitt" in places
- mood tile showed no numeric trend delta even though other tiles did

i18n rename:
- de.json + en.json: trend7dShort, movingAverage7d, moodMA all read
  "7-Tage-Trend" / "7-day trend". The legacy "7T:" / "7d:" header on
  the avg7 line is preserved for tiles that haven't been migrated to
  the trend7Delta API.

trend-delta fallback:
- summaryToTrend7Delta() now falls back to slope30 (projected onto
  7 days) when slope7 is null. Mood entries are typically logged
  less than daily, so slope7 needs ≥ 2 points within the trailing
  7-day window — for sparser users that came back null and the tile
  silently dropped the delta indicator. slope30 still answers "is
  this metric drifting?" so we project that onto the 7-day window
  for label parity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:30:42 +02:00
Marc-André BombeckandClaude Opus 4.7 577d8dd29f fix(insights): BD-Zielbereich computes correctly for real measurement data
The BD-Zielbereich tile rendered 0% for users whose readings sat below
the ESH 2023 narrow goal band (Sys 120-129 / Dia 70-79 for under-65).
Marc's actual production data has many readings like 117/79 — textbook
normotensive, well-controlled — that v1.4.15 A4 still counted as OUT
of target because sys < sysLow (120). 5 of 10 of his last-30-day pairs
should be in-target by clinical interpretation; v1.4.15 reported 0.

Root cause: "in target" was defined as the narrow goal band, which is
the ESH treatment goal for hypertensive patients ON therapy — NOT the
desirable range for a normotensive HealthLog user. Users (and the
dashboard label) intuitively mean "BP is well-controlled = at or below
the ceiling, not implausibly hypotensive".

Fix: change semantics from `sysLow <= sys <= sysHigh && diaLow <= dia <=
diaHigh` to `90 <= sys <= sysHigh && 50 <= dia <= diaHigh`. The 90/50
floors guard against counting symptomatic hypotension as "good control".
Centralised the check in `isBpReadingInTarget()` so the 6 historical
call sites (analytics route, blood-pressure-status, features,
general-status, insights/cards, insights/comprehensive, insights/targets)
share one definition — pre-fix they had 5 independent inline copies of
the narrow-band formula, easy to drift on a future refactor.

Tests:
- 6 new unit tests pin the new ceiling+floor semantics including Marc's
  exact production fixture (10 paired readings -> 50% under v1.4.16,
  was 0% under v1.4.15).
- New `tests/integration/bp-in-target.test.ts` seeds Postgres
  testcontainer with Marc's reading shape + null-tolerance + hypotension
  edge cases.

Cache invalidation already correct: `measurementDependentKeys` includes
`analytics()` so adding a BP measurement invalidates the tile's
TanStack Query cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:28:54 +02:00
Marc-André BombeckandClaude Opus 4.7 4dcaa08b42 chore(release): v1.4.15
Bumps version to 1.4.15 and adds the corresponding CHANGELOG entry
covering backup completeness, achievements UI, onboarding tour,
doctor-report v2, auto-deploy on GHCR push, empty-states sweep,
notification + integration status UI, AI hallucination guardrails,
mobile UX fixes, and the BD-Zielbereich percentage fix.

Includes the formatting drift left over by the v1.4.15 development
phases (Prettier sweep on touched files) and a typecheck fix in
`dashboard-layout.test.ts` so `pnpm typecheck`, `pnpm lint`,
`pnpm format:check`, `pnpm test`, and `pnpm test:integration` all
pass green on the release tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:27:13 +02:00
Marc-André BombeckandClaude Opus 4.7 d94756389f style(v1.4.15): prettier sweep on phase-D reconcile touched files
Class-order + line-length normalisation on tour.tsx, mock-client.ts,
and codex-slug-fallback.test.ts after the focus-trap / strict-schema /
inspect-helper-removal commits. No functional changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:17:17 +02:00
Marc-André BombeckandClaude Opus 4.7 79bb167333 fix(ai): MockAIProvider DEFAULT_RESPONSE conforms to v1.4.15 strict schema
Phase D senior-dev review HIGH H4: the mock's default response carried
the legacy v1.4.14 rich shape (classification, findings, correlations,
dataQuality, disclaimer). The v1.4.15 strict schema requires
{summary, recommendations, citations, warnings}. The default passed
only because of `aiInsightResponseSchema.passthrough()`; once v1.4.16
retires the passthrough, every test using the default mock would
silently start failing schema validation before its asserts ran.

Switch to a strict-schema-conformant default. Tests needing the
legacy shape opt in via `responses: [...]`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:16:33 +02:00
Marc-André BombeckandClaude Opus 4.7 cd3b890067 refactor(v1.4.15): apply simplify-review safe suggestions
Drop dead exports + misleading comments + unreachable defaults flagged
by phase-D simplify review (autonomous-yes items only):

- channel-state: drop unused TEST_CONSTANTS re-export
- retry-policy: drop unused RejectKind type alias
- achievements: drop misleading "fallback" comment on exhaustive switch
- notification-status-card: drop unreachable default in stateBadgeFor
- withings: export isWithingsRefreshReauthFailure + dedupe inline
  classifier in /api/withings/status route
- codex-slug-cache: drop test-only inspectCodexSlugCache helper; tests
  read getCachedCodexSlug() instead

Findings 1, 2, 8, 9 deferred to v1.4.16 (require product call).
Finding 11 is an explicit anti-finding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:14:43 +02:00
Marc-André BombeckandClaude Opus 4.7 4bba951121 feat(ai): fallback-chain slug discovery with 1h positive cache
Phase C1 (v1.4.15) — slug-drift defence per Marc's hard mandate:
"Die Integration des Slug Drift Risiko darf halt immer überhaupt nicht
sein." ("Slug-drift risk must NEVER happen.")

Background: the ChatGPT-Codex backend rotates its allow-list silently.
v1.4.7..v1.4.13 lost a week to chasing the rotation manually
(`gpt-5-codex` → `gpt-5.3-codex`). A single hard-coded `CODEX_MODEL`
flips invalid the moment upstream rotates it.

Solution — the client now walks an ordered fallback chain on every
fresh request series and caches the first slug that returns 200 +
`response.completed` for 1 hour. Subsequent requests skip the chain
walk entirely on a cache hit.

Spec: extends `docs/codex-protocol-spec.md` with new §7b documenting
the chain semantics, cache TTL, walk triggers (400 + "not supported
when using Codex with a ChatGPT account" / `model_not_found` /
"does not exist" / 404), non-walk responses (5xx, 429, 401-with-
refresh, `invalid_prompt`), and the all-failed structured-503 error
shape. Per Marc's v1.4.6 hard rule ("read source-of-truth before
iterating; no more 6-iteration line-fixes"), the spec was extended
FIRST and the implementation cross-referenced against it.

Implementation:

  - New `src/lib/ai/codex-slug-cache.ts` — process-local positive
    cache. Single-slot Map (per-account scoping is a one-line change
    when v1.4.16 multi-tenant rolls out). 1 h TTL. `getCachedCodexSlug`
    auto-evicts expired entries on read.
  - Rewritten `CodexClient.generateCompletion()` — walks the chain
    `[cached?, ...slugChain]` in order. On 401 → refresh + retry SAME
    slug (auth ≠ slug-state). On 400/404 with rejection body → drop
    cache, walk to next. On 5xx/429 → propagate immediately (walking
    won't help). On all-rejected → throw structured 503 with
    `attempted[]` list, `httpStatus: 503`, message "AI provider
    unreachable — all configured Codex slugs were rejected".
  - `CodexAttemptDiagnostics` exposes `{ attempted, cacheState,
    workingSlug }` for Wide-Event annotations the route can read via
    `client.getLastDiagnostics()`.
  - `CODEX_MODEL_FALLBACK_CHAIN` env override (comma-separated). When
    `CODEX_MODEL` is also set, it folds into chain position 0; the
    default chain (`gpt-5.3-codex` → `gpt-5-codex` → `gpt-5` →
    `gpt-4o`) survives behind it. Stable de-dup.

Tests: +20 across 2 new files.

  `codex-slug-cache.test.ts` (7 tests) — TTL-edge boundaries,
  invalidation, replacement semantics.

  `codex-slug-fallback.test.ts` (13 tests) — cold-cache happy path,
  walk on canonical 400 body, walk on `model_not_found`, walk on 404,
  no-walk on 500/429/401, all-rejected → 503-with-attempted-list,
  cache-hit shortcut, cache invalidation on rejection, isSlugRejection
  body classifier, loadFallbackChain env-pin merge.

The 5 existing `codex-client.test.ts` cases continue to pass without
edit — the new `slugChain` constructor parameter is opt-in and the
single-slug behaviour reduces to chain length 1.

Tests: 1028 → 1048 unit pass (+20). Typecheck clean for AI files.
Lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:55:38 +02:00
Marc-André BombeckandClaude Opus 4.7 4e85c38c4b feat(ai): scope-hardened system prompt with refusal pattern
Phase C1 (v1.4.15) — adds the v1.4.15 baseline scope-hardened prompt.

New file `src/lib/ai/prompts/insight-generator.ts` exports
`getStrictInsightsSystemPrompt(locale)` and a stable `PROMPT_VERSION`
tag (`4.15.0`) so future iterations ratchet the prompt without
breaking deterministic tests. The prompt's load-bearing instructions:

  - Role: clinical-context summariser ONLY. Explicitly forbids
    diagnosis, prescription and out-of-scope answers.
  - Out-of-scope refusal pattern: when the snapshot is empty or off-
    topic, the model returns a fixed JSON refusal payload (also
    exported as `OUT_OF_SCOPE_REFUSAL_EN/DE` so tests can pin it
    against the strict response schema).
  - Zero-hallucination guard: every recommendation MUST cite a
    specific snapshot field via `metricSource`; missing → omit.
  - Citation cross-check: every recommendation's metricSource must
    also appear in `citations[]` (enforced by the schema parser).
  - Generic guideline targets only: ESH/ESC 2024 < 140/90 mmHg, AASM
    ≥ 7 h sleep, Saint-Maurice 2020 ≥ 8 000 steps; explicitly
    forbids citing WHO as the source for a step number.
  - Doctor-consult call-to-action required for every actionable
    recommendation.
  - JSON-only output, no markdown fences.

The prompt is NOT yet wired into `/api/insights/generate`. The route
continues to consume the rich legacy schema (`{summary, classification,
findings, correlations, dataQuality, ...}`) which the existing
`<InsightAdvisorCard />` and `<InsightsCard />` UIs render. The
integration step is scheduled for v1.4.16 alongside the UI migration —
see `docs/audit/v1416-ai-roadmap.md` (lands in the next commit of this
phase).

Tests: +18 (1005 → 1023 unit). Pin both locale variants for
diagnosis/prescription forbiddance, citation cross-check instruction,
ESH/ESC 140/90 generic guidance, doctor-consult CTA, fabricate-no-
measurements rule, and out-of-scope refusal pattern. The refusal
payloads are also fed through the wrapper to prove the model can
return them verbatim and the wrapper passes them through cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:50:56 +02:00
Marc-André BombeckandClaude Opus 4.7 d657f798d0 feat(ai): enforce citation-from-data on every recommendation
Phase C1 (v1.4.15) — citation-from-data hard requirement.

Marc, verbatim 2026-05-09: "es muss sich halt irgendwie stützen auf
medizinische Dinge."  ("It must ground on medical facts / user data.")

Builds on the strict response schema (`src/lib/ai/schema.ts`) and
schema-enforced wrapper (`src/lib/ai/generate-insight.ts`) — which were
swept into sibling commit 5510ed5 by a parallel-agent index race
(documented across A2 / A4 / B1-B6 / C2 / C3 of the v1.4.15 marathon;
diff is correct on main, message scope misleading).

This commit adds a focused 11-test invariant suite that nails down the
contract end-to-end:

  1. Schema-level: recommendation without metricSource fails parse;
     empty metricSource.summary fails; missing type or timeRange fails.
  2. Cross-check: findUncitedRecommendations() flags any recommendation
     whose (type, timeRange) does not appear in citations[]. Partial
     match (type alone) is rejected — both fields must align.
  3. Wrapper end-to-end: orphan-citation triggers retry; second attempt
     either fixes the citation OR drops the recommendation (both
     valid per the retry-correction message; model is explicitly
     told an empty recommendations[] is acceptable).

The two-pronged guard (schema + cross-check) ensures the model cannot
fabricate a recommendation that points at "nothing" AND cannot cite
data it hasn't surfaced — closing the v1.4.16 medical-grounding work's
prerequisite.

Tests: +11 (988 → 999 unit). All AI tests green (67 / 67).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:47:37 +02:00
Marc-André BombeckandClaude Opus 4.7 5510ed529b docs(audit): v1.4.15 empty-states audit + i18n keys
Walks every list/table/tile a brand-new user hits and records the empty
path. 13 surfaces flagged for upgrade across admin, feature pages,
insights and dashboard. Adds matching EN+DE i18n keys
(emptyTitle/emptyDescription/CTA copy) under measurements, mood,
medications, admin.section.users/backups, admin login-overview/api-tokens,
admin.feedback, insights, dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:45:52 +02:00
Marc-André BombeckandClaude Opus 4.7 01a10de5d8 test(i18n): non-empty + non-placeholder parity assertion
Phase C4 audit found EN/DE locale files in perfect parity (1817 keys
each, 0 drift, 0 empty values, 0 TODO/FIXME placeholders) — the five
B-agents and the C2/C3 phases each shipped their own translations
inline.

This commit hardens the parity test to lock that state in:
- empty-string assertion (per locale, fails on `"key": ""`)
- key-equals-value assertion (fails when EN==DE==key.lastSegment, with
  an explicit allow-list for 5 legitimate cases: `settings.ntfy`,
  `classifications.{bp.Optimal,bp.Normal,pulse.Normal,bodyFat.Fitness}`)
- TODO/FIXME/XXX/TBD assertion (per locale)

EN-only key=value cases (e.g. `"of": "of"` where DE = `"von"`) are
deliberately NOT flagged — those are natural, since key names derive
from the EN word. The dual-locale check catches the real placeholder
pattern (`"bugReport": "bugReport"` in both locales) without false
positives.

Audit report: docs/audit/v1415-i18n-coverage.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:43:27 +02:00