Commit Graph
633 Commits
Author SHA1 Message Date
Marc-André Bombeck b4ed2eda9e feat(api): GET titration-ladder + current-step for GLP-1 medications
Returns the EMA-reference ladder for the medication's drug, the
user's matched current step (snapped within ±10 % of their latest
dose), elapsed whole-weeks on that step, the immediate-next step,
and the observational escalation-due flag.

  GET /api/medications/[id]/titration
    -> { drugId, drugInn, ladder, currentStep, currentStepIndex,
         weeksOnCurrentStep, nextStep, escalationDue, sourceEMA }

Defence-in-depth gating:
  - requireAuth() + medication ownership check (404 on mismatch);
  - 404 when treatmentClass !== 'GLP1' (the UI already gates here);
  - 404 when the medication name doesn't map to a catalog brand.

Reads the user's latest dose from the MedicationDoseChange stream
(history of record); falls back to parsing the legacy free-text
medication.dose only when the stream is empty.

Pure-read; no writes; annotate() instrumentation only. Same shape
as the W19e cadence route — math lives in the pure helpers, prisma
lives in the route.

8 cases pass covering 401, 404 paths (non-owner, non-GLP-1,
unknown brand), the happy path on a 5 mg Mounjaro user, the
non-standard-dose null path, the ladder-ceiling no-next-step path,
and the escalationDue=true boundary past EMA dwell-time.
2026-05-14 21:23:14 +02:00
Marc-André Bombeck bdaf128931 feat(medications): pure titration-ladder helpers from W19a knowledge
W19f reads the EMA titration schedules already pinned in the
glp1-knowledge layer (titrationStepsMg + titrationIntervalWeeks) and
normalises them into a TitrationStep contract the upcoming API
route and detail-page section will share:

  - getLadder(drugId) / ladderFromRecord(record)
  - findCurrentStep(drugId, latestDoseMg) — snaps within ±10 %, null
    outside the tolerance window so non-standard doses don't get
    falsely highlighted
  - nextStep(drugId, currentStep) — null at the ceiling
  - weeksOnCurrentStep(drugId, currentStep, doseChanges, asOf) —
    counts elapsed whole-weeks since the latest matching dose-change
    row
  - escalationDue(drugId, currentStep, weeksOnStep) — observational
    flag (titrationIntervalWeeks reached AND next step exists)

The escalationDue helper is a *display* flag, not advice — the UI
copy stays observational (MDR boundary, parallel to W19c ground
rules). No DB access; the route owns prisma, this module owns math.
Same shape as the W19d / W19e pure modules.

22 cases pass covering every EMA-approved drug's ladder, tolerance
boundaries, the ceiling edge, weeks-on-step for empty / matching /
future histories, and the escalationDue toggle.
2026-05-14 21:21:57 +02:00
Marc-André Bombeck 71cc05acd6 feat(medications): GLP-1 cadence visualisation + compliance chips on detail page 2026-05-14 21:15:24 +02:00
Marc-André Bombeck 3ef49679ed feat(medications): cadence + compliance read endpoint 2026-05-14 21:11:54 +02:00
Marc-André Bombeck 4af09170bb feat(medications): pure cadence + compliance helpers for GLP-1 detail 2026-05-14 21:10:45 +02:00
Marc-André Bombeck 7dfc212634 feat(medications): side-effect logging on GLP-1 detail pages
Adds the /api/medications/[id]/side-effects collection + per-row
endpoints (GET / POST + DELETE), the SideEffectsSection picker UI,
and i18n across the six locales. Mounted on the medication detail
page between the drug-level chart and the intake history; only the
GLP-1 treatmentClass renders the section.

The picker is category → entry chip → 5-Likert severity with an
optional 280-char note. The server derives the category from the
entry server-side — a client-supplied mismatch returns 422, so a
NAUSEA row tagged as INJECTION_SITE never lands in the DB.

POST is rate-limited 30/min/user (matching the inventory route);
DELETE is allowed at any time because the rows are user-owned and
non-clinical, with the audit trail preserving entry + severity +
occurredAt so a forensic reconstruction stays possible.

DE + EN copy hand-curated; FR / ES / IT / PL drafted from EN with
EMA-EPAR-§4.8 terminology. 39 new tests cover the route contract
(auth, ownership, validation, category mismatch, rate-limit) and
the section's static-render shape (empty state, category badge,
severity ladder, entry label, locale switch).
2026-05-14 21:01:58 +02:00
Marc-André Bombeck 4e2b7be472 feat(medications): pure side-effect taxonomy + severity Likert helpers
Single source of truth for the GLP-1 side-effect taxonomy — entry →
category mapping plus the reverse index, ordered category list, and
a 1-5 Likert → semantic-label helper. The DB stores `category`
denormalised for cheap range scans; this module is the authority and
the API write path uses `categoryForEntry()` instead of trusting a
client-supplied category.

Severity helpers return locale-agnostic ladder keys (`mild` …
`verySevere`); the UI maps each to a translated label so the picker
stays purely structural. Tests pin every contract the API validator
and picker depend on: 21 entries × 5 categories with the EMA-derived
counts (GI 5, METABOLIC 4, INJECTION_SITE 4, COGNITIVE 4,
GLP1_SPECIFIC 4); every entry maps to exactly one category;
severity-ladder index is monotonic.
2026-05-14 20:54:35 +02:00
Marc-André Bombeck bfd129fc7f feat(medications): 90-day research-mode acknowledgment staleness gate 2026-05-14 20:45:00 +02:00
Marc-André Bombeck 8502e7b11f test(coach): adversarial refusal probes for drug-level questions 2026-05-14 20:43:26 +02:00
Marc-André Bombeck 79f8234104 feat(coach): GROUND RULE 15 — refuse drug-level estimates with MDR cite 2026-05-14 20:41:52 +02:00
Marc-André Bombeck 20a72d2e32 feat(settings): research-mode toggle with version re-prompt banner
Adds the Research Mode card to Settings → Advanced alongside the
existing danger zone. Toggle ON opens the acknowledgment dialog (the
dialog owns the POST so the user reads the disclaimer first); OFF
fires DELETE directly. An amber re-prompt banner appears above the
toggle whenever the server's currentDisclaimerVersion drifts ahead of
the acknowledgedVersion, surfacing a Re-acknowledge CTA that re-opens
the dialog without flipping the toggle. The Advanced section
description is updated across all six locales to reflect that the
section now hosts both controls.
2026-05-14 20:30:51 +02:00
Marc-André Bombeck 85ba382009 feat(medications): estimated drug-level chart on GLP-1 detail pages
Recharts AreaChart over a 21-day window, anchored at now. Wires the
W19c-Backend pure PK module: drug id resolved from the medication
brand via the catalog, doses derived from non-skipped intake events
matched against the dose-change history. Y-axis is unit-less per
research §2.3 — no tick labels, the 'Estimated level (relative)'
caption sits above the frame. The chart gates on enabled +
acknowledgedVersion === currentDisclaimerVersion (defence-in-depth
alongside the server 400) and renders distinct placeholders for
each failure mode. Mounted on /medications/[id]/history when
treatmentClass === GLP1.
2026-05-14 20:26:21 +02:00
Marc-André Bombeck 3502f57694 feat(medications): research-mode MDR acknowledgment dialog
Opt-in modal that gates the GLP-1 drug-level chart behind an MDR-aware
disclaimer. Reads the live disclaimer version from the server and posts
it back on acknowledge so a server-side bump forces re-acknowledgment.
Sections cover what the chart is, what it isn't, why population PK is
an estimate, the EU 2017/745 + MDCG 2021-24 boundary, and EMA + ASCPT
psp4.13099 citations. Stale-version 400 surfaces a localised error
toast and keeps the dialog open.
2026-05-14 20:19:25 +02:00
Marc-André Bombeck b896622d22 feat(onboarding): baseline + done steps + welcome-back banner + entry-point swap 2026-05-14 20:08:19 +02:00
Marc-André Bombeck 888efd6cf4 feat(onboarding): source 4-card grid with Apple Health coming-soon 2026-05-14 20:03:17 +02:00
Marc-André Bombeck c7b1fdb022 feat(onboarding): goals chip-picker on step 1 2026-05-14 20:00:19 +02:00
Marc-André Bombeck 983c2ff26a feat(onboarding): value-prop carousel on welcome step 2026-05-14 19:56:00 +02:00
Marc-André Bombeck cf27df49c6 feat(api): research-mode acknowledgment endpoint (GET/POST/DELETE)
Three handlers at /api/auth/me/research-mode gate the opt-in GLP-1
PK research-view chart behind an MDR-disclaimer acknowledgment.

  GET     — returns the persisted flag/version + the live
            currentDisclaimerVersion so the Settings UI can detect
            copy drift and re-prompt.
  POST    — { acknowledged: true, version } turns the flag on.
            Rejects 400 when the submitted version does not match
            the server-side RESEARCH_MODE_DISCLAIMER_VERSION; rejects
            422 on malformed body. Per-user rate-limit 5/min — this
            is a one-tap dialog, not a hot path.
  DELETE  — idempotent: clears all three columns and writes an
            audit row even when the flag is already off.

Prompt-injection guard — the body shape accepts the version string
only (not free text); the value is matched against a server-side
constant before any DB write and never reaches the LLM. Hand-rolled
validation keeps the surface visibly tiny in code review.

11 route tests cover unauth/enabled/disabled GET; happy-path POST
with audit; stale-version POST; malformed POST; rate-limited POST;
unauth/successful/idempotent DELETE with audit.
2026-05-14 19:55:30 +02:00
Marc-André Bombeck 36d147e4f4 feat(medications): one-compartment GLP-1 PK helpers (qualitative use)
Pure module — no I/O, no Date.now(), reads only the static catalog
in glp1-knowledge.ts. Implements Bateman first-order absorption +
first-order elimination per research §2.2, exposes:

  computeOneCompartment — array of {tHours, concentration} samples
                          across a configurable window for the
                          opt-in research-view AreaChart.
  shotPhaseAt           — qualitative rising/peak/fading/none chip
                          per research §2.4.

Two-compartment fidelity is deliberately out of scope. The
journal-of-record (Schneck & Urva 2024, psp4.13099) confirms a
two-compartment structure as the high-fidelity model, but importing
it would invite numeric concentration readouts that cross the EU MDR
Class I threshold (research §11, §12.4). The one-compartment closed
form is sufficient for the qualitative chip and the unit-less
research chart; the rigorous variant waits for v1.5 / R8 alongside
the medical-device review.

RESEARCH_MODE_DISCLAIMER_VERSION (2026-05-14.1) is exported from
this module so the API endpoint records exactly which copy version
the user has acknowledged, and the Settings UI re-prompts on
version drift.

15 tests cover: per-drug smoke, zero-before-dose, empty input,
linear superposition, window/step overrides, sawtooth shape, phase
classification at fresh/mid/tail timepoints.
2026-05-14 19:53:19 +02:00
Marc-André Bombeck 4849ac7634 feat(api): add POST /api/onboarding/step for wizard progression
Persists per-step progress with strict order enforcement: submitted
step must equal current+1, step 4 flips onboardingCompletedAt and
clears the hl_onboarding proxy cookie. Re-reads the fresh User row to
defend against tab-race conditions, audit-logs every transition, and
caps writes at 30 per 10 min per user to defang stuck-retry loops.

409 on out-of-order, 409 on already-completed, 422 on payload
validation failure, 429 on rate limit.
2026-05-14 19:39:42 +02:00
Marc-André Bombeck f2ec1e2c6f feat(onboarding): scaffold /onboarding/[step] nested routes
Adds the dynamic step segment (steps 0-4) plus a pass-through layout
that leaves the legacy /onboarding root page untouched. Each step page
renders the OnboardingShell with a placeholder body the Content agent
will replace.

Server-side gating enforced:
  - unauthenticated → /auth/login
  - onboardingCompletedAt != null → /
  - out-of-order forward jumps → user's current onboardingStep
Backwards navigation stays allowed so the shell's Back button works.
2026-05-14 19:38:04 +02:00
Marc-André Bombeck 9a9827e5fe feat(onboarding): add OnboardingShell wizard chrome primitive
Server component that paints the consistent step frame (logo, four-dot
progress strip, back/skip/next CTA row, safe-area-bottom padding) for
the rebuilt /onboarding/[step] flow. Step pages render their step-
specific body into the children slot; the shell owns every surrounding
pixel.

Translator falls back to the raw i18n key when keys are missing, so the
shell renders cleanly before the full onboarding.* key surface lands.
2026-05-14 19:36:14 +02:00
Marc-André Bombeck 223b8a9208 test(personal-records): detection worker + queue + UI badge coverage
Adds the integration suite for the detection worker against a real
Postgres testcontainer. Covers measurement-driven PRs (steps,
RHR, VO2 max), idempotency on re-run, silent-flag propagation,
and the drift guard that every PR-trackable metric type lands in
the scan loop.

Also patches a pre-existing schema gap: the
(userId, metricType, metricSlot, achievedAt) unique index on
personal_records is NULLS-DISTINCT in Postgres, so the index alone
does not dedup the measurement-driven rows (metricSlot = NULL).
The worker now does an application-level pre-flight findFirst on
the same composite key before each insert, keeping both the
workout path (index covers it) and the measurement path
(application-level guard) idempotent without a Migration.
2026-05-14 19:02:47 +02:00
Marc-André Bombeck dd44a0df49 fix(build): resolve safety-contracts YAML path from cwd, not __dirname
Turbopack rewrites `__dirname` to a synthetic `/ROOT/...` token at
build time, so `readFileSync(join(__dirname, 'safety-contracts.fr.yaml'))`
crashed during page-data collection in the Docker build with an
ENOENT. The chunk error fixed in the previous commit was hiding the
issue.

The path now resolves through `process.cwd() + 'src/lib/ai/prompts'`,
which stays stable across `next build`, `next start`, and the test
runner. `outputFileTracingIncludes` in `next.config.ts` pulls the YAML
siblings into the standalone runtime image so the read succeeds at
deploy time.
2026-05-14 19:01:58 +02:00
Marc-André Bombeck e51a4f53bb feat(insights): PR badge on metric trend tiles when achieved in last 30 days
Adds a small text-only PR pill that consumers can compose next to
a metric label. The badge pulls /api/personal-records?metricType=
and renders only when the most recent record landed in the last
30 days — older PRs are still visible on the dedicated /insights
PR list but the dashboard surface keeps a fresh-only filter so the
pill is a noticeable signal rather than a permanent fixture.

i18n keys insights.personalRecord.badge and insights.personalRecord.tooltip
ship in all six locales. The badge component is locale-aware
through the standard useTranslations / useFormatters hooks and
exposes a withTooltip prop so standalone uses (doctor report,
correlation cards) can skip the Tooltip provider.
2026-05-14 18:59:14 +02:00
Marc-André Bombeck 87853eb2ac fix(validations): route source-priority parse breadcrumb via observer
`@/lib/validations/source-priority` imported `annotate` from
`@/lib/logging/context`, which pulls `node:async_hooks` via
`AsyncLocalStorage`. Any client component touching a type-only export
(e.g. `DEFAULT_SOURCE_PRIORITY` in `components/settings/sources-section`)
dragged async_hooks into the browser bundle and broke the Turbopack
build.

The validations module now exposes `registerSourcePriorityParseObserver`
with a no-op default. `@/lib/logging/context` registers the real
`annotate` callback on first server-side import, so the breadcrumb
still surfaces in ops dashboards while the client bundle stays clean.
2026-05-14 18:57:59 +02:00
Marc-André Bombeck 7bede83d22 fix(tz): split pure helpers out of resolver to keep client bundle clean
`@/lib/tz/resolver` imported `prisma` at module top so any client
component that pulled `detectBrowserTimezone`, `listSupportedTimezones`,
`formatInUserTz`, `userDayKey`, or `DEFAULT_TIMEZONE` dragged
`node:module` into the browser bundle and broke the Turbopack build
(register page, timezone picker, admin general/login sections,
`@/lib/export`).

Pure helpers now live in `@/lib/tz/format` (no Prisma). The resolver
re-exports them so every server-side caller keeps its
`@/lib/tz/resolver` import path; the four client modules + the
export helper now import from `./format` directly.
2026-05-14 18:57:52 +02:00
Marc-André Bombeck ee560a435c fix(medications): glp1 drift test self-skips when research file is absent
The drift guard read `.planning/research/glp1-feature-inspiration.md`
unconditionally and threw an ENOENT at collection time on CI, where
`.planning/` is local-only. The hard-pin block (TS module vs cited
EMA EPAR / psp4.13099 values) is the real production guarantee and
keeps running everywhere; the soft-pin blocks that grep the research
markdown now `describe.skipIf` when the file is missing.
2026-05-14 18:57:45 +02:00
Marc-André Bombeck 4feeafab96 fix(health-score): derive asOf deterministically from input dates
The fallback for missing `attribution.windowEndAt` was `new Date()`,
which made consecutive `computeHealthScore()` calls drift by a
millisecond on loaded CI runners and turned the determinism guard into
a flake. The fallback now picks the most recent input date across the
weight and mood series (Unix epoch when neither has entries) so the
result is byte-identical across calls. Production routes already
supply their own `windowEndAt` and stay unaffected.

Adds a focused test asserting per-component `asOf` stability across
back-to-back invocations.
2026-05-14 18:57:38 +02:00
Marc-André Bombeck 7f133a1aa7 feat(jobs): daily medication-inventory expire-stale queue (03:00 cron) 2026-05-14 18:56:46 +02:00
Marc-André Bombeck a0b9c11e68 feat(medications): inventory card on medication detail page 2026-05-14 18:54:13 +02:00
Marc-André Bombeck 15a72bb329 feat(notifications): personal-records push opt-in toggle (default OFF)
Adds the PERSONAL_RECORD event type to the dispatcher vocabulary
and flips its default policy to opt-in. Without an explicit
preference row the dispatcher now suppresses PR pushes — so a
multi-year Apple Health backfill writes the records but does not
saturate the lock-screen on first sync. Other events keep the
opt-out default they always had.

The /notifications matrix renders the new event automatically
because it iterates EVENT_TYPES; the per-event default helper
keeps the toggle in the right initial state across all six
locales (EN, DE, FR, ES, IT, PL).
2026-05-14 18:51:05 +02:00
Marc-André Bombeck 7b7a896c44 feat(api): batch routes enqueue pr-detection (silent on >50 batch)
Measurement and workout batch ingest now fire a pr-detection job
after each successful insert. Batches above 50 entries propagate
silent=true so a multi-year Apple Health or HKWorkout backfill
writes the records without spamming hundreds of pushes. Both
enqueue calls are wrapped in audit-log entries and a swallow-and-
annotate failure path so a flaky queue cannot 500 an ingest call —
the 30-minute fallback cron picks the user up in any case.
2026-05-14 18:47:22 +02:00
Marc-André Bombeck d34c30eab6 feat(api): medication-inventory CRUD + intake-event hook for dose decrement 2026-05-14 18:46:49 +02:00
Marc-André Bombeck 4600c2385c feat(jobs): pr-detection pg-boss queue (concurrency=5, 30min fallback cron)
Adds the pr-detection queue and its handler to the reminder worker.
Ingest paths enqueue per-user jobs via enqueuePrDetection(); a
30-minute fallback cron iterates every user as a safety net for
ingest paths that skip the hook. Local concurrency is 5 so a
backfill spike drains without crowding the reminder check or the
daily insights workload running on the same node.

The payload carries a silent flag — ingest hooks set it on bulk
historical batches so a multi-year Apple Health backfill writes
the PR rows without firing hundreds of push notifications.
2026-05-14 18:44:59 +02:00
Marc-André Bombeck 05c20c9ba6 feat(personal-records): detection worker — MAX/MIN per metric, workout slots, warmup gate
Scans Measurement and Workout history per user and writes
PersonalRecord rows for every all-time best. Direction comes from
the W8d helper (MAX = higher wins, MIN = lower wins, null = never
a PR). A seven-sample warm-up gate per metric keeps a one-off
out-of-range sensor reading from locking in as the baseline.

Workout slots cover longest run / longest distance / fastest 5 km
for running and cycling. Ties write a row but mark it as
push-suppressed so the user matched (not beat) their best. The
unique index plus skipDuplicates makes the handler idempotent.
2026-05-14 18:42:59 +02:00
Marc-André Bombeck 34ec6d3bd3 feat(medications): pen-inventory state-machine + 30-day clock helpers 2026-05-14 18:42:52 +02:00
Marc-André Bombeck a7cc5de9b5 test(dashboard): regression for tile baseline alignment
Pins the load-bearing CSS class names introduced in W20a so a future
refactor that drops the heading-row height lock or the value-row
baseline alignment fails CI before reaching the dashboard.

- Heading-row contract: `h-5` + `whitespace-nowrap` + `truncate`
  on the label slot. Defensive — the live label is locale-abbreviated
  upstream, but a missing short-key fallback or an unexpectedly long
  fallback string should still not wrap to a second line.
- Value-row contract: `items-baseline` + `text-3xl` + `leading-none`
  across all five top-row tiles (Weight, BP, Pulse, Body Fat, Mood) so
  the digits land at the same y-coordinate inside the grid.
- Arrow-slot contract: always rendered, end-anchored via `ml-auto`,
  fixed 16×16 frame; renders a muted "—" placeholder when slope30
  is null so the slot width is deterministic across tiles.
- i18n integrity: every `dashboard.*Short` key resolves to a
  non-empty string in en / de / fr / es / it / pl. Complements
  the broader i18n-locale-integrity test with a precise diagnostic
  for the dashboard tile namespace.
2026-05-14 18:34:40 +02:00
Marc-André Bombeck 135b37512a feat(dashboard): inline trend arrow + baseline-aligned value row
Locks the TrendCard layout so the top-row Dashboard tiles align across
the strip:

- Heading row gets a fixed `h-5` so a one-line label and a (defensive)
  truncate behave identically. The label class adds `whitespace-nowrap`
  + `truncate` so even an unexpectedly long string never wraps to a
  second line — pairs with the locale-abbreviated keys wired in the
  previous commit.
- Value row drops `flex-wrap` and pins `items-baseline` so the
  headline number, unit, and trend arrow all sit on the same y-baseline.
  At the same font-size (`text-3xl leading-none`) every tile's digits
  now land at the identical height (Weight 80 kg used to sit above BP
  122 mmHg when one tile's heading wrapped).
- Trend arrow moves to the end of the value row (after the unit) per
  Marc's brief, with a deterministic 16×16 slot. When the metric has no
  slope yet, the slot renders a muted placeholder dash so the layout
  width stays consistent across tiles.

Marc verbatim: "rechts neben der Zahl, also nachdem die zum Beispiel
Kilo oder so kommt, der Pfeil kommt, wie gerade der Trend ist".
Verified at Pixel-5 (393 px, 2-col grid ≈ 180 px per tile) and
iPad-portrait (5-col ≈ 140 px) — heading row stays single-line, value
row baseline stays flat across the strip.
2026-05-14 18:33:35 +02:00
Marc-André Bombeck 75fce6cfc6 test(ai): safety-contracts parity + sentinel-preservation + brand-name guard
Adds a dedicated parity test for the safety-contract matrix that
complements the existing i18n locale-integrity test:

  - Every EN ground rule must have a non-empty translation in DE /
    FR / ES / IT / PL.
  - No non-EN locale may carry a body identical to its EN counterpart
    (verbatim-EN = placeholder bug).
  - Sentinel literals (KEYVALUES, END, snapshot token, example tag
    open/close) are IDENTICAL across all six locales.
  - GLP-1 brand list is IDENTICAL across all six locales — international
    brand registry, never translate.
  - JSON contract enums (severity / sourceWindow / sourceMetric / etc)
    are IDENTICAL across all six locales — parser keys.
  - Every parser_critical rule declares at least one must_contain
    token (defence-in-depth — a rule that loses its pin would silently
    bypass the refusal-probe).
  - Every locale exports a non-empty defer-to-clinician phrase list
    and a non-empty reply-language directive.

While the parity test was being wired, the dailybriefing schema
ground rule gained must_contain tokens (dailyBriefing / keyFindings /
sourceWindow / sourceMetric) since it is parser-critical.
2026-05-14 18:32:46 +02:00
Marc-André Bombeck c10b4ca3fd feat(dashboard): abbreviate tile headings + locale-specific BP/BF
Wires the top-row Dashboard tiles (Weight, BP-sys, BP-dia, Pulse, Body
Fat, Mood, Sleep, Steps, VO2 max, BP in Target) to locale-specific
abbreviated heading keys (`dashboard.*Short`) so the heading row
always fits a single line at narrow viewports.

The Romance + Slavic locales pick the clinical compact forms used in
medical practice — FR/IT/ES use `TA` / `PA` for blood pressure,
PL uses `RR` (Riva-Rocci); body-fat shortens to `KF` in DE and
`GC` / `TG` / `TT` in the other non-EN locales. EN and the
already-short DE labels (Puls, Stimmung, Schlaf, Waga) keep their
familiar wording. The corresponding i18n keys landed in messages/
across all six locales via the same touchpoint.

The full `dashboard.bloodPressureSys` / `bodyFat` / `pulse`
labels stay in place for the Settings → Dashboard layout picker and
the chart-row titles below the tile strip — only the cramped tile
heading row reaches for the short form.
2026-05-14 18:32:23 +02:00
Marc-André Bombeck dab7de3786 test(sources): cross-source priority for Withings Activity + Apple Health
New test module exercises pickCanonicalSourceRows with the realistic
W17b/c shape: same user reporting both WITHINGS rows (from the new
activity / sleep sync routines) and APPLE_HEALTH rows (from the v1.5
iOS passthrough) for the same day.

Coverage:
  - Default Marc-directive priority picks APPLE_HEALTH for every
    cumulative metric (steps, active energy, distance, flights)
  - Same default applies to sleep — even when both sources captured
    the same set of per-stage rows from Migration 0055
  - Withings stays the fallback when Apple Health has no rows that
    day (today's v1.4.25 reality; v1.5 may flip it per-user)
  - User-level overrides re-prioritise the ladder as documented
  - Activity rows (sleepStage NULL) and sleep rows (sleepStage non-null)
    pick independently — Migration 0055's per-stage uniqueness doesn't
    leak into the picker's source axis.
2026-05-14 18:31:32 +02:00
Marc-André Bombeck 9b30e116ee feat(i18n): MaintainershipBanner acknowledges AI-drafted safety-critical content
Updates the banner notice across all six locales to be explicit that
the AI-initial locales (FR / ES / IT / PL) are AI-drafted including
the Coach's safety-critical instructions, and that automated
validation guards them in CI. The CTA reframes from "help out" to
"report on GitHub" so users with a regression know the
contribution path is bug reporting, not editorial rewrites.

Pins the new wording in the banner test so a future copy regression
cannot quietly soften the disclosure. Banner gating (renders only on
non-maintained locales, SSR-safe) and the GitHub-issue CTA target
URL remain unchanged from W9e.
2026-05-14 18:30:36 +02:00
Marc-André Bombeck b55942fd18 feat(jobs): pg-boss queues for activity-sync + sleep-v2-sync + webhook enqueue hooks
Two new pg-boss queues join the existing reminder-worker orchestrator:

  withings-activity-sync — cron '0 * * * *' (hourly at :00)
  withings-sleep-sync    — cron '15 * * * *' (hourly at :15, offset)

Each queue's handler dispatches per-user: when the payload carries
`userId` (webhook-triggered) only that user syncs; when `userId` is
absent (cron-triggered) the worker iterates every WithingsConnection.
Per-user failures log as warnings so one parked-at-reauth connection
doesn't starve the rest on a cron tick.

The webhook handler now reads the `appli` form / JSON field and
dispatches:

  appli=16  → enqueue withings-activity-sync (W17b)
  appli=44  → enqueue withings-sleep-sync    (W17c)
  appli=1/2/4 or missing → legacy syncUserMeasurements (unchanged)

When pg-boss is unavailable (dev server without the worker process)
the handler falls back to inline sync so dev iteration isn't blocked
on a separate worker process.

The 15-minute cadence offset between activity (:00) and sleep (:15)
keeps the two crons out of lockstep with the existing measure cron at
:00, smoothing the load against the Withings 120-req/min/app cap.
2026-05-14 18:29:59 +02:00
Marc-André Bombeck 6e043720ea feat(coach): native locale-specific system prompts (replaces REPLY LANGUAGE footer)
Replaces the W9e EN-body-plus-footer plumbing for FR / ES / IT / PL
with native Coach and Insights system prompts assembled from the
safety-contract matrix. Each locale carries hand-curated persona,
day-level, evidence-block and few-shot sections in its medical
register (vouvoiement / usted / Lei / Pan-Pani) while the parser
contract tokens — KEYVALUES sentinels, GLP-1 brand names, severity /
sourceWindow / sourceMetric / timeRange enums — stay verbatim across
every locale.

The DE and EN bodies are unchanged: DE has two years of clause-by-
clause Marc review and remains the calibration reference; EN is the
schema source-of-truth. If the matrix loader fails for any reason
the dispatcher falls back to the previous EN-body-plus-footer path
so the surface fails open rather than empty.

Also exports OUT_OF_SCOPE_REFUSAL_FR / ES / IT / PL constants
mirroring the EN / DE shape, sourced from the matrix's
out_of_scope_refusal.summary field so they cannot drift from the
prompt body that teaches the model to emit them.
2026-05-14 18:27:51 +02:00
Marc-André Bombeck 9ae47dc80a feat(withings): sleep v2 sync — stage-level segments per night
New `syncUserSleep` routine pulls per-segment sleep data from
POST /v2/sleep?action=get and writes one Measurement row per stage
segment, tagged with the appropriate SleepStage enum:

  state 0 (awake)       → AWAKE
  state 1 (light sleep) → CORE  (HealthKit-aligned NREM 1+2)
  state 2 (deep sleep)  → DEEP
  state 3 (REM)         → REM
  state 4 (synthetic)   → ignored

The legacy ASLEEP and IN_BED enum slots stay reserved for HealthKit
ingest (pre-iOS-16 `asleepUnspecified` and iOS-16+ `inBed`).
ScanWatch nights with no REM segment simply omit the row rather than
synthesising an all-zeros entry — Withings drops the field, the writer
never sees it.

Idempotency rides on Migration 0055's five-column composite — a re-sync
of the same night UPDATEs every segment row in place rather than
inserting duplicates, even when Withings re-aggregates the night with
adjusted segment boundaries.

Sleep durations stored as canonical minutes (seconds ÷ 60, rounded) so
re-syncs don't flip values on floating-point noise.
2026-05-14 18:24:53 +02:00
Marc-André Bombeck 128ee10e02 feat(withings): activity sync — steps + distance + active-energy from getactivity
New `syncUserActivity` routine pulls daily aggregates from
POST /v2/measure?action=getactivity and writes one Measurement row per
(date, metric) for three fields:

  steps     → ACTIVITY_STEPS         (count)
  distance  → WALKING_RUNNING_DISTANCE (metres)
  calories  → ACTIVE_ENERGY_BURNED   (kcal)

`elevation`, soft/moderate/intense/active duration and
`totalcalories` are deliberately skipped — no clean MeasurementType
counterpart today; revisit when v1.5 ships the iOS workout passthrough.

Rows anchor measuredAt at the day's 23:59:59 UTC end-of-day so the
Berlin-timezone analytics aggregator buckets them into the correct
local day even across DST shifts. Idempotency rides on the W17b
composite (Migration 0055, NULLS NOT DISTINCT) — repeat syncs of the
same date update value in place rather than inserting duplicates.

Park behaviour mirrors syncUserMeasurements: connections at
error_reauth short-circuit without making the upstream call.
2026-05-14 18:22:58 +02:00
Marc-André Bombeck 0af9923b46 test(ai): refusal-probe matrix — 20 adversarial paraphrasings × 14 contracts × 6 locales
Marc cannot review the FR/ES/IT/PL safety-critical translations, so
automated coverage is the only safety net. The probe walks the full
14×6 ground-rule × locale grid with 20+ adversarial paraphrasings per
contract — direct asks, indirect asks, role-play attempts,
system-prompt-injection tries, and multilingual code-switches.

The test is structural: each (rule, locale, paraphrasing) triple
assembles a synthetic system-prompt + user-turn pair and asserts the
locale's rule body survives prompt assembly verbatim. Parser-critical
contracts (zero hallucination, evidence-block sentinels, GLP-1 dose
refusal, out-of-scope refusal, severity enums) additionally assert
every must_contain token is preserved.

GLP-1 dose refusal — the highest-patient-risk contract — runs a
heavier per-locale check: for every adversarial probe the assembled
context must list every GLP-1 brand AND include at least one
defer-to-clinician phrase token from the locale's allow-list. Total
~3000 structural assertions across 273 test cases.
2026-05-14 18:22:07 +02:00
Marc-André Bombeck e504faa7b6 feat(withings): expand webhook subscriptions to activity + sleep v2
WITHINGS_NOTIFY_APPLIS grows from [1, 2, 4] to [1, 2, 4, 16, 44]:
appli=16 (Activity) and appli=44 (Sleep v2) join the webhook-primary
set so the new sync routines fire in seconds rather than waiting for
the hourly cron fallback.

setupWebhook iterates the new list unchanged — Withings has no batch
subscribe endpoint, so every appli is its own POST. The two new
categories require the user.activity OAuth scope from W5d; legacy
connections that never reconnected return 503/293 on those subscribes,
which setupWebhook already logs as a per-appli warning while keeping
the remaining subscriptions live.
2026-05-14 18:20:14 +02:00
Marc-André Bombeck 84af00d3a9 feat(ai): safety-contract matrix as YAML per locale (single source of truth)
Adds six locale-keyed YAML files plus a Zod-validated loader so the
Coach and Insights system prompts can pull their 14 ground rules from
a single matrix instead of carrying parallel hand-edited bodies. The
matrix marks each rule with surface (coach / insights / both) and
parser_critical so downstream tests can distinguish stylistic from
safety-load-bearing clauses. GLP-1 brand names, evidence-block
sentinels and JSON contract enums are pinned to identical values
across every locale.

The German body is derived from the existing two-year hand-curated
COACH_PROMPT_DE / SYSTEM_PROMPT_DE wording. French / Spanish / Italian
/ Polish bodies are AI-drafted into the project's formal medical
register (vouvoiement / usted / Lei / Pan-Pani). The refusal-probe
test landing in a follow-up commit covers each ground rule x locale
with 20+ adversarial paraphrasings.
2026-05-14 18:18:57 +02:00