Commit Graph
62 Commits
Author SHA1 Message Date
Marc-André Bombeck 76dea94bae chore: Cat-C typo and naming polish from W10 dead-code probe
W10's dead-code candidates report (§C2) flagged a half-quoted reference
inside `src/app/api/insights/targets/route.ts:807`. The comment claimed
the AI prompts lived at `src/lib/insights/prompts/{base-system,
general-status}.ts`, but those files actually sit under
`src/lib/ai/prompts/` and the brace-expansion abbreviation was both
wrong-pathed and easy to misread as a stray-brace typo.

The comment is rewritten in full path form so future readers can grep
the referenced files directly and so the source citation matches the
real location.

Verification:
  pnpm typecheck                         → clean
  pnpm lint                              → clean
  grep -rn 'general-status}' src         → zero hits
2026-05-14 21:46:48 +02:00
Marc-André Bombeck 6148a179ca feat(briefing): GLP1 plateau detection rule with clinical referral framing
Phase 9 of W4d GLP-1 full integration. New `detectGlp1Plateau()`
helper flags the clinical plateau pattern for GLP-1 receptor agonists:
≥21 days on a stable dose AND weight delta within ±0.5 kg over that
same window. When the rule fires, a SYSTEM CONTEXT block is appended
to the insight-generation user prompt with the named drug, current
dose, days-on-dose, and weight delta — and instructs the model to
emit a glp1_plateau keyFinding framed observationally.

GROUND RULE 14 (insight prompt) keeps the safety contract: the
finding NAMES the plateau and the conversation it should prompt
("worth mentioning at the next visit"), but NEVER recommends a dose
change. The plateau is detection, not prescription.

Detector returns `null` when no GLP-1 medication is active OR no
plateau condition is met, so 99% of users pay zero token cost and
never see a plateau-shaped section in their briefing.

The schema gains "glp1_plateau" in the keyFinding.sourceMetric enum
(landed earlier this phase) and the daily-briefing icon mapping
inherits the Pill icon for visual consistency with other
medication findings.
2026-05-14 13:52:31 +02:00
Marc-André Bombeck 2c7c3cc9f3 feat(insights): GLP1 therapy timeline on /insights/medikamente
Phase 8 of W4d GLP-1 full integration. New TherapyTimeline component
mounts on /insights/medikamente below the compliance grid. Renders a
chronological merge of every GLP-1-relevant event the user has logged:
dose changes (with up/down arrow against the previous step),
injections (with optional site), inventory events, and side-effect
days collapsed from MoodEntry tags.

Self-hides for users without a GLP-1 medication (the API returns
`hasGlp1: false`), so /insights/medikamente keeps its v1.4.24 look
for everyone else.

New GET /api/insights/glp1-timeline aggregates the data. Caps at 60
entries by default (configurable to 200 via ?limit) to keep the list
readable; older events surface via the existing intake-history page.

DE + EN copy for the surface lives under insights.therapyTimeline.
The medikamente sub-page description gains a one-liner explaining
when the timeline appears.
2026-05-14 13:49:35 +02:00
Marc-André Bombeck edae56969f chore(insights): drop orphaned /api/insights/general-status — superseded by InsightAdvisorCard since v1.4.16
The HTTP route had no remaining UI callers — the v1.4.16 polish replaced
the <InsightStatusCard> on the /insights overview with the polished
<InsightAdvisorCard>, and the per-metric sub-pages each consume their
own dedicated endpoint (/api/insights/{weight,bmi,pulse,blood-pressure,
medication-compliance}-status). The lib helper generateGeneralStatusForUser
remains in src/lib/insights/general-status.ts because the reminder
worker still pre-warms the daily cache for users opted in to the
briefing notification (src/lib/jobs/reminder-worker.ts).

Verification:
- grep confirms zero callers under src/ outside the route file itself
- the route was not registered in src/lib/openapi/routes.ts and
  docs/api/openapi.yaml carries no general-status path
- pnpm typecheck remains clean
2026-05-14 13:49:06 +02:00
Marc-André Bombeck b50ebccf0b feat(coach): distinct daily-limit vs provider-rate-limit error UX
`coach.budget.exceeded` (per-user daily token cap, returned as JSON
429) now surfaces the dedicated "Daily limit reached; resets at 00:00
UTC." copy. `coach.provider.rate_limited` (every provider in the
fallback chain landed on 429, streamed as an SSE error frame) surfaces
"Provider temporarily rate-limited; retry in ~5 min." Other provider
failures keep the generic copy. The chat route emits the new
rate-limited code; the send hook decodes the JSON 429 envelope's
structured `error` field so the drawer can route the bubble copy.
2026-05-14 13:35:57 +02:00
Marc-André Bombeck 330be965ac feat(coach): default analysis window as user preference + per-conversation override
`coachPrefs.defaultWindow` joins the existing prefs blob (tone,
verbosity, excludeMetrics, showEvidenceByDefault). The picker lives in
the Coach settings sheet; the drawer header carries a small pill that
overrides the saved default for the current chat only. Closing the
drawer drops the override so the next session starts on the saved
preference. Backwards-compatible: legacy rows missing the key resolve
to "allTime", matching the v1.4.24 behaviour. The chat route folds the
saved window into snapshot scope when the client doesn't supply one.
2026-05-14 13:28:10 +02:00
Marc-André Bombeck 35f068ac26 feat(analytics): berlinDayKey callers honour user displayTimezone
The four routes that bucket by Berlin calendar day (dashboard summary,
medication intake, analytics correlations and sleep, insights targets)
now read userTz from the authenticated User and pass it through
userDayKey(). Streaks and consistency strips for a Pacific/Auckland user
land on their Auckland-day, not the legacy Berlin-day.

The aggregation logic is unchanged — only the bucket key function
swaps from berlinDayKey(date) to userDayKey(date, userTz). Existing
Berlin users see no behaviour change (Europe/Berlin is the schema
default).

Integration test extended with a two-case assertion: the userDayKey()
boundary case (13:00 UTC May 14 → 2026-05-15 Auckland vs 2026-05-14
Berlin) and the dashboard summary route returning a non-zero streak for
an Auckland account with three consecutive Auckland-day measurements.
2026-05-14 12:52:01 +02:00
Marc-André Bombeck 8e4a8080dc i18n(targets): add EN + DE strings for redesigned card primitives
Removes the duplicate `targets.mood` + `targets.moodStability` flat
strings that collided with the new `targets.mood.stability.{stable,
variable,highlyVariable}` object added in Phase 3. The flat versions
were unreferenced — the canonical mood label lookup goes through
`targets.label.MOOD_SCORE` / `targets.label.MOOD_STABILITY` — so
removing them is a no-op for the UI and unblocks the
i18n-locale-integrity duplicate-key test.

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

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

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

Verification:
  - pnpm typecheck: clean
  - pnpm test: 2325 / 2325 passing
  - pnpm lint: clean
  - i18n-locale-integrity: 9 / 9 passing (duplicate keys removed)
2026-05-14 12:43:02 +02:00
Marc-André Bombeck f3c9e2d7a6 feat(targets-api): add daysInRange7d + lastMetGoalAt + streakDays + pageSummary
The Zielwerte redesign needs three new per-target signals so the page
can answer "how consistent am I" without a chart:

- daysInRange7d / daysLogged7d — Berlin-tz day buckets over the last
  week. The client renders 7 dots, one per day, filled when the day's
  mean reading landed in the green band, hollow when logged but
  out-of-band, dimmed when the day had no reading.
- consistency7d — the day-by-day band classification array the strip
  reads directly (avoids re-deriving from sums client-side).
- daysInRange30d / daysLogged30d — same rollup over 30 days for the
  page summary line and the optional details popover.
- lastMetGoalAt — Berlin-tz date the user most recently hit the
  target band, or null when no in-range day in the last 30. Surfaces
  the maintainer-tester case the W5 reconcile flagged (today lands
  in-range by luck but the user has not actually hit goal in weeks).
- streakDays — consecutive in-range days ending today. Capped at 365.
- insufficientData — true when the target has < 3 readings in 30d
  OR < 1 day logged in 7d. The client hides the strip + percentage
  when set so we never surface a misleading "0 of 7" caption.

Schema also adds `pageSummary: { targetsMetThisWeek, totalTargets,
streakHighlight }` for the new account-level header. "Met this week"
threshold is daysInRange7d >= 4 — half of a logged week, lenient
enough to feel earnable. Streak highlight surfaces the longest
current streak ≥ 3 days.

Implementation collapses the v1.4.22 two-window (60-day) fetch back to
a single 30-day fetch — the now-removed delta-vs-last-month was the
only reason for the second window. The route is one Prisma query
shorter than v1.4.22. The shared `rollupConsistency` /
`rollupFromDayMap` helpers also pay off Senior-Dev M2 (the 7-pass
per-type computation collapses to one bucket-by-day pass per metric).

Per-day band rules:
- Continuous metrics (weight, pulse, sleep, body fat, glucose, steps,
  mood) — classify the day's mean against the same green band the
  RangeBar paints. Orange band reuses the pulse / glucose
  configurable orange when available; otherwise symmetric ±30%.
- BP / BP-in-target — sys+dia paired within 5 min, day is "in" when
  every pair clears the target, "near" when at least one does.
- MEDICATION_COMPLIANCE — day-level intake rollup. ≥ 99% taken = in,
  ≥ 50% = near, otherwise out. Days with zero scheduled doses
  return null so they don't break the streak.
2026-05-14 12:21:25 +02:00
Marc-André Bombeck 86a52dac33 refactor(targets): remove v1.4.22 per-card sparkline + delta caption
The v1.4.22 C1 inline sparkline + "Δ vs. last month" caption never
earned their place on /targets. The W5 reconcile filed four findings
against them (Design M2 — sparkline ignores min-points threshold so 3
points read as "lots of activity"; M7 — SVG aria-hidden hides the only
motion cue from screen readers; L3 — preserveAspectRatio="none"
distorts the y axis; Senior-Dev M2 — 7 sequential Array.filter passes
on the route). The replacement primitive (a 7-day consistency strip)
lands in the next phase; this commit just removes the dead weight.

Drops:
- Sparkline component, TargetItem.points30d, TargetItem.deltaVsLastMonth
- The 60-day fetch and the per-type sparklinePoints / deltaVsLastMonth
  helpers in the route handler
- targets.deltaVsLastMonth, targets.deltaVsLastMonthFlat,
  targets.deltaUnavailable i18n keys (EN + DE)
- targets-sparkline.test.tsx (the consistency-strip suite replaces it
  in phase 3)
2026-05-14 12:15:17 +02:00
Marc-André Bombeck 3e9881f3fd refactor(insights): remove duplicate InsightStatusCard mount — superseded by InsightAdvisorCard since v1.4.16
The /insights page rendered both <InsightAdvisorCard> (the polished
severity-ordered recommendations grid + per-rec rationale + confidence
meter + medical-citation footnotes shipped in v1.4.16 phase D
reconcile) AND <InsightStatusCard title={t("insights.generalStatusTitle")}>
(the v1.4.15 text-only summary). The page comment at the advisor
wiring acknowledged the status card "was intended to be replaced"
during D-reconcile but the status mount was never removed, so users
saw two cards saying the same thing — one labelled
"KI-Gesundheitsanalyse" and one labelled "Allgemeiner
Gesundheitszustand".

Removed:
  - The <section id="section-general"> block + its <InsightStatusCard>.
  - The `generalStatus` useQuery + the unused `GeneralStatusData`
    interface.
  - The `generalStatus?.updatedAt` entry from freshestUpdatedAt(); the
    other six per-section caches still feed the hero's "Generated …"
    caption.
  - The "section-general" entry from the sticky pill nav (it had
    nothing left to scroll to).

Kept:
  - The /api/insights/general-status route + the underlying
    generateGeneralStatusForUser lib — the reminder worker still
    pre-warms the daily general-status cache for users opted in to the
    briefing notification. Added a JSDoc @deprecated note on the route
    pointing new callers at <InsightAdvisorCard> /
    useInsightsAdvisorQuery().
  - The insights.generalStatusTitle + insights.generalStatusBadge.*
    translation keys — they're still referenced by the per-section
    badge labels (insights.generalStatusBadge.good / .watch /
    .critical map traffic-light states on every metric section, not
    just the dropped general one).
2026-05-14 11:50:49 +02:00
Marc-André Bombeck ca03c6099f chore(release): v1.4.24
Security + accessibility hardening release. Closes the highest-priority
findings from a focused security audit of the v1.4.23 release:

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

Verification: pnpm typecheck && pnpm lint && pnpm test (2244/2244)
&& pnpm format:check all green.
2026-05-12 22:33:35 +02:00
Marc-André BombeckandClaude Opus 4.7 650f150353 fix(coach): feedback references coach_messages, drops plaintext column
Reviewer note: W5 H7 wrote Coach assistant prose plaintext into
recommendation_feedback.recommendation_text. Same prose lives
encrypted in coach_messages — backups captured the leak.

Migration 0042_coach_feedback_message_ref adds nullable coachMessageId
(FK + cascade) and drops recommendation_text. Aggregator joins +
decrypts on read; admin view reads through the same path.

Fixes: Sr-H3 (Wave-6 v1.4.23 senior-dev review)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:35:45 +02:00
Marc-André BombeckandClaude Opus 4.7 58ae9bca42 feat(coach): partial-malformed observability on sentinel parser
The W3-of-v1.4.22 sentinel parser silently dropped malformed rows and
fired `coach.keyvalues.parse_failed` only when the entire block was
unusable. Mixed-format drift (one row breaks, the other six parse)
landed without any signal. Surface it.

- `SentinelParseResult.malformedEntries[]` records each skipped row
  with a typed reason (`missing_colon` / `value_overflow` /
  `label_overflow` / `no_END_marker` / `byte_overflow` /
  `schema_invalid`). Block-level failures (truncation, missing close
  marker) land here as synthetic entries so the wide-event always
  carries a typed cause.
- Per-row label and value caps moved out of the Zod schema into the
  parser so the row-level reason can be `label_overflow` /
  `value_overflow` rather than the catch-all `schema_invalid`.
- Chat route splits the annotation: `coach.keyvalues.parse_partial`
  when at least one row parsed AND at least one row failed, the
  existing `coach.keyvalues.parse_failed` stays for full-block
  failures. Both annotations carry the per-line reason array.
- Backwards-compatible `parseKeyValueLine` wrapper preserved.

Tests +5: label_overflow, value_overflow, byte_overflow,
mixed-malformed entry assertions, and a tryParseKeyValueLine
direct-API surface check.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:23:04 +02:00
Marc-André BombeckandClaude Opus 4.7 2075d392b1 refactor(api): extract createSseStream helper from chat route
The Coach chat route had three identical `new ReadableStream({
start(controller) { try { ... } finally { controller.close(); } } })`
blocks (refusal / provider-error / happy path), differing only in
which frames they enqueued. Extract the boilerplate into
`src/lib/sse/create-stream.ts` so the v1.5 iOS streaming endpoints
inherit the same try/finally close semantics without copy-paste.

The helper accepts sync or async emit callbacks; the controller is
always closed in `finally` so a thrown emit never hangs the response.

Three unit tests pin the sync, async, and throw paths.

Closes Sr-H2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:12:22 +02:00
Marc-André BombeckandClaude Opus 4.7 75ccc01984 fix(coach): graceful fallback when sentinel-only output strips empty
When the model emits only a `---KEYVALUES---` block with no leading
prose (or a malformed block missing the closing marker), the
post-strip prose is empty. The previous fallback chained back to
`rawReply`, surfacing raw sentinel markers to the user. Replace with
a structured `coach.provider.empty` error frame so the UI renders
the localised provider-error copy instead of leaking the markers.

Add a regression integration test pinning that the streamed body
never contains `---KEYVALUES---` for a sentinel-only reply, and that
no assistant message is persisted in this branch.

Closes Code-H1.

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:46:32 +02:00
Marc-André BombeckandClaude Opus 4.7 a22f77881e feat(targets): 30-day sparkline + Δ-vs-last-month caption per card
The Zielwerte page read as a static reference cheat-sheet — every card
surfaced a current value + range bar but no recent trajectory. Each
target now grows a dependency-free 30-day sparkline beneath the range
bar plus a localised "Δ −2.3 kg vs. last month" caption so the page
reads as a living health log rather than a guideline lookup.

The API computes both fields from a single 60-day query (one round-trip,
not two): `points30d` is the daily-mean series for the last 30 days,
`deltaVsLastMonth` is the signed difference between the current and
prior 30-day averages. Both are null when either window has fewer than
3 readings so cold-start accounts don't see a misleading flat trace
or a "± 0" comparison that's really "no comparison".

BMI piggybacks on the weight series (divided by height²) so the BMI
card's sparkline matches its range bar's y-axis. Derived metrics
(BP-in-target rate, mood stability, medication compliance,
blood-glucose-by-context) keep their previous chrome — the rich
status surface they already render is the right answer there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:21:48 +02:00
Marc-André BombeckandClaude Opus 4.7 7b8efaae14 feat(coach): parse evidence-block sentinel out of streamed prose
Add a text-marker sentinel parser (`---KEYVALUES---` … `---END---`)
that strips the Coach's evidence block out of the prose before it
streams to the client, validates each line against a Zod schema, and
attaches the parsed entries to `provenance.keyValues`. The persisted
envelope round-trips so the disclosure renders on conversation
reload. Hard-capped at 8 lines and 1 KB to guard against
prompt-injection padding; malformed blocks log a wide-event and pass
the prose through unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:07:32 +02:00
Marc-André BombeckandClaude Opus 4.7 52dd9ff9c3 docs(planning): record v1.4.20.1 hotfix wave
New `.planning/phase-v1421-hotfix-report.md` summarises the six
fixes (Daily Briefing regenerate · Coach day-level snapshot · Coach
prompt update · streaming-bubble dedup · drawer header · sources rail
scope picker), the test-count delta (2026 → 2036 unit), the
token-budget impact of the day-level snapshot (~190 → ~3000 tokens
per full-scope turn), and a recommendation to ship as a v1.4.20.1
hotfix.

STATE.md grows a Phase F section + status block tracking the six
commits on develop. Includes prettier polish across the touched
coach surfaces (sources-rail, drawer, message-thread, snapshot,
chat route).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:31:20 +02:00
Marc-André BombeckandClaude Opus 4.7 ed61b17f57 feat(coach): snapshot includes day-level readings with weekday labels
The Coach snapshot used to ship only aggregated statistics (mean,
slope, SD, range, count) per metric. A turn that asked
"why was BP higher on Monday?" had no day-level rows to ground in
and the model fell back to apologetic aggregate-only phrasing
("from the snapshot I cannot isolate the specific Monday").

Extend buildCoachSnapshot to fold in:
  - timeline.recent: one row per UTC day for the last 14 days, each
    tagged with its weekday so the model can answer day or weekday
    questions directly. BP rows pair systolic + diastolic; days with
    only one side measured are dropped to avoid fabricating a
    complement.
  - timeline.weekly: ISO-week buckets covering days 15..N where N is
    the configured window. Keeps the prompt budget tight on 30/90-day
    windows while still letting the Coach cite older weeks.
  - per-day medication adherence rows from MedicationIntakeEvent so
    the Coach can answer "did I miss my dose Tuesday?" without
    inventing the schedule.

Also add the optional `scope` field to coachChatRequestSchema (sources
+ window) so the upcoming sources-rail picker can narrow the snapshot
without breaking older clients — missing fields fall through to
all-source last30days.

A typical 30-day, 5-source snapshot lands around 2.4-2.8 KB JSON
(≈700-900 prompt tokens) — comfortably inside the 25k/day Coach token
budget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:21:48 +02:00
Marc-André BombeckandClaude Opus 4.7 7921ffc8e6 fix(insights): wire generate route to strict prompt so dailyBriefing emits
The /api/insights/generate route was still calling the legacy
getInsightsSystemPrompt that returns the v1.4.5 {changed, stable,
drivers, …} shape and never asks the model for a dailyBriefing block.
GROUND RULE 8 (emit dailyBriefing when the snapshot has signal) lives
on getStrictInsightsSystemPrompt in src/lib/ai/prompts/insight-
generator.ts; the route never adopted it. Re-runs from the hero strip
therefore stored a payload with no briefing, the use-insights-advisor
lift saw null, and the briefing card painted its empty state forever.

Switch the route to getStrictInsightsSystemPrompt(locale). Existing
strict-shape tolerance is unchanged: insightResultSchema.safeParse
falls through to the raw parsed body on shape mismatch, and
aiInsightResponseSchema's passthrough() keeps any custom keys for
forward compat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:20:50 +02:00
Marc-André BombeckandClaude Opus 4.7 e632e26854 fix(coach): repair SSE idempotency and error-frame handling
- Drop the withIdempotency wrap on POST /api/insights/chat. The wrapper
  caches response bodies via cloned.text() then replays through
  NextResponse.json(JSON.parse(...)) — turning the SSE wire format into
  a null body on retry. The PWA never sends Idempotency-Key so the bug
  is hidden today; the iOS client would walk straight into it. Dedup
  still holds via the conversationId existence check + 20-turn cap.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:17:15 +02:00
Marc-André BombeckandClaude Opus 4.7 3e037b4d42 feat(api): PUT /api/insights/provider-chain persists user fallback chain
The B5b read-only endpoint now has a write counterpart so the new
Settings → AI dropdown UX can save reorder + enable/disable changes.
Server-side priority is recomputed from insertion order (so the UI's
visual contract — "first row = priority 1" — cannot drift from what
gets persisted), unknown provider types and duplicates 422, and an
empty chain is rejected outright. parseProviderChain still falls
back to the default chain when the persisted JSON is malformed,
so a stale-tab resubmit cannot poison generation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:58:05 +02:00
Marc-André BombeckandClaude Opus 4.7 613d6619df feat(settings): minimal display of active AI provider + configured chain
The AI section now renders a small read-only summary of the user's
fallback chain: "Active provider: ChatGPT (Codex)" plus "Configured
chain: ChatGPT (Codex), OpenAI (your key), Admin OpenAI". When the
in-process cache reroutes to a fallback (the primary 401'd in the
last hour), the line gains "(cached: OpenAI)" so the user can see the
runner has shifted without digging through the wide-event log.

The full management UX (drag-to-reorder, on/off toggles, add/remove
provider entries) is owned by phase B2 — this surface is deliberately
tight so the two phases don't collide. The card hides itself when no
provider is configured at all so accounts that haven't set up AI
don't see an empty header. EN + DE i18n keys added under
settings.ai.providerChain.*; provider-type labels are localised so
"Local model" reads "Lokales Modell" in German.

The new GET /api/insights/provider-chain endpoint is the data source.
Returns the resolved chain in priority order plus the cached active
provider (when set), wrapped in the standard apiSuccess envelope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:34:39 +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é BombeckandClaude Opus 4.7 9b01c86e30 feat(charts): medication chart matches other charts (7d trend + indicator + target-range)
Marc: "Bei Medikamenten zum Beispiel wird jetzt nicht 7-Tage-Schnitt,
Trend und Zielbereiche angezeigt. Das möchte ich natürlich für alle
Charts."

The medication-compliance chart shipped in v1.4.15 A4 (replacing the
static placeholder) was missing the trend chip + target-range
visualization the BP/weight/mood charts already paint. Bringing it to
parity:

- 7-day trend chip in the header: signed delta in percentage points
  with a metric-aware sentiment colour (rising compliance → green,
  falling → orange) and an arrow icon. Mirrors the bg-muted/40 chip
  pattern HealthChart uses for its weekly/monthly bucket label.
- New `computeMedicationTrend7d()` helper computes the delta as the
  mean of the most-recent 7 daily rates minus the mean of the prior 7
  (capped at a 14-day window). Mean-difference rather than slope so a
  single outlier day doesn't wag the indicator.
- Target-range visualization: added a 100 % goal ReferenceLine (green,
  solid-ish) ALONGSIDE the existing 80 % minimum-acceptable threshold
  (yellow, paler dash). Two lines together visualise the target range
  the way HealthChart's targetZone band does for BP/weight tiles.

Tests:
- 6 new unit tests pin computeMedicationTrend7d() including direction,
  delta, the 14-day cap, short windows, stable noise rejection.
- The existing chart-render snapshot is extended with an explicit
  assertion that the trend chip is suppressed in the empty state
  (so the empty-state header doesn't paint a misleading "0 pp"
  indicator).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:32:01 +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 6b88e56aeb chore(format): prettier sweep over phase-2 touched files + stale planning docs
Picks up the prettier line-wraps the v1.5 phase-1 planning docs and
codex-protocol-spec.md missed, and brings the redact.ts + insights
generate route test into the project's standard form. No behaviour
change — pnpm format:check now passes clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:06:52 +02:00
Marc-André BombeckandClaude Opus 4.7 54038216e4 fix(insights): map AI provider errors to 422/503 instead of 500
v1.4.6 T5 fixed only the parse-error branch (502 → 422 to keep
Cloudflare's HTML-error rewrite from breaking `await res.json()` on
the dashboard). Provider errors — `OpenAI request failed (401)` for
an invalid admin key, or transient `OpenAI request failed (5xx)` —
still propagated to the apiHandler's generic 500. Cloudflare rewrites
the 5xx to its own HTML page, so the React Query mutation crashed
with "Unexpected token '<'" instead of surfacing a readable message.

Wraps `provider.generateCompletion(...)` in try/catch and mirrors the
v1.4.5 ai/test categorisation:

  - 401 / 403 from the provider → 422 with "AI provider rejected the
    request — check your API key in Settings > AI"
  - 5xx from the provider → 503 with "AI provider temporarily
    unavailable, try again in a moment"
  - 429 from the provider → 429 (rate-limit passthrough)
  - any other status → 422 generic provider-connection failure

Full error details (message, httpStatus, bodyExcerpt, providerType)
land in the Wide Event via `annotate()` so the operator can debug
without leaking provider URLs / partial keys to the client.

Adds unit tests covering 401/403/429/500/503/unknown mapping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:04:15 +02:00
Marc-André BombeckandClaude Opus 4.7 02b99553b9 chore(style): repo-wide prettier sweep before v1.4.6 tag
Long-overdue prettier sweep; prettier had drifted vs. checked-in code
across ~130 files (mostly long-argument line-break wrapping in TS and
table-padding in markdown). Pure formatting — no semantic changes —
and required to clear the v1.4.6 release gate's `pnpm format:check`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:27:17 +02:00
Marc-André BombeckandClaude Opus 4.7 4a159d2b6c fix(insights): cache hits no longer burn rate-limit tokens
P13. Move `checkRateLimit` below the 24h cache-return path so a request
that gets served from the cached `insightsCachedText` never consumes one
of the 2/hour LLM-generation tokens. A user reloading the dashboard a
few times no longer locks themselves out of a fresh refresh for an hour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:01:17 +02:00
Marc-André BombeckandClaude Opus 4.7 c8ee28d2b0 fix(insights): map LLM-parse failure to 422 instead of 502
Same Cloudflare-rewrites-5xx footgun the v1.4.5 ai/test fix solved. A
malformed JSON response from the upstream model returned HTTP 502, which
Cloudflare swapped for its own HTML error page; the client's
`await res.json()` then crashed with `Unexpected token '<', "<!DOCTYPE "`
and the user — already throttled by the 2/h rate limit — saw nothing
useful. 4xx passes through Cloudflare untouched, so the React Query
mutation can read the JSON envelope and show a real error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:27:07 +02:00
Marc-André BombeckandClaude Opus 4.7 c05f48f520 feat(settings): KI-provider — OpenAI key field, model preset dropdown, gated OAuth
Bucket C of the v1.4.3 polish pass.

Three independent bugs in the KI-provider settings, fixed together
because they all live on the same panel:

1. **Selecting "OpenAI" left no way to provide a key.** The schema
   stored Anthropic and Local API keys, but OPENAI was a valid
   `aiProvider` value with no matching credential column — the
   resolver silently fell through to the admin-level OpenAI key. So
   a self-hosted user who picked OpenAI and typed nothing got the
   admin's key (might not exist), and a user who *wanted* their own
   OpenAI key had no UI surface to enter it.

   Migration `0026_user_ai_openai_key` adds `users.ai_openai_key_
   encrypted`. The resolver's `OPENAI` branch now picks up the
   user-level key first and only falls back to admin if the user
   slot is empty. The settings UI grew an OpenAI-key field gated on
   `provider === "OPENAI"` mirroring the Anthropic flow. The
   `/api/ai/test` override schema accepts `openaiKey` so the
   "Verbindung testen" button can validate an unsaved key without
   persisting plaintext.

2. **Model field was a freetext Input, easy to typo.** Replaced
   with a `<select>` populated from `MODEL_PRESETS` per provider:
   gpt-4o / gpt-4o-mini / gpt-4-turbo / gpt-5 / o3-mini for OPENAI;
   claude-sonnet-4-6 / claude-opus-4-7 / claude-haiku-4-5 / claude-
   3-5-sonnet-latest for ANTHROPIC; llama3.1:8b / llama3.1:70b /
   mistral / qwen2.5 for LOCAL. An "Eigenes…" sentinel still
   surfaces the freetext Input so power users can target preview
   models or fine-tunes. The dropdown disables when no provider is
   chosen — saves the user from filling a model that won't be used.

3. **"Mit ChatGPT verbinden" silently bounced to chatgpt.com login
   with no OAuth grant.** The authorize URL was built without a
   `client_id` parameter, so chatgpt.com couldn't recognise the
   request as an OAuth handshake — it just rendered the regular
   login form. The user came back, nothing connected, the button
   "did nothing".

   Added `CODEX_OAUTH_CLIENT_ID` env var. The URL builder now
   throws `CodexOAuthNotConfiguredError` if it's unset; the
   authorize route maps that to a 503 with an actionable message
   instead of dispatching the dead URL. The settings card reads
   `codexOauthConfigured` from `/api/insights/settings` and hides
   the Connect button when the flag is false, replacing it with a
   tiny italicised "ChatGPT-OAuth ist auf dieser Instanz nicht
   konfiguriert — nutze stattdessen einen eigenen API-Key unten."
   `CHATGPT_OAUTH` was also removed as a manually-selectable
   provider option in the dropdown — the top connect button is the
   path; having both confused the affordance.

Refresh + token-exchange both forward the client_id now too. Test
fixture seeds `process.env.CODEX_OAUTH_CLIENT_ID="test_client_id"`
in `beforeEach` so the existing URL-shape assertion still works,
plus a new test asserts the unset case throws.

Tests + typecheck green (670/670 — one new assertion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:36:36 +02:00
Marc-André BombeckandMarc-André Bombeck 643e6ec3a0 release: v1.4.2 — resurrect v1.4.1 work + bug-fix triple (#152)
* Revert "revert: roll source tree back to v1.4.0 to restore production"

This reverts commit e444a90b10.

* fix(v1.4.2): summaries.WEIGHT + medications array guards + healthcheck IPv4

Three production bugs surfaced after the v1.4.0 deploy stabilized:

1. `TypeError: undefined is not an object (evaluating 'E?.summaries.WEIGHT')`
   on /dashboard and /insights for users without weight readings —
   the optional chain stopped at `data` when it needed to also cover
   `data.summaries`. Fixed: `data?.summaries?.WEIGHT` (one level deeper).

2. `TypeError: y?.filter is not a function` on /medications — the
   medications query value was non-array (likely a stale
   service-worker response from an older API shape). Replaced with
   `Array.isArray(medications) ? medications : []` so the page is
   immune to any future shape drift.

3. Healthcheck-induced 503 outage (already in 1.4.1 — included here
   so 1.4.2 is the single coherent release). The docker-compose
   healthcheck used `localhost` which busybox-wget resolves to IPv6
   ::1; Next.js standalone listens on IPv4 0.0.0.0 only. Pinned
   to 127.0.0.1.

This release also includes everything from v1.4.1 (which never
published a clean GHCR tag): the per-section admin extraction, the
testcontainers integration suite, the Playwright + axe-core E2E
foundation, the moodLog SSRF fix, the central error-redaction in
WideEventBuilder + Glitchtip, the medical-citation drift fixes
(ESH 2023, Saint-Maurice attribution, steps source), and the docs
sync.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
2026-05-08 17:46:31 +02:00
Marc-André Bombeck e444a90b10 revert: roll source tree back to v1.4.0 to restore production
Production at healthlog.bombeck.io has been 503-ing since the v1.4.1
deploys started landing on apps-01 (Coolify). The container boots —
Next.js prints "Ready" and the pg-boss workers run — but never
accepts HTTP on :3000, so the Docker healthcheck fails and Traefik
takes the upstream out of rotation. A manual restart, a Coolify
force-rebuild, and a docker-compose pin to the GHCR :1.4.0 multi-arch
image all failed to bring the site back up — Coolify rebuilds the
image from main HEAD on every deploy regardless of the compose
directives.

This commit resets the working tree to commit 21bd46d (v1.4.0
release). Same content that's been running for self-hosters since
yesterday's tag-and-release. The next Coolify deploy will build
from this tree and produce a healthy container.

The v1.4.1 work is NOT lost:
  - PRs #144, #145, #137, #146, #147, #148, #149, #150 remain in
    git history.
  - Their commits are still tagged (`v1.4.1`), still on the GHCR
    multi-arch image (`ghcr.io/mbombeck/healthlog:1.4.1`), still in
    the GitHub Release notes.
  - Self-hosters who have already pulled the v1.4.1 image keep it.
  - Local development continues from main HEAD with the v1.4.1
    code — the regression only surfaced under the Coolify build
    flow.

Re-applying v1.4.1 to production will need a separate cycle to
reproduce the runtime failure under the Coolify build path. That
work is tracked in docs/ops/v141-followup-issues.md (added back
when the tree is reapplied) and the deploy gating in
.github/workflows/e2e.yml will catch this class of bug going
forward.

No DB migration. No env-var change. No API contract change.
Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
2026-05-08 17:15:34 +02:00
Marc-André Bombeck 70353882eb fix(security): close moodLog SSRF + central error redaction; fix citation drift (#148)
The v1.4.1 audit pass turned up one CRITICAL, two HIGH, and four
medical-citation drift findings. This commit addresses everything
that's safe to land in a hardening release; deeper architectural
fixes (idempotency-race transactional rewrite, encryption-key
fallback gate, refresh-token reuse-detection serialisation, moodLog
secret HMAC lookup column) are tracked for v1.4.2 in
docs/ops/v141-followup-issues.md.

Security
--------

CRITICAL — moodLog SSRF
  src/lib/validations/moodlog.ts now refines the credentials URL
  with isPublicUrl() so an authenticated user cannot store
  http://169.254.169.254/ (cloud metadata) or RFC1918 hosts.
  src/lib/moodlog/sync.ts re-checks isPublicUrl(baseUrl) at the
  fetch site (so legacy rows stored before the credential guard are
  also refused) and switches to redirect: "manual" + 3xx → fail so a
  public host cannot 302 the request to an internal target with the
  user's apiKey on the redirect hop. New unit test
  src/lib/validations/__tests__/moodlog.test.ts asserts the guard
  rejects 169.254.x.x, 10.x, 172.16.x.x, 192.168.x.x, 127.0.0.1, and
  localhost.

HIGH — error-message secret leakage
  New src/lib/logging/redact.ts redacts Bearer tokens (incl. our
  hlk_/hlr_), Telegram bot URLs (bot<digits>:<token>), and
  query-string secrets (?secret=, ?code=, ?token=, ?api_key=). The
  redactor is called from WideEventBuilder.setError() — every error
  reaching Loki is scrubbed once, centrally — and from the
  reportToGlitchtip() call in api-handler.ts so the Glitchtip
  incident UI is also protected. New unit suite covers the four
  patterns plus over-redaction (Bearer's \\S+ greedily consumes
  anything up to the next whitespace, which is the safer side of
  the trade-off).

Truthfulness — citation drift fixes (audit found 0 hallucinations,
4 drift items)
  - messages/{en,de}.json bpClassificationTitle and avg30dEsc:
    "ESC/ESH 2018" → "ESH 2023". User-visible on the dashboard and
    doctor-report PDF. The numeric thresholds are unchanged from
    2018; the 2023 update is ESH-only since ESC withdrew from
    joint authoring.
  - src/lib/analytics/classifications.ts: section header,
    getBpTargetsByAge() docblock, and inline comment updated to
    cite ESH 2023 with the same "numerically unchanged" caveat.
  - src/app/api/insights/targets/route.ts:375: source label "WHO"
    → "Saint-Maurice JAMA 2020". The AI-prompt anti-pattern guard
    explicitly forbids citing WHO for a step number; this surface
    was the last "WHO" mislabel in the codebase.
  - Saint-Maurice "mortality plateau 8000-12000" attribution
    softened to "continued dose-response benefit through ~12,000
    steps/day, not a plateau" in src/lib/medical-citations.ts,
    src/lib/analytics/effective-range.ts,
    src/lib/analytics/classifications.ts, and both AI prompts. The
    original JAMA 2020 paper shows continuing benefit (HR 0.49 at
    8k vs 4k, HR 0.35 at 12k); the plateau-shaped finding belongs
    to Paluch 2022 Lancet PH (PMID 35247352), not Saint-Maurice.

Quality gates
  pnpm typecheck         clean
  pnpm test              669 / 669 (was 658; +11 from new suites)
  pnpm test:integration  10 / 10 (untouched)
  pnpm lint              0 errors
2026-05-08 16:30:37 +02:00
Marc-André BombeckandMarc-André Bombeck f9ff450e08 perf(insights): batch the medication-compliance and per-type latest queries (#125)
Two N+1 patterns the v1.4 discovery audit caught — the same shape v1.3
fixed in /api/insights/comprehensive but in adjacent code paths:

1. **`extractFeatures` (used by every AI-insight route)** issued one
   `prisma.medicationIntakeEvent.findMany` per active medication,
   in-loop. Replaced with a single windowed query
   (`medicationId IN (...)` + `scheduledFor >= 90 days`) and an
   in-memory Map group, mirroring the V3 fix in `/api/insights/
   comprehensive`. Latency now scales with total recent intakes, not
   `medications × intakes-per-medication`. The 90-day cutoff matches
   the longest window `calculateCompliance` consults.

2. **`/api/insights/targets`** issued one `prisma.measurement.findFirst`
   per `MeasurementType` (×~10) to fetch each type's most recent
   value. Replaced with a single `findMany({ distinct: ["type"],
   orderBy: { measuredAt: "desc" } })`. One round-trip instead of N.

Behaviour identical, just fewer round-trips. Tests stay at 371/371.

Note: the eager Recharts import in `app/insights/page.tsx` (the audit
also flagged it as a bundle bloat that neutralises the existing
`dynamic()` wrappers) is its own refactor — every ScatterChart usage
inside the page needs to be lifted into its own component before
dynamic-loading helps. Tracked for a follow-up perf PR so this one
stays scoped to query-side wins.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:11:57 +02:00