Commit Graph
937 Commits
Author SHA1 Message Date
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 570b14d83f feat(schema): MedicationInventoryItem + 30-day in-use clock (Migration 0056) 2026-05-14 18:41:18 +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
Marc-André Bombeck df5a82bc6f feat(schema): extend Measurement composite with sleepStage (Migration 0055)
The Withings Sleep v2 sync writes one Measurement row per stage segment
(AWAKE | CORE | DEEP | REM) for the same night, all sharing the legacy
four-column composite (user_id, type=SLEEP_DURATION, measured_at,
source=WITHINGS). Migration 0055 adds sleep_stage as the fifth axis and
recreates the unique index with NULLS NOT DISTINCT so non-sleep rows
(sleep_stage IS NULL) still dedup on the first four columns — same
effective semantics as before for every meastype other than SLEEP_DURATION.

Prisma's generated compound input requires non-null fields, so the
Withings sync's upsert is reshaped as findFirst + create/update for the
non-sleep path (sleepStage is always NULL there). The new five-column
upsert key is the canonical path for the Sleep v2 ingest landing in W17c.
2026-05-14 18:18:40 +02:00
Marc-André Bombeck 5a7d2526c9 chore(openapi): regenerate spec for /api/workouts/batch
Register the new typed workout batch ingest endpoint in the OpenAPI
route table so the W14a hard-flip CI gate stays green:

- Annotate createBatchWorkoutSchema with .meta() so it lands as a
  named component (CreateBatchWorkoutRequest).
- Define WorkoutBatchEntryResult + WorkoutBatchResponse — the per-
  entry status envelope the iOS sync cursor decodes against.
- Register POST /api/workouts/batch with the 200 / 400 / 413 / 401 /
  422 / 429 response surface so the Swift DTO generator sees the
  full contract.
- Tagged under Measurements rather than a new tag so the iOS
  client's sync surface stays in one section.

Regenerated docs/api/openapi.yaml via pnpm openapi:generate;
pnpm openapi:check confirms the spec is in sync with the
underlying schemas.
2026-05-14 18:17:45 +02:00
Marc-André Bombeck caf2371c77 test(api): batch-workout integration + race + size-cap coverage
Unit + integration coverage for POST /api/workouts/batch.

Unit (database-free, mocks the Prisma layer):
- 401 on unauthenticated calls
- 413 when Content-Length exceeds the 5 MB ceiling, with the
  documented workout.batch.payload_too_large error code
- 429 when the rate-limit gate blocks the call
- 400 when the workouts array exceeds the 100-entry cap
- 400 when the Zod schema rejects the payload
- 400 when a route exceeds the 20 000-point cap
- Fresh entries marked inserted
- Pre-existing entries flagged duplicate without re-inserting
- Race-reconciliation downgrades inserted statuses to duplicate
  when createMany.count is below the attempted-row count
- WorkoutRoute rows attached by FK after the workout createMany

Integration (real Postgres in a testcontainer):
- Happy-path single workout with nested route lands as a Workout
  row + WorkoutRoute row + 1:1 FK + computed durationSec
- 100-workout batch with mixed routes lands all 100 workouts and
  exactly the expected number of route rows
- Over-cap batch (101 workouts) returns 400 with
  workout.batch.too_large
- Over-cap route (20 001 points) returns 400 with
  workout.batch.invalid
- Re-posted batch surfaces as duplicates without inserting twice
- Idempotency-Key replays the cached envelope and sets the
  X-Idempotent-Replay header
- Pre-seeded rate-limit row triggers 429 and writes nothing
- Narrow-scope Bearer token admitted on a route that declares no
  required permission (the W10 fix-C correctness fix the iOS
  narrow-scope sync depends on)
2026-05-14 18:15:37 +02:00
Marc-André Bombeck 6ed925fee8 feat(api): correct batch-workout race reconciliation under contention
Concurrent-write integration coverage that validates the W10 fix-C
reconciliation pattern carries over correctly to the
/api/workouts/batch endpoint:

- Two batches with overlapping (userId, source, externalId) tuples
  posted in parallel resolve to exactly one DB row per tuple (the
  composite unique index enforces it; createMany.skipDuplicates
  absorbs duplicate-key conflicts).
- Each response body's per-entry envelope sums to its own
  aggregate counts — the invariant the iOS sync cursor relies on
  to checkpoint correctly.
- Aggregate inserted counts across both responses equal the row
  count actually present, cross-validating that createMany.count
  is the truth source rather than the older
  'stored vs not-stored' diff (which was a no-op under standard
  Postgres semantics because every attempted row IS in the DB
  after the call, written by whichever batch won the race).
- A two-batch shared-key fixture asserts the shared tuple lands
  exactly once across both responses.

Also adds workouts and workout_routes to the integration suite's
truncate list so beforeEach() actually clears them between tests
rather than relying on CASCADE from users.
2026-05-14 18:14:30 +02:00
Marc-André Bombeck 45bbfe4c9b test(medications): drift-guard for glp1-knowledge.ts vs EMA + psp4.13099 citations
Single-source-of-truth gate between the GLP-1 knowledge layer and the
research file (`.planning/research/glp1-feature-inspiration.md` §1 + §2.6).

Two layers:
  1) Hard pins on every cited numeric — half-life, Ka, CL, F,
     bioavailability, titration ladder, post-opening window — against
     the EMA EPAR / psp4.13099 values. A silent mutation in the TS
     module surfaces here before reaching the Coach or inventory
     surfaces.
  2) Soft check that the research file still carries the underlying
     citations (EMA EPAR URLs, Schneck/Urva DOI, the N7 retatrutide
     exclusion clause). A research-file revision that drops a citation
     is caught immediately.

30 tests, all green. Future Coach grounding (deferred to W14c) can
trust the values it reads.
2026-05-14 18:09:26 +02:00
Marc-André Bombeck 839fa6bd62 feat(api): POST /api/workouts/batch — typed workout ingest with nested route
First write endpoint for the W8d Workout + WorkoutRoute tables.
Mirrors the v1.4.25 W10-hardened /api/measurements/batch in every
cross-cutting concern so the iOS sync engine and the deferred
Withings activity sync (W17b) can re-use the same retry / cursor
plumbing:

- requireAuth() — cookie + Bearer; narrow-scope-token safe per
  the W10 fix-C contract (a route that declares no scope admits
  any authenticated token, wildcard or otherwise).
- withIdempotency — Idempotency-Key replays the cached envelope
  on retry (24h window).
- checkRateLimit at 60 batches/min/user — 100 workouts/batch
  ceiling × 60/min = 6 000 workouts/min headroom, orders of
  magnitude past any healthy sync.
- Content-Length pre-flight at 5 MB — anything larger returns
  413 with a structured error code so the client falls back to
  one-workout-per-call.
- Per-entry status (inserted | duplicate | skipped) so the iOS
  sync cursor can checkpoint accurately.

Idempotency contract:
- HTTP-level via Idempotency-Key.
- Per-entry via @@unique([userId, source, externalId]) — Prisma
  createMany skipDuplicates absorbs duplicate-key conflicts.

Race reconciliation mirrors the v1.4.25 W10 fix for measurements
batch: under contention, createMany.count is the source of truth
for how many rows THIS request wrote, and we downgrade enough
per-entry inserted statuses to duplicate so the envelope sums
stay consistent with the aggregate counts. The iOS sync cursor
advances past both statuses identically.

Two-step write: createMany the workouts, then look up the freshly
written rows by composite key and createMany the routes by FK.
The 1:1 WorkoutRoute.workoutId unique index means route inserts
are also idempotent on re-submit.
2026-05-14 18:08:54 +02:00
Marc-André Bombeck da73e0607e feat(i18n): GLP-1 drug knowledge labels across 6 locales
Add medications.glp1.drug.* namespace with localized INN + drug-class
labels for tirzepatide, semaglutide, liraglutide, dulaglutide, and
exenatide, plus medications.glp1.source.{ema,psp4} citation strings.

Each locale uses the regional spelling convention (DE Tirzepatid,
ES Tirzepatida, PL Tyrzepatyd, …) — scientific names are written
naturally for each market rather than transliterated. Drug-class
labels follow the local pharmacology vocabulary.

All 6 locale files validate; i18n-locale-integrity green.
2026-05-14 18:08:25 +02:00
Marc-André Bombeck cee5bf54b6 feat(medications): GLP-1 EMA drug knowledge layer — 5 drugs, EPAR-sourced
Static read-only catalog covering the five EMA-approved GLP-1 / GIP-GLP-1
receptor agonists (tirzepatide, semaglutide, liraglutide, dulaglutide,
exenatide). Each record carries pharmacology, titration, storage, pen
form-factor, EMA §4.8 adverse-reaction frequencies, contraindications,
warnings, and a direct EMA EPAR citation.

Tirzepatide PK parameters are pinned to Schneck & Urva 2024
(DOI 10.1002/psp4.13099) — half-life 5.0 d, Ka 0.0373 h⁻¹, CL 0.0329 L/h
per 70 kg, F 0.80, two-compartment model.

One-to-many brand mapping (Mounjaro+Zepbound → tirzepatide,
Ozempic+Wegovy+Rybelsus → semaglutide, etc.) plus per-brand route
overrides for the only mixed-route entry (Rybelsus oral, others SC).

Retatrutide deliberately omitted — no EMA approval as of 2026-05.

32 unit tests cover shape integrity, parameter pinning, brand-route
consistency, ascending titration ladders, storage window sanity, and
N7 retatrutide-exclusion.
2026-05-14 18:07:15 +02:00
Marc-André Bombeck 62e4b1d281 feat(validations): nested route + batch shape on workout schema
Lock the W16b ingest contract on the W8d-authored workout schema:

- Cap route geometries at 20 000 points (covers any normal run /
  ride / hike; rejects pathological payloads that would otherwise
  blow the 5 MB request-body ceiling on a single workout).
- Enforce the parallel-array invariant via .superRefine() — when
  route.sampleTimestamps is present its length must equal
  route.geometry.coordinates.length. A desynced pair silently
  poisons downstream analytics (per-sample HR / speed against the
  wrong coordinate), so we hard-fail at parse time.
- Add createBatchWorkoutSchema mirroring the createBatchMeasurement
  shape: { workouts: createWorkoutSchema.array().min(1).max(100) }.
  Cap is an order of magnitude tighter than the 500-entry
  measurements batch because each workout may carry a 20 000-point
  route geometry; 100 covers a cold-start HealthKit backfill on a
  healthy multi-year history.

Schema tests grow from 16 → 20 cases. The /api/workouts/batch
endpoint and Swift DTO generator both consume this contract.
2026-05-14 18:06:20 +02:00
Marc-André Bombeck bff13e76ed feat(sources): pickCanonicalWorkout helper for cross-source dedup
W10 senior-dev review flagged that workouts will collide once v1.5
lands the iOS HealthKit passthrough: a single ScanWatch run becomes
two rows — one from HKWorkout, one from the Withings Activity API.
The Measurement table solved the symmetric problem via
pickCanonicalSourceRows; workouts need a parallel helper before the
"recent workouts" tile can show one row per workout instead of one
per source.

This commit ships the scaffolding only. The helper:

  - Clusters workouts whose start timestamps land within
    proximityMinutes of each other (default 5 minutes — covers the
    HealthKit passthrough lag plus a clock-skew buffer) AND share a
    sport type, so a Walking + Running burst stays distinct.
  - Walks a per-user source ladder (default
    APPLE_HEALTH > WITHINGS > MANUAL > IMPORT — see file-level rationale)
    and keeps the first source present in each cluster.
  - Is deterministic on input order plus a lexicographic id
    tiebreaker so the same fixture always produces the same canonical
    list.
  - Falls through cleanly when none of the ladder entries are
    present in a cluster (legacy / future source values).

NOT yet wired into any query path. The wiring lands alongside the
workout ingest endpoint (W16b) or with the v1.5 iOS rollout,
whichever ships first. The Workout model already carries the
TODO(v1.5 iOS) breadcrumb in prisma/schema.prisma; this commit
delivers the helper that breadcrumb points at.

Unit tests cover the three brief-mandated fixtures (two-source same
workout, two-source different workouts close in time, single source
pass-through) plus the 5-minute proximity boundary, custom-ladder
override, empty input, and determinism guards.
2026-05-14 17:52:55 +02:00
Marc-André Bombeck 0e51007974 feat(insights): VO2 max chart-row card on Insights cardio surface
W8d shipped the opt-in VO2 max trend tile on the dashboard; this is
the deeper Insights surface. Renders one HealthChart for the trend
plus a compact stat strip (latest / min / max / 30-day average with
the comparison delta vs. the prior period) on /insights/puls because
VO2 max sits in Apple Health's Heart category — cardio fitness, not
its own root concept.

Implementation notes:

  - vo2Max joins CHART_OVERLAY_KEYS so the per-chart cog persists its
    overlay-prefs independently from the pulse chart sitting directly
    above it on the same page.
  - The card stays mounted at zero samples so a brand-new account sees
    the "no data yet" hint rather than a missing surface.
  - Compare-delta caption only renders when the user has a comparison
    baseline active AND the prior-period bucket has data; otherwise the
    slot is suppressed.
  - HealthChart is dynamic-imported with the same skeleton placeholder
    the trends row uses so Recharts stays out of the Insights bundle.

Translations land in all six locales; FR / ES / IT / PL carry English
placeholders matching the v1.4.25 convention. RTL test exercises the
stat-strip slots, the empty-state branch, the compare-delta gates, and
the German label rendering.
2026-05-14 17:50:30 +02:00
Marc-André Bombeck a1ffa497f1 fix(security): Withings webhook secret as URL path segment, not query string
Move the Withings webhook shared secret from `?secret=…` to a path
segment (`/api/withings/webhook/<secret>`) so it never lands in the
reverse-proxy `query_string` access-log column or GlitchTip URL/
breadcrumb capture surface (W10 finding M-2).

Withings has no facility for custom HTTP headers on outgoing
notifications and never signs the body — every Notify.subscribe call
accepts only action/callbackurl/appli/client_id/nonce/signature, so
the callback URL is the only authenticity surface a subscriber
controls. Path-segment binding is the largest practical shift away
from loggable query strings that Withings supports end-to-end.

The legacy `/api/withings/webhook` route stays alive for one release
cycle (removal target: v1.4.27) so existing subscriptions keep
delivering. The query-string path now emits a deprecation breadcrumb
on every hit. Active users re-subscribe organically when they reconnect
for the v1.4.25 `user.activity` OAuth-scope upgrade.

Tests: 8 new for the path-segment route (valid / invalid / missing /
unset-env / prefix-match / rate-limited / HEAD / GET), 3 new for
`getWithingsWebhookCallbackUrl()`. Existing legacy-route tests
continue to pass over the refactored shared helper.
2026-05-14 17:46:25 +02:00
Marc-André Bombeck b4df3ae8cd feat(apple-health): extend HK identifier mapping with iOS-17 + iOS-18 long-tail
Adds 32 HKQuantity / HKCategory identifiers to HK_QUANTITY_TYPE_DEFERRED
so the batch ingest endpoint logs a "deferred, not unknown" signal when
the iOS app passes a sample type HealthLog hasn't surfaced yet. None
currently have a HealthLog MeasurementType counterpart; mapping them
through requires the enum extension to land first (separate wave).

Buckets covered, with iOS version they shipped in:
  - Cardiovascular clinical (iOS 16): AFib burden, peripheral perfusion
  - Mobility (iOS 14/15): walking steadiness, falls, steadiness event
  - Pulmonary clinical (iOS 17): FEV1, FVC, peak expiratory flow,
    inhaler usage
  - Other quantity types: insulin delivery, UV exposure, electrodermal
    activity, blood-alcohol content, legacy Nike fuel
  - Heart-rhythm event flags (iOS 9+/17 refresh): low/high heart-rate
    events, irregular rhythm, low cardio fitness event
  - Audio-exposure event flags + environmental sound reduction
  - Behavioural: handwashing, toothbrushing
  - Reproductive / fertility / pregnancy: contraceptive, lactation,
    pregnancy, pregnancy/progesterone test, sexual activity, sleep
    changes, persistent intermenstrual bleeding, prolonged / irregular
    / infrequent menstrual cycles

Tests gain explicit coverage that every long-tail identifier sits in
the deferred set and that no identifier is duplicated.
2026-05-14 17:43:37 +02:00
Marc-André Bombeck 41147bd76a ci(openapi): hard-fail on spec drift (was warn-only)
The Security & Quality workflow ran the OpenAPI drift check with
`continue-on-error: true` since v1.4.23 while the Zod registry caught
up with the hand-maintained legacy spec. Coverage is now complete and
the v1.5 iOS Swift codegen needs the contract pinned, so the gate
flips to hard-fail: any route signature change without a regenerated
`docs/api/openapi.yaml` blocks the merge.

The CI error message points at `pnpm openapi:generate` and the
registry under src/lib/openapi/ so the fix is self-evident.
2026-05-14 17:40:17 +02:00
Marc-André Bombeck 74bf608892 chore(openapi): regenerate spec after v1.4.25 schema additions
The Zod registry picked up additional route schemas during the v1.4.25
wave (Apple Health DTOs, GLP-1 injection sites, dashboard token slots).
Regenerate the committed spec so the upcoming hard-fail drift gate has
a clean baseline.
2026-05-14 17:39:09 +02:00
Marc-André Bombeck cb07d5ccc6 chore(release): v1.4.25
Largest single-release feature delta in the v1.4.x line:
- Insights overhaul with 7 sub-pages
- GLP-1 medication tracking full integration
- Cross-source priority resolution (two-axis)
- Per-user timezone end-to-end
- Withings expanded coverage + webhook latency fix
- Health-Score provenance UI
- Four additional locales (fr/es/it/pl) with maintainership banner
- Multi-architecture Docker image for Apple Silicon

See CHANGELOG.md for full entry.
2026-05-14 17:24:33 +02:00
Marc-André Bombeck ec484e0af2 refactor(settings/sources): replace __default__ sentinel with null bucket
moveDeviceType now takes bucket: string | null where null means the
global ladder. The function maps null to the canonical "default" key
once at the top of the body and reads + writes that key uniformly,
removing the two equal-shape if branches and the magic-string check
that drove them. Callers pass null instead of "__default__".
2026-05-14 17:11:05 +02:00
Marc-André Bombeck 8c929fc554 refactor(apple-health): collapse sleep branch in mapAppleHealthEntry
Builds the base output object once with the four shared fields and
appends sleepStage only when the mapping carries a sleep-stage table.
Drops one duplicated object literal and one return path; the function
keeps the same null-on-bad-input semantics for unmapped, invalid date,
missing stage, and unknown stage cases.
2026-05-14 17:09:46 +02:00
Marc-André Bombeck 7851341d98 refactor(apple-health): drop redundant HK_QUANTITY_TYPE_TO_MEASUREMENT view
The derived map mirrored APPLE_HEALTH_TYPE_MAP one key for one key
with the value narrowed to the measurementType field. Every caller can
read that via APPLE_HEALTH_TYPE_MAP[id]?.measurementType — no external
consumer is depending on the compact view today. Drops the redundant
constant plus the mirror-check test block that only existed to keep
the two tables in sync.
2026-05-14 17:09:17 +02:00
Marc-André Bombeck 4ec67056e5 refactor(health-score): hoist COMPONENT_ORDER, memoise asOf formatter
COMPONENT_ORDER moves to module scope so the provenance row order is
allocated once per process. The asOf date formatter is memoised on the
locale via useMemo so the per-row map no longer constructs a fresh
Intl.DateTimeFormat instance for each row. The defensive try/catch
around new Date() drops at the same time — the constructor doesn't
throw on bad ISO input, the Number.isNaN(getTime()) guard already
catches every failure mode.
2026-05-14 17:08:26 +02:00
Marc-André Bombeck d4796ee0b1 refactor(insights): derive SubPageSlug + SUB_PAGE_SLUGS from SUB_PAGE_METRIC
Three independent listings of the same seven sub-page slugs collapse
to one. The keys of SUB_PAGE_METRIC drive both the type union and the
array — adding a sub-page becomes a single-line change in one record
instead of touching three declarations that must stay in lockstep.
2026-05-14 17:07:25 +02:00
Marc-André Bombeck 4745d7fbf5 docs(planning): note Fix-E confirms C2 sleep-suffix translations were already complete (W10 reconcile-A follow-up)
Fix-C's release-readiness flag about i18n-locale-integrity drift on
`insights.sleep.headlineCaptionSuffix` reflected a transient mid-session
snapshot — commit e14466e already shipped native translations for all
six locales and the integrity test is green (26/26). Append a follow-up
note to the W10 reconcile-A design report so the audit trail is complete.
2026-05-14 17:06:43 +02:00
Marc-André Bombeck 38023eedf5 refactor(source-priority): reuse getDeviceTypeLadder in canonical-row picker
Drops the inline override-then-default-then-constant lookup ladder
inside pickCanonicalSourceRows and routes the resolution back through
getDeviceTypeLadder, the single helper that already encodes that
order. The picker keeps its per-bucket cache; only the resolution
itself moves out. Future changes to the fallback rule land in one
place instead of two.
2026-05-14 17:06:42 +02:00