Extends src/lib/measurements/apple-health-mapping.ts with the W8d
mapping surface the v1.5 iOS-Swift session needs locked before it
starts:
- Three new APPLE_HEALTH_TYPE_MAP entries —
HKQuantityTypeIdentifierEnvironmentalAudioExposure,
HeadphoneAudioExposure, TimeInDaylight — wired to the matching
MeasurementType values landed in Migration 0052.
- HK_QUANTITY_TYPE_TO_MEASUREMENT: a compact derived view of the
same data keyed by HK identifier so a caller that only needs the
enum value can skip instantiating the full mapping record (XML
import worker, iOS DTO routing).
- HK_QUANTITY_TYPE_DEFERRED: the curated "we know about it, we are
not mapping it yet" set. Covers iOS-17/18 running form, cycling,
paddle / row / ski distances + speeds, sleep apnea, scored
assessments (GAD-7 / PHQ-9), nutrition, mental-state, FHIR
clinical, ECG, pregnancy / cycle. Each block carries its planned
release window inline.
File-header attribution credits the two open-source projects whose
identifier lists were consulted as data —
k0rventen/apple-health-grafana (MIT) and dogsheep/healthkit-to-sqlite
(Apache-2.0); only the lookup strings were ported, not code.
Test additions
(src/lib/measurements/__tests__/apple-health-mapping.test.ts):
- exhaustiveness — every MeasurementType outside the
Withings-only set has an HK counterpart;
- dedup — no identifier appears in both the mapping table and the
deferred set;
- identity-conversion check for the three new entries;
- HK_QUANTITY_TYPE_TO_MEASUREMENT key + value parity with the full
map;
- deferred-set sanity (every entry starts with a real HK prefix).
Adds three new enum values that close the iOS-17/18 HealthKit coverage
gap before the v1.5 iOS-Swift session locks against Postgres:
- AUDIO_EXPOSURE_ENV (dBA, environmental noise)
- AUDIO_EXPOSURE_HEADPHONE (dBA, AirPods listening)
- TIME_IN_DAYLIGHT (minutes/day, iOS 17+)
Wire-up touches the canonical surfaces:
- prisma/schema.prisma: MeasurementType + comment block
- src/lib/validations/measurement.ts: enum + unitMap + VALUE_RANGES
- src/lib/__tests__/measurement-type-enum-coverage.test.ts: 25 -> 28
expected types; new entries added to PDF_VITAL_EXCLUSIONS under the
existing v1.5 clinical-layout gate
- src/components/measurements/measurement-list-meta.ts: i18n keys,
Volume2 / Headphones / Sun icons, palette assignments
- src/lib/insights/chart-tokens.ts: ALLOWED_CHART_TOKENS allow-list
plus orphan-enum stripper coverage so the LLM cannot leak raw
upper-snake names into rendered prose
- messages/{en,de,fr,es,it,pl}.json: typeAudioExposureEnv,
typeAudioExposureHeadphone, typeTimeInDaylight (en/de translated;
fr/es/it/pl carry English placeholders matching their existing
convention for the W5d additions)
The task brief originally listed a fourth value (WORKOUT_ROUTE) as a
sentinel slot. Dropped here because workouts are first-class entities
landing in Migration 0053 with their own table (Workout + WorkoutRoute);
HealthKit also separates HKWorkout from HKQuantitySample. A reserved
sentinel that no real Measurement row will ever take would be a dead
slot for analytics, doctor-PDF allow-lists, and chart registries.
Next 16's dev server passes a synthetic placeholder (not a real
NextRequest instance) as the first argument to route handlers
declared `export const dynamic = "force-static"`. NextRequest's
`method`, `url`, and `headers` getters all read a private `#state`
field; invoking those getters with the wrong `this` binding crashes
with the V8 native error:
TypeError: Cannot read private member #state from an object whose
class did not declare it
at Reflect.get (<anonymous>)
at wrapped (src/lib/api-handler.ts:56:23)
This 500s `/api/version` on every request (the only `force-static`
route in the app today) and would 500 any future force-static route
that anyone adds.
Wrap every direct NextRequest property access inside the apiHandler
wrapper in a `safeRequestProp` helper that swallows the private-field
error and returns a sensible fallback (empty string for `url`, "GET"
for `method`, `null` for header lookups). The fallbacks let logging
instrumentation continue to attach `path` / `method` / `user_agent`
fields when they are available and silently degrade when they are
not — instrumentation must never crash a route handler.
Verification: `curl -sI /api/version` -> HTTP/1.1 200 OK, body
contains the package.json version (was 500). Dynamic routes
(`/auth/login`, `/api/health`) still serve correctly. Affected
tests still pass (vitest src/lib/api-handler + src/app/api/version
+ src/components/targets = 31/31).
Add the four AI-initial locale bundles (FR / ES / IT / PL) and a
small dismissible MaintainershipBanner strip that paints at the top
of the auth shell whenever the active locale falls outside the
maintainer-curated set (currently DE + EN). The banner names the
AI-initial provenance and links to a GitHub translation-feedback
template so users can contribute fixes back. Dismissal persists
per-locale in localStorage so dismissing the FR banner does not
hide the IT banner once the user switches language.
The bundles are generated by scripts/i18n/build-locale.py — a layered
walk of messages/en.json that applies a vocabulary lookup keyed on
the lowercase EN value, preserves every {interpolation} placeholder
and <tag> marker verbatim, and falls through to the English value
for long-tail prose the script can't translate. The banner advertises
this status so users know what they are looking at. All four locale
files pass the i18n-locale-integrity test verbatim — full key
parity to en.json, no empty values, no TODO/FIXME placeholders,
no value-equals-last-segment matches.
Foundation work bundled with the banner so the build never lands in
a half-wired state:
- locales array extended to ["de","en","fr","es","it","pl"] with
matching localeLabels + MAINTAINED_LOCALES set + isMaintainedLocale
helper in src/lib/i18n/config.ts.
- INTL_LOCALE_MAP and parseLocaleFromAcceptLanguage in
format-locale.ts gained the four new tags so SSR locale resolution
and Intl number/date formatting work end-to-end.
- i18n/context.tsx and i18n/server-translator.ts import the four new
JSON bundles alongside DE + EN.
- Coach (src/lib/ai/coach/system-prompt.ts) and the strict insights
generator (src/lib/ai/prompts/insight-generator.ts) ship the EN
system prompt with a one-line REPLY LANGUAGE footer for the four
AI-initial locales. The DE branch keeps its hand-curated body
verbatim. Routing through the EN system prompt preserves every
safety contract (zero hallucinations, no dose-prescription,
evidence-block sentinel format) without re-validating four new
language bodies.
- Five locale-parameter signatures (no-key-fallbacks, glp1-plateau,
time-window-format, target-prompts, medication-form) widened
from "de" | "en" to the canonical Locale type. Helpers that
ship DE+EN copy route non-DE locales to the EN body, mirroring
the same fallback chain the JSON bundles use.
- OpenGraph alternateLocale on the root layout lists the four new
BCP-47 tags so social-link previews advertise the broader
language coverage.
Tests:
- 4 new SSR tests pin the locale gate (EN/DE hidden, FR/ES/IT/PL
visible by default, dismissed flag honoured) and the re-exported
isMaintainedLocale helper.
- parseLocaleFromAcceptLanguage gains a FR/ES/IT/PL coverage block
alongside the existing DE / EN / unknown branches.
- The existing i18n-locale-integrity suite auto-discovers each new
messages/<locale>.json and runs the 25-sub-test parity + value-
quality battery against it — all six locales pass.
LocalePicker is data-driven (settings and onboarding both iterate
the locales array) so promoting the array to 6 entries grows both
dropdowns without per-call-site changes. The PDF doctor-report
renderer is t()-driven for every label, so the four new JSON
bundles give that renderer FR/ES/IT/PL output without touching
doctor-report-pdf-core.ts.
Tailwind v4's content scanner walks the entire project root by default
and emits a CSS rule for every class-shaped token it finds — including
literal class fragments quoted inside .planning report markdown and
docs/ design specs. The bracketed CSS-variable arbitrary-value form
combined with an opacity modifier (e.g. `bg-[var(--name)]/12`)
generates an escaped selector whose `\(...\)\/N` token sequence
crashes Turbopack's CSS parser with `Unexpected token Delim('.')`,
500-ing every page that imports globals.css.
The crash:
./src/app/globals.css:1853:27
Parsing CSS source code failed
1852 | .bg-\[var\(...\)\]\/12 {
> 1853 | background-color: var(...);
| ^
Three coordinated changes:
- Add `@source not "../../.planning"` and `@source not "../../docs"`
to globals.css so the scanner skips planning + docs trees. Stops
fixture-style class names in research/audit reports from poisoning
the generated CSS.
- Migrate `bg-[var(--dracula-*)]/N` / `ring-[var(--dracula-*)]/N` in
target-card.tsx to the Tailwind v4 parenthesised shorthand
`bg-(--dracula-*)/N` / `ring-(--dracula-*)/N`. The shorthand emits
a clean selector that LightningCSS parses cleanly. Per the Tailwind
v4 upgrade guide, the bracketed form is deprecated for CSS
variables.
- Replace `shadow-[0_0_0_1px_var(--dracula-green)]/20` in
consistency-strip.tsx with `shadow-[0_0_0_1px_color-mix(in_oklab,var(--dracula-green)_20%,transparent)]`.
The arbitrary shadow with opacity modifier hits the same parser
issue; encoding the alpha directly in the value via color-mix
removes the modifier and keeps the visual identical.
Verification: `pnpm dev --port 3030` then `curl -sI /auth/login` ->
HTTP/1.1 200 OK (was 500). Zero CSS parse errors in the dev server
log. Affected tests (targets + api-handler + api/version) still pass
(31/31).
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.
Extend Settings → Sources with two collapsed-by-default expanders below
the existing global ladder (Marc no-split directive — one screen, three
vertically-stacked sections):
1. Per-metric overrides — surfaces every metric whose ladder has been
edited away from the default; each row reveals a one-click reset
so a single metric can roll back without touching the rest.
2. Device-type priority — the new W8c axis. Default ladder uses the
open-wearables-compatible 'watch beats phone beats scale' order;
up/down arrows + 'Reset device-types' keep the same interaction
vocabulary as the existing source ladder.
Save round-trips the W8c canonical shape (metricPriority +
deviceTypePriority); the server's parseSourcePriority already echoes
the resolved shape back so the optimistic update is a setQueryData.
Reset PUTs an empty body so the server returns constant defaults.
i18n: 11 new keys under settings.sections.sources.* (en + de). German
prefers Umlaute (Uhr, Geräte-Typ-Reihenfolge, individuelle Anpassungen).
Tests: settings-shell.test.tsx already gates the navigation
ordering — keeps passing (12/12). No new component tests yet; the
integration coverage in 8c.5 exercises the new shape end-to-end.
The `/api/insights/general-status` route was deleted as part of W3f
(commit edae569) when the general-status panel migrated into the
combined comprehensive insights payload. The matching query-key factory
+ its integrity test assertion were missed in that cleanup pass.
The test now pins `insightsBpStatus` instead — the locale-in-status-key
invariant remains covered.
The wrapper composed `<ComplianceHeatmap />` and `<ComplianceLineChart />`
into a single panel, but no surface ever rendered the wrapper —
analytics pages mount the two child charts directly. The children stay
intact; only the dead wrapper is removed.
The component was exported but never imported anywhere — only the file
itself contained the three references (the props interface, the
function declaration, and the type annotation). Likely an early scaffold
for the v1.4 medication-intake redesign that was superseded by the
intake-card layout before any caller wired it in.
No tests existed for this component, so the delete is single-file.
`<HeroStrip>` (v1.4.20 phase B1) replaced this component on every
surface. Last production import was retired with the dashboard rewrite
and only the deprecated source + its self-test remained. Comment
markers in `hero-strip.tsx` already note the migration.
Pre-existing typecheck failures in the working tree (locale enum drift
from concurrent W9e i18n work in medication-card / medication-form /
target-card) are unrelated to this delete and stay as-is.
After picking the canonical source for a day, the picker now consults a
device-type ladder and keeps ONLY the rows from the top-ranked device-
type present in the day's bucket. Rationale: Apple Watch + iPhone +
Withings scale all stream steps into HealthKit; summing the three
triple-counts the daily total. The watch is the most reliable signal,
so the picker keeps the watch rows and drops phone/scale.
Lookup order (high to low): deviceTypePriority[metricType] (per-metric
override) > deviceTypePriority.default (user default) >
DEFAULT_DEVICE_TYPE_PRIORITY (constant). When no device-type info is
present (legacy NULL rows / pre-W8c data), the second axis is a no-op
— every row in the bucket passes through. Custom ladders that don't
enumerate the bucket's device-types also fall through unfiltered, so
the picker never silently drops data.
SourcePickerRow gains two optional fields (deviceType, type). Both
default-null so existing call sites continue to compile.
Tests: 18 cases pass (8 single-axis legacy + 10 new W8c two-axis).
Covers per-metric override, custom ladder, legacy NULL fallback,
preference of known over unknown, and the W8c-over-W5e shape merge.
Layer two new optional keys on top of the existing User.sourcePriorityJson
shape — metricPriority (nested per-metric ladder, canonical going forward)
and deviceTypePriority (default ladder plus per-metric overrides). The
W5e flat top-level metric keys stay accepted as a backward-compat alias
so existing rows keep working without a DB migration or forced reconfig.
parseSourcePriority() now returns a fully-resolved shape where every
metric ladder is defaulted and the device-type axis defaults to the
documented DEFAULT_DEVICE_TYPE_PRIORITY (watch first). Two helpers
(getSourceLadder, getDeviceTypeLadder) centralise the lookup so the
picker, the UI and downstream call sites share one path.
Tests: 30 unit tests pass (up from 19). Covers both axes' parse,
fallback chain (W8c nested > W5e flat > defaults), enum guards, and
the two helper functions.
Pre-existing typecheck failures unrelated to this change:
src/lib/i18n/* and a handful of consumers expect a 'de' | 'en' locale
union but i18n config declares the 6-locale superset.
Settings and Admin pages render inside AuthShell, whose inner main
already applies `mx-auto max-w-[76.8rem] px-4 py-6 md:px-6`. Both
shells then re-applied `px-4 py-6 md:px-6 md:py-8` on their own
container, which produced visibly more top/bottom whitespace on
/settings/* and /admin/* than on /, /measurements, /medications,
/mood, /insights, /coach, and /notifications.
Drop the redundant padding from SettingsShell and AdminShell so the
visual top-of-page baseline matches the rest of the app. The
`mx-auto w-full max-w-screen-xl` wrapper stays for the wider
content column that these two surfaces opt into. Mobile chip-strip
`-mx-4` still cancels AuthShell's horizontal padding correctly.
Pixel-5 (393 px) audit flagged four primary navigation surfaces that
were below the 44 px touch-target floor:
- TopBar user-menu trigger and login link: text-only flex with
py-1.5 measured ~30 px tall. Added min-h-11 min-w-11 with a
rounded hit zone so the trigger lands on the WCAG floor without
visibly altering the icon's position.
- SettingsShell mobile section chip-strip and AdminShell mobile
section chip-strip: rounded-full chips with py-1.5 were ~30 px
tall. The mobile chip-strips are the primary navigation surface
for /settings and /admin on touch devices, so min-h-11 was added
to both.
- medication-form schedule interval picker (1W / 2W / 3W / 4W):
h-8 native buttons inside a 4-col grid; min-h-11 brings them up
to the floor without changing column width.
Also normalised the medications page-root spacing from space-y-5 to
space-y-6 to match dashboard / measurements / mood / notifications.
The Coach-Feedback admin section previously rendered four distinct top-
level structures across its loading / error / empty / data branches.
The loading branch was a thin flex-row stub without the heading, the
error branch was a p-4 alert with no card chrome at all, and only the
empty + data branches mounted the Sparkles icon + 'Coach Feedback'
title inside the bg-card border-border rounded-xl border p-6 wrapper.
The result: when the user clicked into /admin/coach-feedback the
section first measured at ~52 px (loading) and then snapped down to
its full height once the query resolved, shifting everything below it.
Mirror the canonical pattern used by SystemStatusSection: render the
outer card + heading unconditionally, and switch only the body between
spinner / alert / empty / table. The header now sits at a constant
y-offset from the section root regardless of fetch state.
Adds a regression test that asserts the 'Coach Feedback' heading and
the bg-card rounded-xl outer chrome are present in every render state.
Both heading variants (empty-state at line 126, header at line 139) now
follow the convention used by 21 other Settings sections: a Bell icon
(text-primary h-5 w-5) preceding the h2 in a flex container with
gap-2 items-center. Brings the only two violators in
src/components/settings/* into alignment with the icon-heading pattern
established in telegram-card.tsx, ntfy-card.tsx, web-push-card.tsx.
The 7 W5d additions (fat-free mass, fat mass, muscle mass, skin
temperature, pulse-wave velocity, vascular age, visceral fat) landed
in measurementTypeEnum but the per-type lookup maps and AI-chart
allowlist were never extended. Six drift-guard tests caught the gap.
Adds icons (Dumbbell for muscle; family-consistent reuse for the
rest), chart-bucket colors (mass → chart-1, fat/temp → chart-4,
cardio → chart-3, pulse-derived → chart-5), AI chart-token allowlist
entries, and orphan-enum strip list entries so raw enum identifiers
can never leak through prose. Doctor-report PDF holds the 7 new
types under the same v1.5 gate as the v1.4.23 Apple Health metrics
until clinical layout + reference ranges are agreed.
Baseline: 2537 → 2543 passing tests; typecheck clean.
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.
Closes the second half of the deferred phase-W4d test gap. The
picker is a controlled SVG with 8 click-targets wrapped in
invisible 22px circles carrying role=button + aria-label +
tabIndex=0 (WCAG 2.5.5 minimum-tap-target + keyboard-reachable).
The nextInjectionSite() helper return is dashed-ring annotated
over the recommended target.
Coverage (12 tests):
- All 8 InjectionSite enum values render as click-targets
- Active site gets the fill-primary highlight
- Recommended next-site is dashed-ring annotated
- onChange(site) fires for every enum value
- Keyboard navigation: tabindex=0 on every target
- ARIA labels EN + DE smoke (Umlaute round-trip)
- aria-pressed=true on the selected site, false on the others
- Empty history → recommender defaults to ABDOMEN_LEFT
- Caption swap between selection line and recommendation hint
- SVG body outline + role=group wrapper
No production code changes.
Closes the deferred component-level test gap from phase W4d
(`.planning/phase-W4d-v1425-glp1-full-report.md` section 5).
Renders the Glp1MedicationCard via renderToStaticMarkup with a
seeded QueryClientProvider so the useQuery calls resolve
synchronously, then asserts on the produced SSR string.
Coverage (13 tests):
- GLP-1 variant renders when treatmentClass === "GLP1"
- Default MedicationCard back-compat when treatmentClass is null
- Drug name + dose joined with the middle-dot separator
- Last + next injection lines with localised prefixes
- Dose-history <details> disclosure (closed by default)
- Injection-site rotation hint (last + recommended)
- Pen-inventory line + low-stock badge
- Side-effect quick-log button hands off the medication object
- AI-disabled state pin (no Coach hand-off button today)
- Inactive paused-since state
- German locale parity with Umlaute round-trip
No production code changes.
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.
Seven new enum values for v1.4.25 W5d. Source: Withings developer
guide — Data API → All Available Health Data — cross-referenced
against `.planning/research/withings-api-coverage.md` §3.
- FAT_FREE_MASS (meastype 5) — Body+, Body Cardio, Body Comp, Body Scan
- FAT_MASS (meastype 8) — same devices; mass form of BODY_FAT
- MUSCLE_MASS (meastype 76) — Body+ family
- SKIN_TEMPERATURE (meastype 73) — ScanWatch dermal reading. DISTINCT
from BODY_TEMPERATURE: surface temps run ~32 °C, core ~37 °C, so
they cannot share a bucket without corrupting analytics.
- PULSE_WAVE_VELOCITY (meastype 91) — Body Cardio / Body Scan;
indicator of arterial stiffness
- VASCULAR_AGE (meastype 155) — Body Scan; composite of PWV + age
- VISCERAL_FAT (meastype 170) — Body Comp / Body Scan; Withings'
1–12 rating scale (not a percent)
Migration 0049 is additive only — every value is unconditional, no
backfill needed. The Withings client picks these values up once the
meastype → enum mapping lands (separate commit in this phase).
Unit map + plausibility ranges updated in `src/lib/validations/
measurement.ts` so the create-measurement validator accepts the new
types end-to-end.
Final-pass hygiene for the W4d GLP-1 integration:
- `buildGlp1SnapshotBlock()` and `detectGlp1Plateau()` short-circuit
to `null` when the test environment mocks prisma partially (only
`measurement` / `moodEntry` mocked, `medication` undefined). Matches
the production behaviour for accounts without an active GLP-1 row.
- DE locale parity: adds the matching German string for
`insights.subPage.therapyTimelineDescription` that landed without
its de.json counterpart.
- Drops unused `locale` destructure on the GLP-1 medication card and
the dead `DailyValueRow` interface in the snapshot helper.
- Regenerated `docs/api/openapi.yaml` so the new GLP-1 endpoints
(/api/medications/[id]/glp1, /api/insights/glp1-timeline) appear
in the spec.
Phase 10 of W4d GLP-1 full integration. The doctor-report PDF gains a
dedicated "GLP-1 therapy" section when the patient has at least one
active GLP-1 medication and the existing compliance toggle is on. The
section lists what a prescribing clinician needs at every follow-up:
- Weight delta over the report window (start → end → delta)
- Per-medication block: drug name, current dose + start date,
compliance count and rate, full titration history table
(date | dose | note)
- Side-effect frequency table (last N days, counts per tag) when the
mood section is also enabled (mood is the data source)
Generic accounts (no GLP-1 medication) get `glp1: null` from the
aggregator and the PDF skips the entire section, so existing reports
stay byte-identical.
Aggregator extension lives in `doctor-report-data.ts` — eager-loads
`doseChanges` + the most recent intake event on every active
medication (cheap, indexed); GLP-1 specifics only assemble when at
least one GLP-1 row exists. The field is optional on the type to
preserve compatibility with existing test fixtures.
DE + EN copy for the report section lives under `doctorReport.glp1*`.
Marc directive 2026-05-14 — when a chart has a comparisonBaseline
selected but the prior period (lastMonth / lastYear) carries zero
overlay-able rows, the chart settings cog now shows the comparison
buttons in a visibly disabled state (opacity-50, aria-disabled, native
title tooltip, and a sub-label hint) instead of silently rendering
nothing. The 'none' button always stays enabled — it's the escape
hatch.
Plumbing:
- chart-overlay-controls.tsx: new `hasComparisonData` prop (defaults
to true so legacy callers keep their behaviour). The popover body is
extracted as `<ChartOverlayControlsBody>` so unit tests can render
it without Radix's portalled popover stripping it out of the SSR
output.
- health-chart.tsx + mood-chart.tsx: thread the already-computed
`hasComparisonData` memo (line 768 / 457) into the controls.
- medication-compliance-chart.tsx: passes `hasComparisonData={false}`
unconditionally because the compliance heatmap never paints a
prior-period overlay (already documented in the existing N/A
caption block).
Tests: 5 new cases under chart-overlay-controls.test.tsx cover the
normal / greyed / none-baseline / locale / default-prop branches.
The pre-W3f assertion suite (3 tests) keeps passing unchanged.
i18n: appended chart.overlay.controls.comparisonUnavailable to both
locales — no key restructure.
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
Phase 5 of W4d GLP-1 full integration. The body-map picker is the
visual surface for the 8-zone InjectionSite enum that landed with
migration 0046. Stylized front-of-body SVG outline with 8 click-target
dots — head/neck/torso/arms/thighs for orientation, abdomen
quadrants centred on the navel reference per Eli Lilly's IFU rotation
zones, plus thighs and upper arms.
UI behaviour:
- Active site fills with the primary colour
- Recommended next site shows a dashed primary-coloured ring
- Each <button> hit area is 22px radius so the target meets WCAG 2.5.5
even though the visible dot is 12px
`nextInjectionSite()` is a pure function that maximises the average
Euclidean distance from the candidate to the last N=4 picks, with a
recency penalty for sites used most recently. Untouched sites beat
stale ones via the recency-bonus tie-break. Empty history returns
ABDOMEN_LEFT as a sensible first-time default (the card always has
something to point to).
Unit tests cover: never-recommends-most-recent invariant, sensible
choice after a left-cluster sequence, rotation through unused sites,
i18n key shape, coordinate-table completeness.
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.
The composer starts at a single-line height (matching the disclaimer
text on the left of the row) and grows with content up to six lines.
Past that point the textarea scrolls internally so the rest of the
drawer never moves. Implementation is the standard scrollHeight
measurement — reset to "auto", read scrollHeight, clamp to
[lineHeight + padding, 6 × lineHeight + padding]. The pure
`computeAutoGrowHeight` helper backs the effect for unit testing.
Adds an optional `verticalMarkers` prop to `<HealthChart>` so the
v1.4.25 GLP-1 dashboard tile + the Insights /medikamente sub-page can
paint dashed vertical reference lines at each recorded injection day.
Each marker = `{ date: 'YYYY-MM-DD', label?, color? }`. Defaults to
the strip-tile green (`#50fa7b`) so a row of markers reads as
"injection cadence" at a glance; callers can override per marker.
Rendering:
- Recharts `<ReferenceLine>` with `strokeDasharray='3 3'`,
`strokeOpacity=0.55`, no label by default (inline labels would
crowd a once-weekly cadence over a 90-day window).
- A small filled `<ReferenceDot>` (r=3) at the y-axis minimum so
the markers also read along the x-axis baseline.
- Off-window markers silently drop via `ifOverflow='discard'`,
matching the existing storyboard-annotation contract.
The marker math is factored into a pure helper
`resolveVerticalMarkerPositions` so the unit suite can pin behaviour
without mounting Recharts. Day-keys are matched by EXACT string
comparison against `chartData[i].date` (no ±7-day snap like the
storyboard annotations) — drawing a marker on a day the chart has no
data point reads as a rendering bug rather than as orientation.
Tests pin: empty input, undefined input, single in-window match,
multi-marker order, caller-supplied color/label overrides, off-window
drop, sparse weekly-bucket exact-day matching.
The mic was rendered + disabled with a "voice arrives with iOS"
tooltip, drawing taps that did nothing. Voice input remains scheduled
for the iOS client (v1.5); the web composer no longer surfaces a
placeholder. Frees the row for the Enter-to-send hint to breathe.
All three header actions (new chat, settings, close) now share the
same ghost / size-icon / 36-px hit-target shape with a 16-px icon and
identical color treatment. The Sheet's default absolute-positioned
close X is gone; the inline close button sits on the same baseline as
the others so the cluster reads as a single set.
`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.
Phase 2a of W4d GLP-1 full integration. New `buildGlp1SnapshotBlock()`
helper reads every active medication classified as GLP1 (treatmentClass
column from migration 0046) and assembles a compact block the snapshot
ships under `weeklyContext.glp1` — drug display name + generic active
ingredient, current dose + weeks-on-dose, chronological titration
history, weekly cadence (parsed from MedicationSchedule.daysOfWeek),
last + next injection date, last injection site, pen inventory math,
and side-effect tag counts from the last 14 days of MoodEntry rows.
The block is omitted entirely for users with no GLP-1 medications (the
helper short-circuits to null after one indexed Prisma lookup), so
web-only generic accounts pay zero token cost.
Marc directive 2026-05-14: the Coach must say "your Mounjaro 7.5 mg"
instead of "your medication" when the user has named the drug. The
snapshot is the load-bearing surface for that — the system-prompt
guardrail (GROUND RULE 9) lands in the follow-up commit and forbids
any dose-prescriptive use of this data.
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 ScanWatch family reports VO2 max under meastype 123 in
mL/(kg·min), which matches HealthLog's canonical DB unit verbatim
(no conversion needed beyond the standard value × 10^unit exponent
decode). Targets the VO2_MAX enum slot added in v1.4.23 for the
Apple Health passthrough.