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.
HealthKit's API does not surface a stream of deleted UUIDs, so the iOS
HealthLog client performs a periodic 30-day reconciliation: it diffs
HealthKit's current UUID set against the server's externalIds and
posts the surplus here. This endpoint deletes the user's matching
rows and never touches another user's data (Prisma's deleteMany with
the userId predicate is the cross-user 404 guard).
Contract:
- Body: { externalIds: string[] } with min 0, max 500 entries.
- Auth: requireAuth() — session cookie or bearer.
- Empty array: returns { deletedCount: 0 } with 200 (no-op, not a
failure — matches the ingest path's symmetric tolerance).
- Over-cap: 422 with errorCode "measurement.delete.too_large" so
the iOS client can surface a specific diagnostic.
Integration coverage: happy path, cross-user 404 guard, empty array,
and batch-cap rejection. The cap (500) mirrors POST /measurements/batch
so the two surfaces share one operational ceiling.
OpenAPI: registered as DELETE on /api/measurements/by-external-ids
with the Measurements tag; req/res schemas reuse the same z.array cap
the runtime validator enforces. openapi.yaml regenerated.
The four routes that bucket by Berlin calendar day (dashboard summary,
medication intake, analytics correlations and sleep, insights targets)
now read userTz from the authenticated User and pass it through
userDayKey(). Streaks and consistency strips for a Pacific/Auckland user
land on their Auckland-day, not the legacy Berlin-day.
The aggregation logic is unchanged — only the bucket key function
swaps from berlinDayKey(date) to userDayKey(date, userTz). Existing
Berlin users see no behaviour change (Europe/Berlin is the schema
default).
Integration test extended with a two-case assertion: the userDayKey()
boundary case (13:00 UTC May 14 → 2026-05-15 Auckland vs 2026-05-14
Berlin) and the dashboard summary route returning a non-zero streak for
an Auckland account with three consecutive Auckland-day measurements.
Removes the duplicate `targets.mood` + `targets.moodStability` flat
strings that collided with the new `targets.mood.stability.{stable,
variable,highlyVariable}` object added in Phase 3. The flat versions
were unreferenced — the canonical mood label lookup goes through
`targets.label.MOOD_SCORE` / `targets.label.MOOD_STABILITY` — so
removing them is a no-op for the UI and unblocks the
i18n-locale-integrity duplicate-key test.
Also rolls in the prettier-normalised formatting on the targets
route + target-card files (no semantic change). All i18n keys
added across phases 3-8 are documented inline:
• targets.summary.{weekTitle,weekTitleAll,streakHighlight}
• targets.card.{lastMet,streak}
• targets.consistency.{ariaLabel,daysInRange,daysLogged,
dotInRange,dotNearRange,dotOutOfRange,
dotNotLogged,insufficientData,relativeDay.*}
• targets.relativeDay.{today,yesterday,daysAgo}
• targets.coach.cta
• targets.mood.stability.{stable,variable,highlyVariable}
Per-target Coach prefills (the W3e plan called for
targets.coach.prefill.{bp,weight,…}) are NOT added as i18n strings
— they're generated by `buildTargetPrompt()` in
src/lib/ai/coach/target-prompts.ts which interpolates live state
values. Keeping the templating in JS avoids 12 long-form bilingual
strings with i18n interpolation gymnastics and lets the prompt
include conditional fragments (streak, cadence) without exploding
the key count.
Verification:
- pnpm typecheck: clean
- pnpm test: 2325 / 2325 passing
- pnpm lint: clean
- i18n-locale-integrity: 9 / 9 passing (duplicate keys removed)
The Zielwerte redesign needs three new per-target signals so the page
can answer "how consistent am I" without a chart:
- daysInRange7d / daysLogged7d — Berlin-tz day buckets over the last
week. The client renders 7 dots, one per day, filled when the day's
mean reading landed in the green band, hollow when logged but
out-of-band, dimmed when the day had no reading.
- consistency7d — the day-by-day band classification array the strip
reads directly (avoids re-deriving from sums client-side).
- daysInRange30d / daysLogged30d — same rollup over 30 days for the
page summary line and the optional details popover.
- lastMetGoalAt — Berlin-tz date the user most recently hit the
target band, or null when no in-range day in the last 30. Surfaces
the maintainer-tester case the W5 reconcile flagged (today lands
in-range by luck but the user has not actually hit goal in weeks).
- streakDays — consecutive in-range days ending today. Capped at 365.
- insufficientData — true when the target has < 3 readings in 30d
OR < 1 day logged in 7d. The client hides the strip + percentage
when set so we never surface a misleading "0 of 7" caption.
Schema also adds `pageSummary: { targetsMetThisWeek, totalTargets,
streakHighlight }` for the new account-level header. "Met this week"
threshold is daysInRange7d >= 4 — half of a logged week, lenient
enough to feel earnable. Streak highlight surfaces the longest
current streak ≥ 3 days.
Implementation collapses the v1.4.22 two-window (60-day) fetch back to
a single 30-day fetch — the now-removed delta-vs-last-month was the
only reason for the second window. The route is one Prisma query
shorter than v1.4.22. The shared `rollupConsistency` /
`rollupFromDayMap` helpers also pay off Senior-Dev M2 (the 7-pass
per-type computation collapses to one bucket-by-day pass per metric).
Per-day band rules:
- Continuous metrics (weight, pulse, sleep, body fat, glucose, steps,
mood) — classify the day's mean against the same green band the
RangeBar paints. Orange band reuses the pulse / glucose
configurable orange when available; otherwise symmetric ±30%.
- BP / BP-in-target — sys+dia paired within 5 min, day is "in" when
every pair clears the target, "near" when at least one does.
- MEDICATION_COMPLIANCE — day-level intake rollup. ≥ 99% taken = in,
≥ 50% = near, otherwise out. Days with zero scheduled doses
return null so they don't break the streak.
The v1.4.22 C1 inline sparkline + "Δ vs. last month" caption never
earned their place on /targets. The W5 reconcile filed four findings
against them (Design M2 — sparkline ignores min-points threshold so 3
points read as "lots of activity"; M7 — SVG aria-hidden hides the only
motion cue from screen readers; L3 — preserveAspectRatio="none"
distorts the y axis; Senior-Dev M2 — 7 sequential Array.filter passes
on the route). The replacement primitive (a 7-day consistency strip)
lands in the next phase; this commit just removes the dead weight.
Drops:
- Sparkline component, TargetItem.points30d, TargetItem.deltaVsLastMonth
- The 60-day fetch and the per-type sparklinePoints / deltaVsLastMonth
helpers in the route handler
- targets.deltaVsLastMonth, targets.deltaVsLastMonthFlat,
targets.deltaUnavailable i18n keys (EN + DE)
- targets-sparkline.test.tsx (the consistency-strip suite replaces it
in phase 3)
v1.4.25 W7 phase 6 — proposal §4 Option C as default-value layer.
The registration form now reads
Intl.DateTimeFormat().resolvedOptions().timeZone and sends it with
the signup payload. The server:
1. Validates the value against Intl.supportedValuesOf via
isValidTimezone().
2. On any invalid value (or absence), resolves the admin-
configured server default (AppSettings.defaultUserTimezone) and
uses that.
3. On no admin default, the resolver falls back to the
'Europe/Berlin' string, which is also the User.timezone column
default — so the worst case still produces the legacy
pre-v1.4.25 behaviour.
The Zod registerSchema gains an optional 'timezone' string field
capped at 64 chars (matches the longest IANA canonical zone
identifier plus headroom). Audit-log entry now carries the resolved
zone so an operator can spot a Tokyo user mis-attributed to Berlin
if browser detection fails.
No new tests — the existing register-route integration coverage is
thin, and the v1.4.25 W7 phase 7 integration test in
Pacific/Auckland covers the full signup → measurement → export
round-trip end-to-end.
v1.4.25 W7 phase 5 — proposal §5 admin half.
GET /api/admin/settings now returns defaultUserTimezone (null = use
Europe/Berlin fallback). PUT accepts the field:
- empty string clears the column (resolver falls back)
- non-empty must pass isValidTimezone(); otherwise 422
- successful writes invalidate the server-default resolver cache
so the registration handler sees the new value on the next
signup without waiting 60s for the TTL
The admin form gets a third row in the General Settings section
between 'default language' and the toggles, mirroring the user
picker UX (native select over Intl.supportedValuesOf, fallback
'Europe/Berlin (default)' option clears the override). 3 new i18n
keys per locale (defaultUserTimezone +
defaultUserTimezoneDescription + defaultUserTimezoneFallback);
descriptions follow proposal §5: 'Used for new accounts that don't
carry a browser-detected zone. Does not affect existing users.'
Tests: 3 new cases in src/app/api/admin/settings/__tests__/route.test.ts
covering valid IANA write, empty-string clear, and invalid-zone
rejection.
v1.4.25 W7 phase 4 — proposal §5.
Adds a per-user timezone picker to /settings#account between
date-of-birth and language. The control is a native <select> over
Intl.supportedValuesOf('timeZone') with a 'use browser timezone'
button that pre-fills detectBrowserTimezone(). Older engines without
Intl.supportedValuesOf fall back to a free-text input — every Chrome
99+/Safari 15.4+/Firefox 99+ ships the API so the fallback is
defensive only.
Form save uses Promise.all over two PUTs:
- /api/auth/profile (email, height, dob, gender)
- /api/auth/me/timezone (single field, dedicated route)
The split keeps the timezone validation + resolver cache
invalidation on a clean path; profile errors don't poison the
timezone write and vice versa.
The new route PUT /api/auth/me/timezone:
- requireAuth gates the cookie + Bearer paths
- 422 on any string the runtime's Intl.DateTimeFormat rejects
- 200 on success, returns the persisted value
- audits 'user.timezone.update' with previous + next
- invalidates the resolver cache so the next read sees the new
value within one tick instead of waiting 60s for the TTL
i18n: 4 new keys (timezone, timezoneHint, timezoneDetect,
timezoneInvalid) in messages/de.json + messages/en.json. Hint copy
follows proposal §5: 'Used for chart axis labels, reminder times,
export timestamps, and AI Coach context. Stored data is unchanged.'
Tests: 6-case suite on the new route covers auth, valid + invalid
zones, empty / missing field, and whitespace trimming.
v1.4.25 W7 phase 3.3 — closes proposal §3 symptom 8.
renderDoctorReportPdfBytes now accepts an optional userTz in its
DoctorReportRenderOptions. The PDF route resolves the patient's
stored timezone via the v1.4.25 W7 resolver and threads it through
makeFormatters so date/time rows render in the user's zone.
A US patient generating a report at home in Boston now sees Eastern
time rows, not Berlin. Existing Berlin-region users see no
behaviour change — the User.timezone default is Europe/Berlin and
the formatter falls back when userTz is empty.
Client-side caller (settings dialog) is untouched in this commit — a
follow-up wave wires the auth-context value through the dialog
component.
v1.4.25 W7 phase 3.1 — closes the export half of issue #167.
CSV/JSON exports previously emitted timestamps via .toISOString(),
producing 2026-05-15T09:00:00.000Z. Excel and LibreOffice drop the
trailing Z silently and render the bare datetime in the viewer's
locale, so a Warsaw user reads UTC as local time — 11:00 entries
display as 09:00 in their spreadsheet.
Export helpers now accept an optional userTz argument and emit
ISO-8601 with the user's UTC offset
(2026-05-15T11:00:00+02:00). The offset survives the round-trip and
the on-screen value is unambiguous regardless of the spreadsheet's
defaults. Routes resolve the user's stored timezone via the v1.4.25
W7 phase 2 resolver and thread it through formatMeasurements /
formatIntakeEvents / formatMoodEntries.
Backward-compatible: userTz is optional. When omitted (admin tools,
canonical-backup-on-disk shape) the helpers fall back to
.toISOString(), keeping the legacy contract for the JSON full-backup
route that the upload-restore validator depends on.
Surfaces touched:
- src/lib/export.ts (3 formatters + helper)
- src/app/api/export/measurements/route.ts
- src/app/api/export/medications/route.ts (intake history block)
- src/app/api/export/mood/route.ts
- src/app/api/export/route.ts (legacy aggregate route)
Tests: 5 new cases in src/lib/__tests__/export.test.ts covering
Berlin / Auckland / New_York offsets, the no-userTz fallback, and
the mood-entry case where the stored YYYY-MM-DD date column stays
unchanged (see proposal §6.3 for the read-side rationale).
The /insights page rendered both <InsightAdvisorCard> (the polished
severity-ordered recommendations grid + per-rec rationale + confidence
meter + medical-citation footnotes shipped in v1.4.16 phase D
reconcile) AND <InsightStatusCard title={t("insights.generalStatusTitle")}>
(the v1.4.15 text-only summary). The page comment at the advisor
wiring acknowledged the status card "was intended to be replaced"
during D-reconcile but the status mount was never removed, so users
saw two cards saying the same thing — one labelled
"KI-Gesundheitsanalyse" and one labelled "Allgemeiner
Gesundheitszustand".
Removed:
- The <section id="section-general"> block + its <InsightStatusCard>.
- The `generalStatus` useQuery + the unused `GeneralStatusData`
interface.
- The `generalStatus?.updatedAt` entry from freshestUpdatedAt(); the
other six per-section caches still feed the hero's "Generated …"
caption.
- The "section-general" entry from the sticky pill nav (it had
nothing left to scroll to).
Kept:
- The /api/insights/general-status route + the underlying
generateGeneralStatusForUser lib — the reminder worker still
pre-warms the daily general-status cache for users opted in to the
briefing notification. Added a JSDoc @deprecated note on the route
pointing new callers at <InsightAdvisorCard> /
useInsightsAdvisorQuery().
- The insights.generalStatusTitle + insights.generalStatusBadge.*
translation keys — they're still referenced by the per-section
badge labels (insights.generalStatusBadge.good / .watch /
.critical map traffic-light states on every metric section, not
just the dropped general one).