Hoist the ownership-check boilerplate that nine medication sub-routes
were each carrying inline into the shared route-guard helper, and align
the three W19 routes that hand-wrote the rate-limit header loop on the
new apiError option-bag form.
Routes touched:
- titration: shared guard + replaced parseDoseString with the shared
parseDoseMgOrNull + replaced the inline GLP1_DRUGS reverse-lookup
with findDrugIdByBrand.
- side-effects: shared guard + option-bag headers + dropped the
client-supplied 'category' field per code-M6 (server derives via
categoryForEntry).
- inventory: shared guard + option-bag headers.
- cadence: shared guard layered on top of the W21 Fix-O timezone wiring.
- intake (POST + GET): shared guard.
- compliance: shared guard.
- phase-config (GET + PUT + DELETE): shared guard.
- api-endpoint (GET + PUT): shared guard.
- glp1: shared guard replacing Fix-K's inline copy + option-bag headers.
apiError now accepts { headers: Record<string,string> } in its third
argument bag; the headers flow through NextResponse and are stripped
from the meta envelope so the existing { errorCode, ... } meta usage
keeps its shape.
The W19e pure cadence + compliance helpers used `Date.setHours`,
`getDay`, and `setHours(0, 0, 0, 0)` for every local-day boundary,
window-time application, and streak day-key. Those methods read the
host process's system time — every medication detail page on the
cadence route returned values relative to whatever zone the server
ran in (Europe/Berlin in prod, often UTC in CI), not the user's. A
Tokyo user reading the same data saw their 08:00 window land at
16:00 wall-clock and the day-of-week filter ran off the Berlin
weekday.
Thread an optional IANA `timeZone` argument through
`expandScheduleSlots`, `pairDoses`, `buildCadenceTimeline`,
`computeNextDose`, `missedDoses`, and `complianceChips`. Every local
operation now resolves through `Intl.DateTimeFormat`. When the
argument is omitted the helpers fall back to system-local — every
existing unit test (system-local by construction) keeps its
assertions green. The cadence route resolves the user's zone via
`resolveUserTimezone(user.id)` and forwards.
Regression tests pin the 08:00 wall-clock contract across Berlin /
Tokyo / Los Angeles and check that a "Mondays only" schedule lands
on each zone's local Monday.
Fix-O / code-H2.
Migration 0057 shipped `onboarding_step INTEGER DEFAULT 0` as nullable so
the additive DDL avoided a long table rewrite during the W14b deploy.
The wizard mount code and the POST route then carried defensive
null-handling branches (read-side `?? 0`, write-side `OR step IS NULL`)
to keep legacy pre-W14b rows working. That left a tri-state surface the
route had to widen on every step-0 advance.
Backfill every NULL row to 0 (the same value the read-path already
substitutes), then `ALTER COLUMN ... SET NOT NULL`. schema.prisma drops
the `?` so the Prisma type-surface mirrors the DB. The conditional
update WHERE in `/api/onboarding/step` collapses to the strict-equality
form on every step — the legacy null branch becomes unreachable code
and is removed.
Fix-O / senior-M1.
Two parallel tabs both reading `onboardingStep = 2` and both POSTing
`step: 3` would both succeed because the second write didn't re-check
the precondition — the wizard could double-advance and skip a step.
Move the write onto `updateMany` with the precondition embedded in
the WHERE clause: exactly one update lands, the loser sees count = 0
and returns 409 so the client can refresh and retry.
The WHERE clause covers `onboardingCompletedAt: null` so the same
guard catches a race with `POST /api/onboarding/complete`. The
`current === 0` branch widens the match to `step IS NULL OR step = 0`
so legacy pre-W14b accounts still advance from step 1.
Fix-M / code-M7.
The legacy ?secret=... route stays alive through v1.4.27 so existing
Withings subscriptions keep delivering while users re-subscribe. The
release-gate cannot tell when "legacy form usage has trended to zero"
because the route only emits a fire-and-forget Wide Event warning and
the warning text omits the destination URL anyone watching the access
log would need.
This pass adds:
- An in-memory `withings.webhook.legacy_form_total` counter scoped to
the process (the right granularity for "since cold-start" usage).
- A `getLegacyFormTotal()` accessor + a test-only reset helper so the
counter is observable without a backchannel.
- A `counters.withingsWebhookLegacyFormTotal` field on the existing
`/api/admin/status` payload — the natural surface for ops counters
alongside the user / measurement / worker blocks.
- The warning text now spells out `/api/withings/webhook/[token]` and
the "re-subscribe with the path-segment token form" instruction.
- Two new assertions on the webhook test (counter increments on the
query path, stays at zero on the header path, warning contains the
migration URL) and one new assertion on the admin-status test.
The GLP-1 convenience route shipped in W4d skipped the Zod / audit /
rate-limit triple the sibling inventory + side-effect POSTs ship with.
Hand-rolled `typeof === "number"` guards let NaN, Infinity, negative
doses, multi-megabyte notes, and 1970-era timestamps reach Prisma.
This pass adds:
- `glp1DoseChangePostSchema` + `glp1InventoryPostSchema` + the XOR
`glp1PostBodySchema` in `src/lib/validations/medication.ts`.
- A 500-char cap on `note`, 200-char cap on `reason`, 100 mg cap on
`doseValue`, integer cap on `delta`, and a +/-5-year window on
`effectiveFrom`.
- The 30/min/user rate-limit copied from the sibling routes, keyed on
`medication-glp1:post:<userId>`.
- `auditLog("medication.glp1.update", ...)` for every mutation, tagged
with the affected row id and the dose / delta values.
- A test fixture mirroring the inventory + side-effect tests with
thirteen assertions covering the new 422 paths, the rate-limit
header, and both audit branches.
A back-dated first-use stamp whose 30-day in-use window has already
lapsed must land at EXPIRED, not IN_USE. The hand-rolled state
transitions in the PATCH route composed the next-row view but never
re-evaluated through the canonical state machine, so the route could
emit an IN_USE row whose expiresAt was already in the past. Wire
`computeInventoryState` over the composed view so every PATCH lands
on the same decision tree the intake hook and the daily expire cron
already share.
Test: ACTIVE pen + markAsFirstUseAt 45 days ago ⇒ EXPIRED.
Fix-M / code-H1.
The literal "manual" | "withings" | "appleHealth" was spelled in
six places across health-score.ts and the analytics route, plus once
in the card. The lib helper and the route now read from a single
ContributingSource alias; the card keeps its local copy on purpose so
the client bundle stays free of analytics imports. Closes W10
simplifier S9 (lib + route scope).
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
The batch ingest route lives under /api/measurements, not the Coach.
Aligns the errorCode with the sibling DELETE route, which already
emits measurement.delete.too_large.
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.
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).
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.
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.
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.
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.
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)
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.
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.
Both PUT endpoints persist values into User.*Json columns that
shape downstream analytics output (source-priority drives the
aggregator's source pick on every read; doctor-report-prefs gates
PDF section inclusion including the mood-data toggle). Neither
wrote an audit-log entry, so a silent compromise or unintended
client write was invisible.
Mirror the timezone-route pattern: capture the previous shape,
write the new value, then `auditLog` with previous + next +
client IP. Adds the missing source-priority route test alongside
the new audit case in the doctor-report-prefs suite.
The previous race-reconciliation block compared post-insert rows in
the DB against the rows we attempted to insert, downgrading any
"inserted-but-not-stored" entries to "duplicate". Under standard
Postgres semantics that branch never fires: `skipDuplicates`
absorbs duplicate-key conflicts but the row is still present in the
table (written by the other batch that won the race). So every
attempted row was "stored" and the loop ran as a no-op, leaving
the per-entry envelope inconsistent with the aggregate counts when
two batches collide. The v1.5 iOS sync cursor depends on the
envelope sums matching the aggregate counts to checkpoint
correctly.
Pragmatic fix: trust `createMany.count` for the aggregate
`insertedCount` and downgrade exactly `toInsert.length -
insertedCount` per-entry "inserted" statuses to "duplicate". The
post-fix loop no longer touches `insertedCount` (it was already
correct from createMany) so the count cannot drift negative. Order
doesn't matter for the client — both statuses checkpoint past the
row and the DB ends up with the same single-copy state.
Concurrent-write integration test asserts the three invariants the
iOS sync cursor relies on: per-entry sums equal aggregate counts,
aggregate counts stay non-negative, and the DB row count matches
the combined `inserted` total.
`Measurement.deviceType` exists on the row, was added by the W8c
batch ingest path, but the single-entry `createMeasurementSchema`
omitted it — every single-row POST silently dropped the device tag
even when the client supplied one. Manual rows tagged with watch /
scale / ring metadata lost their attribution at the schema boundary
and showed up as unsourced.
Declare `deviceType` on the schema (optional, nullable, 1–32 chars),
thread it through both single-entry and array-body POST handlers,
and add three RTL cases (present, null, omitted) so the parsed
shape stays observable.
The Apple Health batch endpoint accepted 500-row payloads with no
per-user write cap. A leaked wildcard iOS token could sustain
unbounded inserts (500 rows × N batches/sec) and degrade the
database for every other user on the host.
Gate the write path with the shared `checkRateLimit` helper at
60 batches/min/user — generous for legitimate iOS sync (the
HealthKit observer coalesces well below one batch/minute on a
quiet device, and 60/min × 500 rows = 30 000 rows/min headroom)
and tight enough that a misbehaving client bottoms out within a
minute. Returns 429 with an unambiguous message so the iOS
client can back off and retry rather than assume the row landed.
Integration test exercises the over-limit path by pre-seeding the
`rate_limits` counter at the cap.
Update existing call-shape assertions to include the new `take: 100`
default and add three cases for the W10 pagination contract:
- explicit `?limit=25` passes through verbatim
- `?limit=999999` clamps to the project-wide ceiling (500)
- garbage `?limit=abc` falls back to the default without 400-ing
The GET route was an unbounded findMany. Today the detection worker
isn't shipped so every user sees an empty array, but a power user with
multi-year Apple Health history could accumulate 50+ PRs per metric ×
14 PR-trackable metrics once the worker lands.
Default 100, max 500 — matches `listMeasurementsSchema`'s ceiling so
every ingest-and-read endpoint on the route surface shares one upper
bound. Garbage values silently clamp to the default rather than 400-ing,
keeping the same defence-in-depth posture the `metricType` parse uses.
The v1.5 iOS app's query path is now bounded from day one.
The W7 per-user-tz sweep threaded `userTz` through every aggregator on
the analytics route except `berlinIsoWeekday()` — the module-level
`Intl.DateTimeFormat` stayed pinned to `Europe/Berlin`. A user in
`Pacific/Auckland` whose 23:30 weight reading lands at 12:30 the same
day in Berlin had their Monday-local reading bucketed under Sunday's
weekday column. Pearson/ANOVA on the wrong weekday.
Replace the pinned formatter with a per-tz memoised lookup, rename the
helper to `isoWeekdayInTz()`, and feed `userTz` through the call site.
Also rename the misleading `dateFromBerlinKey()` → `dateFromDayKey()`
since the keys it consumes are already anchored to the user's tz
upstream.
Integration test covers the non-Berlin path: 28 weight readings at a
UTC instant where the Berlin and Auckland weekdays disagree; the
correlator reaches the gate with every seeded row counted, confirming
the helper used the user's tz to bucket.
Extends HealthScoreComponentDetail with a source pill (manual /
withings / appleHealth / mixed / none) and an asOf ISO timestamp.
The analytics route folds the persisted MeasurementSource enum from
weight + BP rows into the new attribution slot; mood + compliance
stay manual until later ingest paths land. Backward compat is
preserved — callers that don't pass attribution still get a valid
result with manual defaults for present components.
Schema-only release of the personal-records feature. Lands now so the
v1.5 iOS-Swift app can build its query path against the GET
/api/personal-records contract from day one; the detection worker
that populates the rows is explicitly out of v1.4.25 scope and
will land in v1.4.26 or v1.5.
Schema (prisma/migrations/0054_personal_record):
- PersonalRecordDirection enum (MAX | MIN) stored on the row so
the future detection worker doesn't re-resolve direction at
query time.
- personal_records table: userId FK (cascade), metricType
(MeasurementType enum), nullable metricSlot for sport-specific
PRs, direction NOT NULL, value + unit + achievedAt, optional
sourceMeasurementId (SET NULL on delete so PRs survive the
underlying measurement), source provenance, externalId.
- Composite unique (userId, metricType, metricSlot, achievedAt)
for idempotent worker re-runs.
- Index (userId, metricType, value) for the "current PR" read
pattern (ORDER BY value DESC LIMIT 1 / ASC LIMIT 1).
Direction helper (src/lib/personal-records/pr-direction.ts):
- getPRDirection(MeasurementType) -> MAX | MIN | null. Exhaustive
switch covering all 28 canonical enum values:
MAX — steps, active energy, flights, distance, VO2 max, HRV,
total body water, bone mass, muscle mass, time in daylight.
MIN — resting HR, body fat, fat mass, visceral fat, vascular age,
pulse-wave velocity, audio exposure (env + headphone).
null — BP, glucose, body / skin temperature, pulse, SpO2, weight,
sleep duration, fat-free mass (homeostatic, clinical-edge,
or direction-ambiguous metrics; the v1.4.26 worker can
enrich weight from User.thresholdsJson when it ships).
- isPRTrackable() convenience predicate for the future worker.
API surface (src/app/api/personal-records/route.ts):
- GET returns { data: PersonalRecord[], error: null } (canonical
apiSuccess envelope).
- Optional ?metricType=X filter, validated against
measurementTypeEnum; unknown values drop silently so a
loose-typed caller never 400s.
- No write endpoint, no detection worker — explicitly v1.4.26
scope.
Tests (drift-guard + route):
- pr-direction: every MeasurementType returns one of MAX / MIN /
null; per-direction bucket membership pinned.
- route: 401 unauth, empty response, valid filter passthrough,
unknown-filter graceful drop, seeded-record round-trip.
Lock the GET /api/analytics wire-up to the new two-axis picker with a
fixture-driven test that drives a real Postgres testcontainer + the
production handler. Four scenarios on the same Berlin day:
1. Plain default user (single source, no override) — picker passes
through; v1.4.25-today behaviour stays a regression sentinel.
2. Per-metric override (sleep: [WITHINGS, APPLE_HEALTH]) — flips
the default APPLE_HEALTH-first ladder; Withings row wins.
3. Per-device default (no user override) — Apple Watch row beats
iPhone row under same source on default ladder (watch first).
4. User flips device ladder to [phone, watch] — iPhone row wins.
Wire-up: analytics' fetchMeasurementSeriesChunked now selects
deviceType + type on every chunk so the picker can read both axes.
ChunkedRow gains two fields; all existing aggregation paths continue
to compile and the integration suite stays green (6/6 passing across
analytics-sleep-stages.test.ts + source-priority-two-axis.test.ts).
Unit tests already cover the picker's internal edge cases (45 unit
across helper + algorithm); this file is the end-to-end guard.
Add an optional deviceType field to the POST /api/measurements/batch
payload schema. The iOS client maps each sample's HKDevice.model to a
canonical device class (watch | band | ring | phone | scale | other |
unknown); the server stores it on a new Measurement.device_type
column. The cross-source canonical picker (W8c) reads this column to
break Apple-Watch-vs-iPhone ties when the same source contributed
multiple devices for the same day.
Backward-compatible end-to-end:
- Schema field is optional; legacy iOS builds keep working unchanged.
- DB column is nullable; the migration is an additive ALTER TABLE
(Migration 0051, next free per v1.4.25 handoff section 4).
- The picker treats null as 'unknown' and only uses it as a
tiebreaker when a ranked device-type coexists in the bucket; with
a single source + null deviceType the row passes through as today.
OpenAPI: deviceType is documented on AppleHealthBatchEntry with
guidance on the HKDevice.model mapping. Batch endpoint description
gains a W8c note. iOS-Claude can lock the field name + enum from the
spec without re-reading source.
Tests: 45/45 source-priority tests pass; the batch route's existing
integration tests are untouched by the schema addition.
Pre-existing lint failure unrelated to this change:
src/components/i18n/maintainership-banner.tsx triggers a
react-hooks/set-state-in-effect warning; the file is currently
untracked and lives outside this Wave's scope.
7 conflict files resolved by taking the upstream/HEAD side (which carries
the W4d/W5/W6c/W3f feature additions). Stashed-side changes that were
genuine new features re-land in subsequent commits if any were lost.
This sweeps up the loose state the W4e+W5e+W5d combined agent left
mid-stash. Resolves develop's working tree for clean push.
v1.4.25 W5d. The Withings Activity / Sleep endpoints sit behind the
`user.activity` OAuth scope which v1.4.24-and-earlier connections
never requested. This commit ships the scope plumbing + reconnect
surface so existing users can opt in to step / active energy /
distance / floors-climbed ingest with a single click.
- Schema: `WithingsConnection.scope` nullable column. NULL =
legacy connection that's never re-authed since v1.4.25.
- Migration 0050 — additive column add.
- Client: `WITHINGS_OAUTH_SCOPE = "user.metrics,user.activity"`
+ `parseWithingsScope` / `hasActivityScope` helpers.
- Callback: persists `scope: WITHINGS_OAUTH_SCOPE` on every
successful exchange, so reconnecting upgrades the row.
- Status route: exposes `scope` + `hasActivityScope` so the UI
can render the reconnect banner conditionally.
- Settings → Integrations: paint a reconnect banner when the
user has an active connection AND `hasActivityScope === false`.
Click → /api/withings/connect → standard OAuth flow.
- i18n: EN + DE strings for the banner +
`measurements.types.{fatFreeMass,fatMass,muscleMass,
skinTemperature,pulseWaveVelocity,vascularAge,visceralFat}`.
- 5 new unit tests covering the scope helpers.
Note: the Activity sync routine itself
(`POST /v2/measure?action=getactivity`) lands in v1.4.26. Shipping the
scope upgrade now means users only reconnect once instead of twice.
Cross-source dedup protection is already in place via the W5e
foundation (`pickCanonicalSourceRows`): once Activity sync lands AND
a user also has Apple Health passthrough (v1.5), the per-day picker
prevents double-counting cumulative metrics from two sources.
Marc directive 2026-05-14 — the /insights/schlaf chart now shows one
bar per night with DEEP / REM / CORE / AWAKE / IN_BED / ASLEEP stacked,
matching Apple Health's sleep tab. The 30-day average-composition
read is preserved as a fallback when the API doesn't yet ship
perNight (legacy clients during rollout).
Plumbing:
- src/app/api/analytics/route.ts: computeSleepStageBreakdown() also
builds a per-Berlin-tz-day perNight array, sorted ascending. Empty
days drop out — the chart treats them as gaps.
- src/components/insights/sleep-stage-stacked-bar.tsx: rewrites
the visualisation. Default window = 7d; 14d + 30d toggles in the
card header (same min-h-11 touch targets as the dashboard range
tabs). Y-axis renders hours, tooltips show per-stage minutes +
percent + nightly total.
- Mobile-first: window-toggle pills reflow under the title on <sm
screens, sit inline on sm+. Bars use Recharts vertical layout
(per the directive — no horizontal reflow on mobile, just tighter
XAxis labels via preserveStartEnd).
i18n: no new keys — reuses insights.sleep.stages.* + the existing
compositionTitle / compositionSubtitle / compositionAriaLabel slots.
Tests: 4 new cases under sleep-stage-stacked-bar.test.tsx cover
- 7d default + three toggle buttons rendering
- empty-state (perNight=[] AND stages={} → 'sleep-stage-empty' slot)
- legacy fallback (no perNight → aggregate row still renders)
- sparse perNight (gaps don't crash the chart)
Existing 4 tests (title + aria + de locale + unknown stage key)
keep passing after perNight prop addition.
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.
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.
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
Renders a dedicated tile for users on a GLP-1 receptor agonist
(Mounjaro, Ozempic, Wegovy, Zepbound, …) — the new W4d schema enum
`Medication.treatmentClass = GLP1` is the discriminator. Tile self-
hides when no active GLP-1 med exists so accounts without one see no
visual change.
The tile surfaces:
- Drug name + current dose ("Mounjaro 7.5mg") — sourced from the
`MedicationDoseChange` titration history (latest row wins), so a
Mounjaro user titrating up 2.5 → 5 → 7.5 → 10 mg sees their
current step, not the starting dose.
- Last injection date + weekday ("Sun, 05/10")
- Next injection date + weekday + countdown
("Sun, 05/17 (in 3 days)") — predicted from the existing schedule
cadence
- Weight delta since starting the therapy ("−4.2 kg since start"
/ "−4,2 kg seit Beginn") — green chip on loss, orange on gain,
muted minus on flat (within ±0.1 kg)
- A compact weight chart with vertical injection-day markers via
the new `<HealthChart verticalMarkers={…} />` prop
New server endpoint `GET /api/dashboard/glp1` aggregates the data:
the heavy lifting (current dose / last + next injection / titration
history / inventory math) is delegated to the existing
`buildGlp1SnapshotBlock` so the Coach's GLP-1-aware reply path and
the dashboard tile read from the same shape. The route adds the UI-
facing extras (weight series since first dose-change, current vs
start, injection date keys for the markers) on top.
Mount: dashboard mother page (`src/app/page.tsx`) mounts the tile
below the AI insights preview and above the chart row — high enough
that a Mounjaro user sees their dose-response without scrolling past
BP / pulse, but below the AI preview which stays the scroll-stop
hero anchor.
Constraint respected (Marc directive 2026-05-14): the chart lives on
the Dashboard tile and the Insights /medikamente sub-page only; the
Medications page itself stays chart-free.
i18n keys: dashboard.glp1.{title, lastInjection, nextInjection,
weightDelta, inDays, empty.title, empty.description} in EN + DE.
Tests pin 7 scenarios: hidden when no active GLP-1, hidden when meds
array is empty, skeleton during fetch, full render for a Mounjaro 7.5
mg weekly profile, latest titration step wins (10 mg, not 2.5 mg),
fallback to name-only when no dose history exists, German copy under
the de locale.
Phase 4 of W4d GLP-1 full integration. New `Glp1MedicationCard`
variant renders inside the existing medications grid alongside generic
cards — same Card shell, same compliance bars, same primary intake
actions. The GLP-1-specific rows sit between the dose line and the
compliance bars so the user reads top-down: drug → injection state →
adherence.
Marc directive 2026-05-14: NO chart on the medication card. The card
stays text-rich:
- Headline: drug name + current dose ("Mounjaro · 7.5 mg")
- Last injection line with optional site ("Last: Mo May 12 · Abdomen,
lower left")
- Next injection line with day count ("Next: Mon, May 19 (in 5 days)")
- Rotation hint ("Last site: Abdomen left · Recommended:
Abdomen right") — only when the last site differs from the
recommended next site
- Pen-inventory line with low-stock badge when dosesPerUnit is set
- Collapsible "Dose history" details block listing every
MedicationDoseChange row
- "Log side effect" quick-button alongside Taken / Skipped
Routing: the medications page reads `treatmentClass === "GLP1"` and
dispatches to the variant. Existing meds (treatmentClass = "GENERIC",
the migration 0046 default) render the unchanged generic card.
Companion pieces:
- `injection-sites.ts` — the 8-site enum + `nextInjectionSite()`
recommender that maximises Euclidean distance to recent picks and
beats stale sites in the tie-break.
- `/api/medications/[id]/glp1` — GLP-1 details endpoint feeding the
card (dose changes + recent intakes + running inventory math).
POST creates a dose-change or inventory event.
`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.
Phase 3 of W4d GLP-1 full integration. The form gains a small
"Treatment class" selector (GENERIC | GLP-1 injection) sitting under
the existing clinical-category dropdown. Picking GLP-1 reveals two
helpers that stay hidden for every other medication:
- "Once weekly on …" preset — a single-tap weekday row that writes
daysOfWeek=[N] + intervalWeeks=1 into the first schedule and reveals
the advanced block so the user can still tune the window. The last
weekday picked persists in localStorage so a follow-up med starts on
the same day.
- "Doses per pen / vial" optional input that powers the inventory math
(Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose
pen = 1). Leaving it blank keeps inventory tracking off.
The wire schema gains optional `treatmentClass` and `dosesPerUnit`
fields. Both default sensibly (GENERIC + null) so the v1.4.24 client
keeps working untouched.
Marc directive 2026-05-14: GLP-1 must integrate "harmonisch", not
crowd the form. The new affordances live behind the treatment-class
toggle — for every other med the form is byte-identical to v1.4.24.
`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.
setupWebhook now subscribes to appli=1 (weight + composition),
appli=2 (temperature), and appli=4 (pressure family: BP, pulse, SpO2)
in one pass. The previous implementation only registered appli=1, so a
new BP reading from a BPM cuff sat for up to an hour waiting for the
hourly poll fallback instead of arriving via push within seconds.
A failure on one category is logged and we proceed with the rest —
losing one webhook is strictly better than rolling back all three.
The disconnect path mirrors the same iteration so cleanup unsubscribes
every category we ever subscribed to. Webhook handler already
dispatches `syncUserMeasurements` regardless of appli, so no change
needed on the receiver side.
Exports `WITHINGS_NOTIFY_APPLIS` as the single source of truth so the
unit test and the disconnect route stay in lockstep with setupWebhook.
The Settings → Dashboard sub-section's Save button silently 422'd once
the user had touched any per-chart overlay popover. Root cause was a
Zod v4 semantics change: `z.record(z.enum(KEYS), ...)` now requires
every enum key to be present in the input, where Zod v3 treated the
same shape as a partial record. Real-world Save payloads only carry
the chart keys the user actually toggled, so every Settings → Dashboard
Save attempt with one or two `chartOverlayPrefs` entries failed with
`expected: object, path: ["chartOverlayPrefs", "<missing-key>"]` and
the toast surfaced "Layout konnte nicht gespeichert werden".
Fix uses `z.partialRecord(z.enum(KEYS), ...)` which preserves the
original v1.4.18 intent — per-chart opt-in overlay prefs, missing keys
filled by the resolver. The inner object now also accepts the
`comparisonBaseline` field so a Settings save preserves the per-chart
comparison toggle the user set via the chart-card popover (otherwise
Zod's default strip behaviour wiped it on every save).
New integration suite pins:
- partial chartOverlayPrefs round-trips
- per-chart comparisonBaseline survives a Settings save
- empty chartOverlayPrefs still persists (fresh accounts)
- widget toggle changes land in the DB
After W4a scaffolded the routed sub-pages and lifted the sticky tab
strip into src/app/insights/layout.tsx, the mother page becomes the
overview-only surface it always wanted to be: Hero + DailyBriefing +
CorrelationRow + TrendsRow + InsightAdvisorCard + CoachDrawer.
What's gone from the page (now lives on the matching sub-page):
- Six per-section <HealthChart> / <MoodChart> mounts (bp/weight/
pulse/mood/meds/bmi) with their target-zone + value-band wiring.
- Per-metric status-card queries (blood-pressure-status,
weight-status, pulse-status, bmi-status, mood-status,
medication-compliance-status).
- All the per-section <InsightStatusCard> + scatter-correlation
cards (BP × continuity, weight × BP, mood × BP, mood × weight).
- The bulky helper functions (getBloodPressureSectionStatus,
getWeightSectionStatus, …, classifyRangeValue) and their imports
(getBpTargets, buildWeightBandsFromHeight, pulse-targets, etc.).
- The inline <InsightsSectionNav> mount — superseded by the strip
that <InsightsLayoutShell> now mounts.
What stays here:
- HeroStrip (greeting, weekly-report banner, Health Score panel).
- DailyBriefing (the AI morning paragraph).
- CorrelationRow (cross-metric hypotheses).
- TrendsRow (BP / weight / mood mini-charts above each annotation).
- InsightAdvisorCard (the rich recommendations grid).
- CoachDrawer — mother-page-only, per Marc's directive.
The two insights-polish guard tests are re-pointed at the new homes
of their assertions: the tab-strip refresh affordance now lives on
the layout shell (src/components/insights/insights-layout-shell.tsx),
and the comparisonBaseline plumbing migrated into the shared
useInsightsLayoutPrefs() hook.
trends-row.test.tsx wraps every render in a QueryClientProvider —
the v1.4.25 W7 timezone change added a useAuth() call inside
<TrendsRow> for chart-side userTimezone plumbing, which needs a
TanStack-Query client at mount time.