The Workout uniqueness key is (userId, source, externalId). The same
workout ingested from MANUAL plus the iOS HealthKit passthrough lands
two rows because `source` differs — Postgres NULL-distinct semantics
keep manual workouts unique among themselves, but the cross-source
case has no canonical-picker layer yet.
Measurements already solved the symmetric problem via the W8c
canonical-source picker. Workouts need the same shape before the
dashboard "recent workouts" tile can show one row per workout instead
of one per source. Schema-only release today (W8d), and there is no
iOS client yet, so the smell is latent — track it inline on the model
so the v1.5 iOS sprint picks it up.
Two reconcile fixes on parseSourcePriority(), one observability and one
invariant guard:
- Emit a wide-event tag when the persisted JSON fails the Zod schema
so a future schema-tightening regression that silently nukes a
user's saved ladder surfaces in ops dashboards. annotate() is a
no-op outside a request context, so static settings-page renders
and unit tests stay side-effect-free.
- Deep-freeze the resolved blob. The top-level flat keys share their
backing arrays with `metricPriority` via the spread, so a caller
mutating `resolved.metricPriority.weight = […]` would silently
desync the two views. Freezing every ladder + the containers + the
resolved root surfaces the mutation at runtime in strict mode.
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.
The 'per night' caption beneath the sleep headline was branching on
`locale === "de"` and falling through to English for FR/IT/ES/PL.
Move the string into `insights.sleep.headlineCaptionSuffix` with
native translations (de: pro Nacht, fr: par nuit, it: per notte,
es: por noche, pl: na noc) and resolve it through `t()`.
The previous `requireAuth()` policy 403'd every narrow-scope Bearer token
on routes that did not declare a required permission. The intent was to
keep wildcard tokens from acting as session-equivalents on sensitive
routes that never gated on scope, but the side effect blocked four
W8d routes (`/api/measurements/by-external-ids`, `/api/personal-records`,
`/api/medications/[id]/glp1`, `/api/dashboard/glp1`) for the v1.5 iOS app.
Restate the contract: a route that does not declare a `requiredPermission`
is content with any authenticated token; a route that declares one gates
on it. Narrow-scope tokens still 403 against scoped routes they don't
list. Add four cases to the Bearer-auth test suite covering all token-
× route-scope combinations.
The W8c picker pinned the device-type ladder to `pickedRows[0].type` for
the entire bucket and walked one ladder for every row. The inline
comment promised per-row resolution against the cached ladder map; the
implementation never delivered it. Today's only caller — the
`SLEEP_DURATION` aggregation in `/api/analytics` — feeds single-type
rows so the gap is latent. Future callers (Coach evidence rollup,
doctor-PDF section, correlations engine) that batch multiple
MeasurementTypes through one picker call would have tested non-matching
rows against the wrong ladder.
Group present device-types per row-type, resolve each type's ladder
from the cache, pick the winning device-type independently per type.
Per-type fall-through covers the case where the user's custom ladder
doesn't enumerate any device-type present for a given row-type.
Cover the multi-type path with two new tests:
- per-row ladder resolution preserves each type's winner
- per-type fall-through when a custom ladder is unreachable
CartesianGrid and axis labels were rendering un-themed because
`stroke="hsl(var(--border))"` evaluates to `hsl(#44475a)` —
invalid CSS — and the SVG fell back to currentColor. The Dracula
tokens are already hex, so the bare `var(--border)` /
`var(--muted-foreground)` form is the correct contract.
The components list above the accordion already paints a top
border; a second border on the accordion wrapper made the divider
stride read visually heavy. Drop it so the accordion sits flush
under the four sub-bars.
Pins every key the W8e accordion renders to the locale-integrity
guard. Mirrors the measurement-list-meta enum-coverage pattern:
if a future regression drops a key or adds a new source token
without a matching locale entry, the test fails fast across all
six shipped locales.
Pairs the panel <section> with the toggle button via id +
aria-labelledby so screen readers announce 'Driven by' as the
panel's accessible name. The toggle already advertises
aria-expanded + aria-controls; the labelledby pairing closes the
loop the WAI accordion pattern recommends.
The drift-guard test that pinned strict attribute order on the
panel id now tolerates additional ARIA attributes between id and
data-slot via a flexible regex.
Adds a tap-to-expand 'Driven by' panel under the four component
sub-bars inside <HealthScoreCard>. Each row pairs the existing
value bar with a second weight-share bar tinted in the Coach
provenance accent plus a source pill (manual / withings /
appleHealth / mixed / none). Rows sort by effective weight
descending so the biggest contributor leads; null components sink
to the bottom and dim to 50 % opacity.
A 'Provisional' header badge fires when fewer than half of the
configured inputs carry data, and a role=status 'mixed sources'
banner surfaces above the rows whenever at least one component
blends multiple ingest paths.
i18n keys land under insights.healthScore.provenance.* across all
six locales (en / de / fr / es / it / pl with formal register).
The locale-integrity drift-guard auto-discovers the new keys.
14 new RTL-style SSR tests pin the contract: aria-expanded toggle,
aria-controls/id pairing, sort order, source-pill localisation,
mixed banner gating, empty-state dimming, provisional threshold,
asOf tooltip forwarding, and footnote rendering.
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.
Captures the five-sub-task wave that locks the Apple Health server
contract before the v1.5 iOS-Swift session: migrations 0052 / 0053 /
0054, the mapping extension, the personal-records skeleton, and the
opt-in VO2 max tile. Restates the WORKOUT_ROUTE drop rationale and
lists the items deferred to v1.4.26+.
Adds the VO2 max strip tile to /, matching the existing
secondary-metric convention (sleep / steps / glucose / oxygen
saturation are all default-invisible + opt-in via Settings →
Dashboard).
Wire-up:
- src/lib/dashboard-layout.ts: vo2Max appended to
DASHBOARD_WIDGET_IDS and DEFAULT_DASHBOARD_LAYOUT.widgets with
visible=false / tileVisible=false. The /api/dashboard/widgets
Zod enum is derived from the same array so the toggle is wired
automatically.
- src/app/page.tsx: vo2Summary from data?.summaries?.VO2_MAX
(auto-populated by /api/analytics; the route iterates the full
measurementTypeEnum.options list, no backend change needed),
hasVo2 data-floor gate, showVo2Tile combined gate, and the
TrendCard push next to showStepsTile. directionSentiment is
up-good (higher VO2 max is better) and the unit string runs
through the t() helper so the iOS app session can localise it.
- src/components/settings/dashboard-layout-section.tsx: vo2Max
label key added to WIDGET_LABEL_KEYS so the Settings → Dashboard
toggle row renders the localized label.
- messages/{en,de,fr,es,it,pl}.json: dashboard.vo2Max + vo2MaxUnit
keys. "VO₂ max" is universal across all six locales (scientific
abbreviation, no translation needed); the unit string is
consistent SI notation.
No chart-row card — that's an explicit v1.5 deferral per the W8d
outline §6.4. The tile lights up the moment the user accumulates any
VO2 sample (Withings ingest or the upcoming iOS Health passthrough).
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.
Locks the workout-ingest contract before the v1.5 iOS-Swift session.
v1.4.25 ships no UI surface for workouts beyond the VO2-max tile
landing in W8d.5; the table layout is in place so the iOS DTO can
be generated against it and the v1.4.26 XML / Strava workers can
target the same shape.
Schema highlights (full rationale in
.planning/research/w8d-implementation-outline.md §4):
- Typed columns for every hot-path metric (sport, duration, energy,
distance, HR avg/max/min, steps, elevation, pause). HKWorkout-
shaped 1:1, so the iOS DTO needs zero glue. Withings v2/measure
getworkouts maps cleanly modulo `hr_zone_0..3` (-> metadata).
- Open `metadata Json?` blob for the long tail (HK metadata bag,
Withings HR-zone breakdown, device bundle id).
- `sportType` is TEXT, not a Postgres enum — Apple alone carries
70+ HKWorkoutActivityType values and the tail only grows. Adding
a new sport is a Zod refinement, never a Postgres migration.
- Composite unique `(userId, source, externalId)` for ingest dedup;
Postgres NULL-distinct semantics keep manual entries non-colliding.
- WorkoutRoute is 1:1 with Workout, FK on workoutId with cascade
delete. Geometry is GeoJSON LineString in JSONB (no PostGIS
dependency — v1.4.20 set the same precedent for correlations).
Per-sample timestamps + speed/HR ride in `sample_timestamps` and
stay parallel to the coordinate array.
New module src/lib/validations/workout.ts:
- workoutSportTypeEnum: 20-member roster covering ≥98% of typical
workouts in the open-source corpora referenced in the outline.
- geoJsonLineStringSchema: RFC 7946 §3.1.4 with optional altitude
component; ≥2 points, lon ∈ [-180, 180], lat ∈ [-90, 90].
- workoutRouteSamplesSchema: parallel-array timestamp + speed + HR.
- createWorkoutSchema: full HKWorkout-shaped payload Zod for the
eventual POST /api/measurements/batch?kind=workout endpoint.
13 unit tests cover the happy path (Apple Health full payload), the
minimal-payload defaults, sport-type rejection, GeoJSON validation
(Point rejected, single-point LineString rejected, OOB coordinates
rejected), and route-sample edge cases. No ingest route ships in
v1.4.25 — the schema is the contract.
Records the two release-blockers resolved against the W9e translation
wave before tagging v1.4.25:
- Fix 1 (parseLocaleFromAcceptLanguage regression): no-op — the W7d
agent flagged a stale failure that the W9e wave itself had already
closed by adding a dedicated FR/ES/IT/PL prefix-match test block.
- Fix 2 (missing comparison.toggleHint): bundled into 6a7da4b which
restored the key across all six locales alongside the audio-exposure
/ time-in-daylight enum backfill.
All quality gates clean — full suite 2583 passed (1 skipped), above
the post-W9e baseline of 2577.
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).
W8d.1 follow-up — the first commit landed the schema + code surface but
the messages/*.json edits did not persist through the editor sync. This
commit completes the locale parity:
- typeAudioExposureEnv / typeAudioExposureHeadphone / typeTimeInDaylight
across en (translated), de (translated), and fr / es / it / pl
(English placeholders matching the existing convention for the W5d
additions; will be filled by the locale build script on the next
pass)
- comparison.toggleHint backfilled to it / pl which were missing the
key the other four locales already carry — pre-existing drift that
surfaced in the same diff, included here so the locale-integrity
drift-guard test runs green
No code change. The drift-guard tests
(measurement-list-meta + i18n-locale-integrity) now pass.
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.
scripts/i18n/build-locale.py walks messages/en.json and emits the four
AI-initial locale bundles (FR / ES / IT / PL) via a layered translation
strategy: full-path overrides for highly contextual strings, a
common-vocab lookup keyed on the lowercase EN value, placeholder
protection that splits {interpolation} tokens and <tag> markers out
before translation and re-glues them verbatim afterwards, and a
fallback to the English value for long-tail prose the script can't
cover. Re-running the script on a fresh en.json keeps the four
locale bundles in parity automatically — handy when new keys land
between releases.
The phase report (.planning/phase-W9e-v1425-translations-report.md)
captures the per-locale vocabulary calls (Apple-Health / Withings
register checks for each language), the system-prompt routing
decision (EN body + REPLY LANGUAGE footer rather than four native
~500-line bodies, to preserve every safety contract verbatim), and
the deferred polish for v1.4.26 (hand-review prose for the highest-
traffic surfaces, native FR/ES/IT/PL prefs strings in
buildPrefsPrefix, locale-native date formats).
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.
Previous Claude session reached 79% context. Handoff for the new
session to pick up W8 → W13 cleanly without re-reading the full
phase-report trail. Lists exactly what's landed (all 23 waves
inventoried), what's still open (W8/W9e/W10/W11/W12/W13), the
critical Marc-Voice + branch-model + i18n conventions, the
in-flight test failures to expect, and the verbatim greeting for
the new session.
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.
Records the four polish-bar deliverables (per-card target-edit cog,
comparison-overlay grey-out, orphan general-status endpoint cleanup,
per-night sleep-stages stacked bar) with their commit SHAs,
file lists, verification results, and notes on the parallel-agent
commit-message collision that swept Win 1 into d74aa20.
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*`.