From befac0ec65292f1027f35a788605fa3ce33897ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 28 May 2026 22:19:16 +0200 Subject: [PATCH] fix(measurements): wire the six deferred Apple Health identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds RESPIRATORY_RATE, BODY_MASS_INDEX, and the four remaining deferred quantity types to MeasurementType plus the Apple Health mapping. The batch route already returns 200 with a per-entry skip for unmappable identifiers; iOS reads that as success and advances its sync anchor, so any sample with one of these identifiers used to be dropped forever. The mapping now accepts them and the rows land. The walking-asymmetry and walking-double-support percent values adopt the project's server-side scaling convention (raw HK reading on the wire, server applies the ×100 at ingest), matching the walking-steadiness path. iOS releases that still send the pre-multiplied percent need a one-release shim and are tracked in the iOS coord notes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../v155-wire-six-deferred-identifiers.md | 77 ++++++++++++ docs/api/openapi.yaml | 20 ++- messages/de.json | 6 + messages/en.json | 6 + messages/es.json | 6 + messages/fr.json | 6 + messages/it.json | 6 + messages/pl.json | 6 + .../migration.sql | 29 +++++ prisma/schema.prisma | 38 ++++++ .../measurements/measurement-list-meta.ts | 26 ++++ .../measurement-type-enum-coverage.test.ts | 20 ++- src/lib/insights/chart-tokens.ts | 16 +++ .../__tests__/apple-health-mapping.test.ts | 111 ++++++++++++++++ src/lib/measurements/apple-health-mapping.ts | 118 ++++++++++++++++-- src/lib/measurements/categories.ts | 12 ++ src/lib/personal-records/pr-direction.ts | 16 +++ src/lib/validations/measurement.ts | 42 +++++++ 18 files changed, 552 insertions(+), 9 deletions(-) create mode 100644 .planning/ios-coord/v155-wire-six-deferred-identifiers.md create mode 100644 prisma/migrations/0083_v155_ios_measurement_types/migration.sql diff --git a/.planning/ios-coord/v155-wire-six-deferred-identifiers.md b/.planning/ios-coord/v155-wire-six-deferred-identifiers.md new file mode 100644 index 000000000..a7047e52f --- /dev/null +++ b/.planning/ios-coord/v155-wire-six-deferred-identifiers.md @@ -0,0 +1,77 @@ +# v1.5.5 — six previously-deferred HK identifiers wired + +Server-side change shipping on `main` for v1.5.5. + +## Summary + +Six HealthKit identifiers used to sit in +`HK_QUANTITY_TYPE_DEFERRED` inside +`src/lib/measurements/apple-health-mapping.ts`. The mapping returned +null; the batch route emitted HTTP 200 with a per-entry +`skipped:"unmappable_identifier"`; the iOS app read 200 as success +and advanced its sync anchor. Net effect: every sample carrying one +of these identifiers was silently dropped forever with no retry +path. The audit team flagged the gap on the v0.8.0 iOS pass. + +The six identifiers now wire end-to-end with new `MeasurementType` +values + Zod validator entries + DB plausibility ranges + category / +PR-direction / list-meta / chart-token coverage: + +| HK identifier | MeasurementType | DB unit | +| ---------------------------------------------------------- | ---------------------------- | ------------- | +| `HKQuantityTypeIdentifierRespiratoryRate` | `RESPIRATORY_RATE` | `breaths/min` | +| `HKQuantityTypeIdentifierBodyMassIndex` | `BODY_MASS_INDEX` | `kg/m²` | +| `HKQuantityTypeIdentifierLeanBodyMass` | `LEAN_BODY_MASS` | `kg` | +| `HKQuantityTypeIdentifierWalkingHeartRateAverage` | `WALKING_HEART_RATE_AVERAGE` | `bpm` | +| `HKQuantityTypeIdentifierWalkingAsymmetryPercentage` | `WALKING_ASYMMETRY` | `%` | +| `HKQuantityTypeIdentifierWalkingDoubleSupportPercentage` | `WALKING_DOUBLE_SUPPORT` | `%` | + +DB migration: `prisma/migrations/0083_v155_ios_measurement_types`. +Pure additive `ALTER TYPE ... ADD VALUE IF NOT EXISTS`. + +## Unit convention — server-side scaling is canonical + +The audit also flagged a footgun in the gait-percent path. Two +conventions for "percent" values were live in the tree: + +- `walkingAsymmetryPercentage` + `walkingDoubleSupportPercentage` + — iOS multiplied the HK reading (`0..1` fraction) by ×100 **before + upload**, so the wire value already lived in `0..100`. +- `walkingSteadiness` (v1.4.30) — iOS sent the raw HK reading + (`0..1` fraction), the server applied the ×100 at ingest. Same + shape as the existing `oxygenSaturation` + `bodyFatPercentage` + paths. + +The split was source-of-bugs: every future contributor adding a new +percent metric had to remember which convention applied. We picked +the cleaner one as the project convention: + +> **Server-side scaling is canonical.** Every HealthKit value flows +> over the wire as the raw HK reading. Whatever ×100 / unit-bend / +> clamp the canonical DB shape needs, the server applies it inside +> `convertToDbUnit` at ingest. + +`apple-health-mapping.ts` now documents this convention at the top +of the `APPLE_HEALTH_TYPE_MAP` block. + +### iOS migration window + +The v1.5.5 server release ships with the new gait identifiers +applying the ×100 scaling server-side. iOS releases that still +multiply by 100 pre-upload will land values in the `0..10000` band +— the plausibility-range guard (`0..100`) will reject them with +`skipped:"value_out_of_range"` per entry. + +Action item for the iOS client: drop the pre-multiplication for +`HKQuantityTypeIdentifierWalkingAsymmetryPercentage` and +`HKQuantityTypeIdentifierWalkingDoubleSupportPercentage` so the raw +HK reading (`0..1`) flows over the wire. The server will apply the +×100 the same way it already does for `oxygenSaturation`, +`bodyFatPercentage`, and `appleWalkingSteadiness`. + +Suggested timing: one iOS release alongside the v1.5.5 server cut, +or the next regular iOS sync if the existing TestFlight build can +wait. The plausibility-range guard fails closed — no bad data lands +in the DB; the only impact during the window is that those two +gait identifiers ingest as `skipped:"value_out_of_range"` instead +of as recorded samples. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2818f582c..cf0e4bec1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.5.3 + version: 1.5.4 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -170,6 +170,12 @@ paths: - TIME_IN_DAYLIGHT - WALKING_STEADINESS - AUDIO_EXPOSURE_EVENT + - RESPIRATORY_RATE + - BODY_MASS_INDEX + - LEAN_BODY_MASS + - WALKING_HEART_RATE_AVERAGE + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT - in: query name: from schema: @@ -295,6 +301,12 @@ paths: - TIME_IN_DAYLIGHT - WALKING_STEADINESS - AUDIO_EXPOSURE_EVENT + - RESPIRATORY_RATE + - BODY_MASS_INDEX + - LEAN_BODY_MASS + - WALKING_HEART_RATE_AVERAGE + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT value: type: number measuredAt: @@ -2222,6 +2234,12 @@ components: - TIME_IN_DAYLIGHT - WALKING_STEADINESS - AUDIO_EXPOSURE_EVENT + - RESPIRATORY_RATE + - BODY_MASS_INDEX + - LEAN_BODY_MASS + - WALKING_HEART_RATE_AVERAGE + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT value: type: number unit: diff --git a/messages/de.json b/messages/de.json index aa56088ab..1c182f546 100644 --- a/messages/de.json +++ b/messages/de.json @@ -337,6 +337,12 @@ "typeTimeInDaylight": "Zeit im Tageslicht", "typeWalkingSteadiness": "Gehstabilität", "typeAudioExposureEvent": "Lautes-Hören-Ereignis", + "typeRespiratoryRate": "Atemfrequenz", + "typeBodyMassIndex": "Body-Mass-Index", + "typeLeanBodyMass": "Magermasse", + "typeWalkingHeartRateAverage": "Geh-Puls (Mittelwert)", + "typeWalkingAsymmetry": "Gang-Asymmetrie", + "typeWalkingDoubleSupport": "Gang-Doppelstand", "glucoseContext": "Kontext", "glucoseContextFasting": "Nüchtern (≥8 h ohne Nahrung)", "glucoseContextPostprandial": "Postprandial (2 h nach dem Essen)", diff --git a/messages/en.json b/messages/en.json index 8f51cf488..5eca14859 100644 --- a/messages/en.json +++ b/messages/en.json @@ -337,6 +337,12 @@ "typeTimeInDaylight": "Time in Daylight", "typeWalkingSteadiness": "Walking Steadiness", "typeAudioExposureEvent": "Loud-Listening Event", + "typeRespiratoryRate": "Respiratory rate", + "typeBodyMassIndex": "Body-mass index", + "typeLeanBodyMass": "Lean body mass", + "typeWalkingHeartRateAverage": "Walking heart rate (avg)", + "typeWalkingAsymmetry": "Walking asymmetry", + "typeWalkingDoubleSupport": "Walking double support", "glucoseContext": "Context", "glucoseContextFasting": "Fasting (≥8h without food)", "glucoseContextPostprandial": "Post-meal (2h after eating)", diff --git a/messages/es.json b/messages/es.json index 87624a0d0..1e5092520 100644 --- a/messages/es.json +++ b/messages/es.json @@ -337,6 +337,12 @@ "typeTimeInDaylight": "Tiempo a la luz del día", "typeWalkingSteadiness": "Estabilidad al caminar", "typeAudioExposureEvent": "Evento de escucha alta", + "typeRespiratoryRate": "Frecuencia respiratoria", + "typeBodyMassIndex": "Índice de masa corporal", + "typeLeanBodyMass": "Masa magra", + "typeWalkingHeartRateAverage": "Frecuencia cardíaca al caminar (media)", + "typeWalkingAsymmetry": "Asimetría al caminar", + "typeWalkingDoubleSupport": "Doble apoyo al caminar", "glucoseContext": "Contexto", "glucoseContextFasting": "En ayunas (≥8 h sin comer)", "glucoseContextPostprandial": "Posprandial (2 h tras comer)", diff --git a/messages/fr.json b/messages/fr.json index d910b843f..7e3d58c54 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -337,6 +337,12 @@ "typeTimeInDaylight": "Temps à la lumière du jour", "typeWalkingSteadiness": "Stabilité à la marche", "typeAudioExposureEvent": "Événement d’écoute forte", + "typeRespiratoryRate": "Fréquence respiratoire", + "typeBodyMassIndex": "Indice de masse corporelle", + "typeLeanBodyMass": "Masse maigre", + "typeWalkingHeartRateAverage": "Fréquence cardiaque à la marche (moy.)", + "typeWalkingAsymmetry": "Asymétrie de la marche", + "typeWalkingDoubleSupport": "Double appui à la marche", "glucoseContext": "Contexte", "glucoseContextFasting": "À jeun (≥8 h sans repas)", "glucoseContextPostprandial": "Postprandial (2 h après le repas)", diff --git a/messages/it.json b/messages/it.json index fbc65df37..ca42fbf5c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -337,6 +337,12 @@ "typeTimeInDaylight": "Tempo alla luce del giorno", "typeWalkingSteadiness": "Stabilità nel cammino", "typeAudioExposureEvent": "Evento di ascolto forte", + "typeRespiratoryRate": "Frequenza respiratoria", + "typeBodyMassIndex": "Indice di massa corporea", + "typeLeanBodyMass": "Massa magra", + "typeWalkingHeartRateAverage": "Frequenza cardiaca a passo (media)", + "typeWalkingAsymmetry": "Asimmetria del cammino", + "typeWalkingDoubleSupport": "Doppio appoggio nel cammino", "glucoseContext": "Contesto", "glucoseContextFasting": "A digiuno (≥8 h senza cibo)", "glucoseContextPostprandial": "Postprandiale (2 h dopo il pasto)", diff --git a/messages/pl.json b/messages/pl.json index 4b6c3837c..9218da715 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -337,6 +337,12 @@ "typeTimeInDaylight": "Czas w świetle dziennym", "typeWalkingSteadiness": "Stabilność chodu", "typeAudioExposureEvent": "Zdarzenie głośnego słuchania", + "typeRespiratoryRate": "Częstość oddechów", + "typeBodyMassIndex": "Wskaźnik masy ciała", + "typeLeanBodyMass": "Masa beztłuszczowa", + "typeWalkingHeartRateAverage": "Tętno przy chodzeniu (śr.)", + "typeWalkingAsymmetry": "Asymetria chodu", + "typeWalkingDoubleSupport": "Podwójna podpora chodu", "glucoseContext": "Kontekst", "glucoseContextFasting": "Na czczo (≥8 godz. bez jedzenia)", "glucoseContextPostprandial": "Po posiłku (2 godz. po jedzeniu)", diff --git a/prisma/migrations/0083_v155_ios_measurement_types/migration.sql b/prisma/migrations/0083_v155_ios_measurement_types/migration.sql new file mode 100644 index 000000000..b4265888a --- /dev/null +++ b/prisma/migrations/0083_v155_ios_measurement_types/migration.sql @@ -0,0 +1,29 @@ +-- v1.5.5 — extend `measurement_type` enum for the six iOS-side +-- deferred identifiers. +-- +-- Each HK identifier in this set used to land in +-- `HK_QUANTITY_TYPE_DEFERRED` in `src/lib/measurements/apple-health-mapping.ts`. +-- The batch ingest returned HTTP 200 with a per-entry +-- `skipped:"unmappable_identifier"`; the iOS client read that as +-- success and advanced its sync anchor, so any sample carrying one +-- of these identifiers was lost forever. The mapping now wires them +-- end-to-end: +-- +-- RESPIRATORY_RATE ← HKQuantityTypeIdentifierRespiratoryRate +-- BODY_MASS_INDEX ← HKQuantityTypeIdentifierBodyMassIndex +-- LEAN_BODY_MASS ← HKQuantityTypeIdentifierLeanBodyMass +-- WALKING_HEART_RATE_AVERAGE ← HKQuantityTypeIdentifierWalkingHeartRateAverage +-- WALKING_ASYMMETRY ← HKQuantityTypeIdentifierWalkingAsymmetryPercentage +-- WALKING_DOUBLE_SUPPORT ← HKQuantityTypeIdentifierWalkingDoubleSupportPercentage +-- +-- Pure additive — no existing row carries any of these values. The +-- `IF NOT EXISTS` guard keeps the migration idempotent and lets the +-- runner replay it on a partially-applied environment without +-- failing on the duplicate-enum-value error code. + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'RESPIRATORY_RATE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BODY_MASS_INDEX'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'LEAN_BODY_MASS'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_HEART_RATE_AVERAGE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_ASYMMETRY'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_DOUBLE_SUPPORT'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2a19c7754..7eb28148a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -264,6 +264,33 @@ model User { // `useAuth()` hook. disableCoach Boolean @default(false) @map("disable_coach") + // v1.5.5 — self-hosted avatar storage. + // + // Replaces the Gravatar third-party leak (the old `getGravatarUrl` + // path hashed the email with SHA-256 and shipped the digest to + // www.gravatar.com on every authenticated page load, which let + // Automattic correlate the digest against its own table of known + // emails). The bytes live on the User row so the standard pg_dump + // backup path already covers them — no new filesystem volume to + // mount, no new Docker compose change. + // + // `avatarBytes` carries the raw image (JPEG / PNG / WebP), hard- + // capped at 2 MiB by the upload route and at 2048×2048 by the + // post-decode dimension check. `avatarContentType` carries the + // sniffed MIME (`image/jpeg`, `image/png`, `image/webp`); the + // value is never trusted from the multipart header — it is + // resolved from the file's magic bytes. `avatarUpdatedAt` is the + // cache-bust anchor: the /api/auth/me profile response appends + // the unix-ms suffix to the avatar URL so a re-upload paints + // fresh client-side without an HTTP cache flush. + // + // Null on all three columns = the user has not uploaded an + // avatar and the profile response returns `avatarUrl: null`. The + // client falls back to the username initials. + avatarBytes Bytes? @map("avatar_bytes") + avatarContentType String? @map("avatar_content_type") + avatarUpdatedAt DateTime? @map("avatar_updated_at") + // Relations passkeys Passkey[] sessions Session[] @@ -443,6 +470,17 @@ enum MeasurementType { // ── v1.4.30 R-F T1.4 + T1.5 Tier-1 additions ── WALKING_STEADINESS // percent (0-100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `appleWalkingSteadiness` (iOS 15+). Daily rollup. Falls + mobility-risk signal Apple's own Health app shows prominently in the Mobility section. AUDIO_EXPOSURE_EVENT // count (event flag: 1 per fired loud-listening event) — HealthKit `environmentalAudioExposureEvent` + `headphoneAudioExposureEvent` category-events. Apple fires these when the rolling 7-day average crosses the WHO 80-dBA threshold. The notes field carries the source identifier (env vs headphone). + // ── v1.5.5 iOS-coord additions — six identifiers the deferred list refused to map. ── + // Each one was emitted by the iOS app, dropped server-side with a + // per-entry `skipped:"unmappable_identifier"`, and ack-200'd to the + // client. iOS read 200 as success, advanced its sync anchor, and the + // samples were lost forever. Now mapped end-to-end. + RESPIRATORY_RATE // count/min — HealthKit `respiratoryRate`. Watch + iPhone sleep + workout sampling. + BODY_MASS_INDEX // kg/m² — HealthKit `bodyMassIndex`. iOS computes it from weight + height before upload; we still store it as a first-class metric for trend display. + LEAN_BODY_MASS // kg — HealthKit `leanBodyMass`. Body-composition counterpart to FAT_MASS; pairs with WEIGHT − FAT_MASS for sanity checks. + WALKING_HEART_RATE_AVERAGE // bpm — HealthKit `walkingHeartRateAverage`. Daily rollup; cardio-fitness signal distinct from RESTING_HEART_RATE and spot PULSE. + WALKING_ASYMMETRY // percent (0-100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `walkingAsymmetryPercentage`. Mobility section sibling to WALKING_STEADINESS. + WALKING_DOUBLE_SUPPORT // percent (0-100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `walkingDoubleSupportPercentage`. Mobility section sibling. @@map("measurement_type") } diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index c26b364c0..3d5ea97cf 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -71,6 +71,13 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { // ── v1.4.30 R-F T1.4 + T1.5 ── WALKING_STEADINESS: "measurements.typeWalkingSteadiness", AUDIO_EXPOSURE_EVENT: "measurements.typeAudioExposureEvent", + // ── v1.5.5 iOS-coord additions ── + RESPIRATORY_RATE: "measurements.typeRespiratoryRate", + BODY_MASS_INDEX: "measurements.typeBodyMassIndex", + LEAN_BODY_MASS: "measurements.typeLeanBodyMass", + WALKING_HEART_RATE_AVERAGE: "measurements.typeWalkingHeartRateAverage", + WALKING_ASYMMETRY: "measurements.typeWalkingAsymmetry", + WALKING_DOUBLE_SUPPORT: "measurements.typeWalkingDoubleSupport", }; export const MEASUREMENT_TYPE_ICONS: Record = { @@ -119,6 +126,16 @@ export const MEASUREMENT_TYPE_ICONS: Record = { // event flag — the event is a louder-cousin of the env quantity. WALKING_STEADINESS: Gauge, AUDIO_EXPOSURE_EVENT: Volume2, + // ── v1.5.5 iOS-coord additions ── + // Wind already carries the breathing family (SpO2 uses it); + // Scale carries body-comp; HeartPulse rounds out the cardio + // pair; Footprints + Gauge live in the gait family. + RESPIRATORY_RATE: Wind, + BODY_MASS_INDEX: Scale, + LEAN_BODY_MASS: Scale, + WALKING_HEART_RATE_AVERAGE: HeartPulse, + WALKING_ASYMMETRY: Footprints, + WALKING_DOUBLE_SUPPORT: Footprints, }; export const MEASUREMENT_TYPE_COLORS: Record = { @@ -161,4 +178,13 @@ export const MEASUREMENT_TYPE_COLORS: Record = { // ── v1.4.30 R-F T1.4 + T1.5 ── WALKING_STEADINESS: "bg-chart-2/20 text-chart-2", AUDIO_EXPOSURE_EVENT: "bg-chart-5/20 text-chart-5", + // ── v1.5.5 iOS-coord additions ── + // Reuse the existing palette families: chart-5 (cardio/pulse), + // chart-1 (mass), chart-2 (activity/gait). + RESPIRATORY_RATE: "bg-chart-5/20 text-chart-5", + BODY_MASS_INDEX: "bg-chart-1/20 text-chart-1", + LEAN_BODY_MASS: "bg-chart-1/20 text-chart-1", + WALKING_HEART_RATE_AVERAGE: "bg-chart-3/20 text-chart-3", + WALKING_ASYMMETRY: "bg-chart-2/20 text-chart-2", + WALKING_DOUBLE_SUPPORT: "bg-chart-2/20 text-chart-2", }; diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index 14cfee0a6..533a87eed 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -49,10 +49,17 @@ const EXPECTED_TYPES = [ // ── v1.4.30 R-F T1.4 + T1.5 ── "WALKING_STEADINESS", "AUDIO_EXPOSURE_EVENT", + // ── v1.5.5 iOS-coord — six previously-deferred HK identifiers wired ── + "RESPIRATORY_RATE", + "BODY_MASS_INDEX", + "LEAN_BODY_MASS", + "WALKING_HEART_RATE_AVERAGE", + "WALKING_ASYMMETRY", + "WALKING_DOUBLE_SUPPORT", ] as const; describe("measurementTypeEnum coverage", () => { - it("exposes the 30 canonical measurement types", () => { + it("exposes the 36 canonical measurement types", () => { expect([...measurementTypeEnum.options].sort()).toEqual( [...EXPECTED_TYPES].sort(), ); @@ -105,6 +112,17 @@ describe("measurementTypeEnum coverage", () => { // in the clinical PDF until the iOS-app sync lands. "WALKING_STEADINESS", "AUDIO_EXPOSURE_EVENT", + // v1.5.5 iOS-coord additions held under the same v1.5 PDF gate. + // Respiratory rate, BMI, lean body mass, walking HR average, and + // the two gait-percent metrics ride the same clinical-layout + // deferral as the other v1.4.23+ additions; the doctor PDF picks + // them up once the layout and reference ranges land. + "RESPIRATORY_RATE", + "BODY_MASS_INDEX", + "LEAN_BODY_MASS", + "WALKING_HEART_RATE_AVERAGE", + "WALKING_ASYMMETRY", + "WALKING_DOUBLE_SUPPORT", ]); it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => { diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index 00e14167e..7af7d26be 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -65,6 +65,15 @@ export const ALLOWED_CHART_TOKENS = [ // alongside the iOS-app sync. "metric:WALKING_STEADINESS", "metric:AUDIO_EXPOSURE_EVENT", + // v1.5.5 — iOS-coord additions. Same posture: the LLM can + // reference these in prose; the chart components ride the + // existing card-shelf renderer. + "metric:RESPIRATORY_RATE", + "metric:BODY_MASS_INDEX", + "metric:LEAN_BODY_MASS", + "metric:WALKING_HEART_RATE_AVERAGE", + "metric:WALKING_ASYMMETRY", + "metric:WALKING_DOUBLE_SUPPORT", ] as const; export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number]; @@ -142,6 +151,13 @@ const ORPHAN_ENUMS = [ "AUDIO_EXPOSURE_ENV", "AUDIO_EXPOSURE_HEADPHONE", "TIME_IN_DAYLIGHT", + // v1.5.5 iOS-coord additions — same shape, same posture. + "RESPIRATORY_RATE", + "BODY_MASS_INDEX", + "LEAN_BODY_MASS", + "WALKING_HEART_RATE_AVERAGE", + "WALKING_ASYMMETRY", + "WALKING_DOUBLE_SUPPORT", ] as const; // `\b` boundaries keep ordinary English prose untouched — "weight" diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 9bf17f521..d62d9a82d 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -346,6 +346,117 @@ describe("mapAppleHealthEntry", () => { }); }); +describe("v1.5.5 iOS-coord additions — six previously-deferred identifiers", () => { + it("maps every one of the six identifiers", () => { + const expected: Array<[string, string]> = [ + ["HKQuantityTypeIdentifierRespiratoryRate", "RESPIRATORY_RATE"], + ["HKQuantityTypeIdentifierBodyMassIndex", "BODY_MASS_INDEX"], + ["HKQuantityTypeIdentifierLeanBodyMass", "LEAN_BODY_MASS"], + [ + "HKQuantityTypeIdentifierWalkingHeartRateAverage", + "WALKING_HEART_RATE_AVERAGE", + ], + [ + "HKQuantityTypeIdentifierWalkingAsymmetryPercentage", + "WALKING_ASYMMETRY", + ], + [ + "HKQuantityTypeIdentifierWalkingDoubleSupportPercentage", + "WALKING_DOUBLE_SUPPORT", + ], + ]; + for (const [hkId, type] of expected) { + const mapping = APPLE_HEALTH_TYPE_MAP[hkId]; + expect(mapping, `${hkId} should be mapped`).toBeDefined(); + expect(mapping.measurementType).toBe(type); + } + }); + + it("scales the gait percent identifiers ×100 server-side (project convention)", () => { + const asym = + APPLE_HEALTH_TYPE_MAP + .HKQuantityTypeIdentifierWalkingAsymmetryPercentage; + const ds = + APPLE_HEALTH_TYPE_MAP + .HKQuantityTypeIdentifierWalkingDoubleSupportPercentage; + // Apple ships these as 0..1 fractions; HealthLog stores 0..100. + expect(asym.convertToDbUnit(0.07)).toBeCloseTo(7); + expect(ds.convertToDbUnit(0.2)).toBeCloseTo(20); + expect(asym.dbUnit).toBe("%"); + expect(ds.dbUnit).toBe("%"); + }); + + it("keeps identity conversion for the non-percent additions", () => { + expect( + APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierRespiratoryRate.convertToDbUnit( + 16, + ), + ).toBe(16); + expect( + APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierBodyMassIndex.convertToDbUnit( + 24.5, + ), + ).toBe(24.5); + expect( + APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierLeanBodyMass.convertToDbUnit( + 62, + ), + ).toBe(62); + expect( + APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierWalkingHeartRateAverage.convertToDbUnit( + 96, + ), + ).toBe(96); + }); + + it("removes the six identifiers from the deferred set", () => { + const previouslyDeferred = [ + "HKQuantityTypeIdentifierRespiratoryRate", + "HKQuantityTypeIdentifierBodyMassIndex", + "HKQuantityTypeIdentifierLeanBodyMass", + "HKQuantityTypeIdentifierWalkingHeartRateAverage", + "HKQuantityTypeIdentifierWalkingAsymmetryPercentage", + "HKQuantityTypeIdentifierWalkingDoubleSupportPercentage", + ]; + for (const id of previouslyDeferred) { + expect( + HK_QUANTITY_TYPE_DEFERRED.has(id), + `${id} should no longer be deferred`, + ).toBe(false); + } + }); + + it("round-trips a respiratory-rate sample through mapAppleHealthEntry", () => { + const out = mapAppleHealthEntry({ + hkIdentifier: "HKQuantityTypeIdentifierRespiratoryRate", + value: 16, + unit: "count/min", + startDate: "2026-05-28T03:00:00.000Z", + endDate: "2026-05-28T07:00:00.000Z", + }); + expect(out).toEqual({ + type: "RESPIRATORY_RATE", + value: 16, + unit: "breaths/min", + takenAt: new Date("2026-05-28T07:00:00.000Z"), + }); + }); + + it("round-trips a walking-asymmetry sample with ×100 scaling", () => { + const out = mapAppleHealthEntry({ + hkIdentifier: "HKQuantityTypeIdentifierWalkingAsymmetryPercentage", + value: 0.07, + unit: "%", + startDate: "2026-05-28T08:00:00.000Z", + endDate: "2026-05-28T08:00:00.000Z", + }); + expect(out).not.toBeNull(); + expect(out!.type).toBe("WALKING_ASYMMETRY"); + expect(out!.unit).toBe("%"); + expect(out!.value).toBeCloseTo(7); + }); +}); + describe("v1.4.30 Tier-1 additions (R-F T1.4 + T1.5)", () => { it("maps appleWalkingSteadiness to WALKING_STEADINESS with × 100 scaling", () => { const mapping = diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index 2aa0a160f..874f04552 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -95,6 +95,34 @@ export const APPLE_HEALTH_SLEEP_STAGE_MAP: Record = { * fraction; HealthLog stores percent (0..100). * - `HKQuantityTypeIdentifierBodyFatPercentage` ships as a 0..1 * fraction; HealthLog stores percent (0..100). + * + * ── Project convention: server-side scaling is canonical ───────────── + * + * Every HealthKit value travels over the wire as the raw HK reading. + * Whatever ×100 / unit-bend / clamp the canonical DB shape needs, the + * server applies it at ingest inside `convertToDbUnit`. Two reasons: + * + * 1. The wire contract is the HK contract — iOS clients (and any + * future Health Connect / Garmin / Fitbit bridge) emit the + * native sensor reading without per-platform pre-massaging. + * 2. The conversion lives next to the canonical DB unit + the + * plausibility-range guard, so a future contributor adding a + * new identifier touches one file instead of three. + * + * The pre-existing precedents already follow the convention: + * + * - `HKQuantityTypeIdentifierOxygenSaturation` (0..1 → 0..100) + * - `HKQuantityTypeIdentifierBodyFatPercentage` (0..1 → 0..100) + * - `HKQuantityTypeIdentifierAppleWalkingSteadiness` (0..1 → 0..100) + * + * The v1.5.5 gait additions (`walkingAsymmetryPercentage` + + * `walkingDoubleSupportPercentage`) extend the precedent. Older iOS + * releases pre-multiplied those two identifiers by ×100 before + * upload (a footgun the audit team flagged); the iOS client is on + * track to drop the pre-multiplication so every HK percent flows + * through the same canonical server-side scaling path. The coord + * note in `.planning/ios-coord/` documents the one-release shim and + * the migration window. */ export const APPLE_HEALTH_TYPE_MAP: Record = { // ── Body composition ──────────────────────────────────────── @@ -322,6 +350,83 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { convertToDbUnit: () => 1, aggregation: "sum", }, + + // ── v1.5.5 iOS-coord — six previously-deferred identifiers ─── + // Background: each entry below sat in `HK_QUANTITY_TYPE_DEFERRED`. + // `mapAppleHealthEntry()` returned null; the batch route emitted + // 200 with a per-entry `skipped:"unmappable_identifier"`; the iOS + // app read 200 as success and advanced its sync anchor. Result: + // every sample carrying one of these identifiers was lost + // forever, no retry path. Wired through end-to-end now. + + // Respiratory rate — count-per-minute breaths. Watch + iPhone + // sample this during sleep + workouts. Mean aggregation matches + // Apple's own Health-app display (resting RR averaged over the + // sleep window). + HKQuantityTypeIdentifierRespiratoryRate: { + hkIdentifier: "HKQuantityTypeIdentifierRespiratoryRate", + measurementType: "RESPIRATORY_RATE", + hkUnit: "count/min", + dbUnit: "breaths/min", + convertToDbUnit: (v) => v, + aggregation: "mean", + }, + // BMI — iOS computes it from weight + height before upload. We + // still want a first-class metric for trend display so the iOS + // chart can read a single series instead of recomputing per + // datapoint. Unit-less ratio on the wire; canonical label is + // `kg/m²` to match clinical convention. + HKQuantityTypeIdentifierBodyMassIndex: { + hkIdentifier: "HKQuantityTypeIdentifierBodyMassIndex", + measurementType: "BODY_MASS_INDEX", + hkUnit: "count", + dbUnit: "kg/m²", + convertToDbUnit: (v) => v, + aggregation: "latest", + }, + // Lean body mass — body-composition counterpart to FAT_MASS. + // Apple ships this in kg; canonical DB unit is kg too. + HKQuantityTypeIdentifierLeanBodyMass: { + hkIdentifier: "HKQuantityTypeIdentifierLeanBodyMass", + measurementType: "LEAN_BODY_MASS", + hkUnit: "kg", + dbUnit: "kg", + convertToDbUnit: (v) => v, + aggregation: "latest", + }, + // Walking heart-rate average — distinct from RESTING_HEART_RATE + // (sleep-window minimum) and spot PULSE. Daily rollup. + HKQuantityTypeIdentifierWalkingHeartRateAverage: { + hkIdentifier: "HKQuantityTypeIdentifierWalkingHeartRateAverage", + measurementType: "WALKING_HEART_RATE_AVERAGE", + hkUnit: "count/min", + dbUnit: "bpm", + convertToDbUnit: (v) => v, + aggregation: "mean", + }, + // Walking asymmetry — Apple ships as a 0..1 fraction; HealthLog + // stores 0..100 (same convention as walking steadiness, body fat, + // oxygen saturation). See the "server-side scaling is canonical" + // block above for the rationale. The iOS client's previous + // pre-upload ×100 multiplication is the documented migration item. + HKQuantityTypeIdentifierWalkingAsymmetryPercentage: { + hkIdentifier: "HKQuantityTypeIdentifierWalkingAsymmetryPercentage", + measurementType: "WALKING_ASYMMETRY", + hkUnit: "%", + dbUnit: "%", + convertToDbUnit: (v) => v * 100, + aggregation: "latest", + }, + // Walking double-support percentage — gait companion metric. + // Same ×100 server-side scaling convention. + HKQuantityTypeIdentifierWalkingDoubleSupportPercentage: { + hkIdentifier: "HKQuantityTypeIdentifierWalkingDoubleSupportPercentage", + measurementType: "WALKING_DOUBLE_SUPPORT", + hkUnit: "%", + dbUnit: "%", + convertToDbUnit: (v) => v * 100, + aggregation: "latest", + }, }; /** @@ -417,12 +522,13 @@ export function hkIdentifierForType( * `__tests__/apple-health-mapping.test.ts` flags double-bookings. */ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ - // Body composition / vitals — v1.5 - "HKQuantityTypeIdentifierBodyMassIndex", // computed from weight + height — never stored + // Body composition / vitals + // v1.5.5 — `BodyMassIndex`, `LeanBodyMass`, `RespiratoryRate`, + // `WalkingHeartRateAverage`, `WalkingAsymmetryPercentage`, + // `WalkingDoubleSupportPercentage` moved into the mapping table. + // The remaining identifiers below stay deferred until a sibling + // MeasurementType lands. "HKQuantityTypeIdentifierHeight", // already on User.heightCm - "HKQuantityTypeIdentifierLeanBodyMass", - "HKQuantityTypeIdentifierRespiratoryRate", - "HKQuantityTypeIdentifierWalkingHeartRateAverage", "HKQuantityTypeIdentifierHeartRateRecoveryOneMinute", "HKQuantityTypeIdentifierAppleSleepingWristTemperature", "HKQuantityTypeIdentifierBasalEnergyBurned", @@ -432,8 +538,6 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ // Running / walking form — v1.5+ "HKQuantityTypeIdentifierWalkingSpeed", "HKQuantityTypeIdentifierWalkingStepLength", - "HKQuantityTypeIdentifierWalkingAsymmetryPercentage", - "HKQuantityTypeIdentifierWalkingDoubleSupportPercentage", "HKQuantityTypeIdentifierStairAscentSpeed", "HKQuantityTypeIdentifierStairDescentSpeed", "HKQuantityTypeIdentifierSixMinuteWalkTestDistance", diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index fc1876242..73aa8c410 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -64,6 +64,9 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< ["PULSE", "vitals"], ["OXYGEN_SATURATION", "vitals"], ["BODY_TEMPERATURE", "vitals"], + // v1.5.5 — respiratory rate sits alongside SpO2 + body temp as a + // point-in-time clinical vital. + ["RESPIRATORY_RATE", "vitals"], // ── Body composition ── ["WEIGHT", "body"], @@ -74,6 +77,10 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< ["TOTAL_BODY_WATER", "body"], ["BONE_MASS", "body"], ["VISCERAL_FAT", "body"], + // v1.5.5 — BMI + lean body mass round out the body-composition + // surface (FAT_MASS already covers the fat side). + ["BODY_MASS_INDEX", "body"], + ["LEAN_BODY_MASS", "body"], // ── Activity (cumulative + fitness + mobility) ── ["ACTIVITY_STEPS", "activity"], @@ -82,6 +89,11 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< ["WALKING_RUNNING_DISTANCE", "activity"], ["VO2_MAX", "activity"], ["WALKING_STEADINESS", "activity"], + // v1.5.5 — gait + walking-HR-average sit alongside walking + // steadiness as Apple-Health Mobility-section signals. + ["WALKING_ASYMMETRY", "activity"], + ["WALKING_DOUBLE_SUPPORT", "activity"], + ["WALKING_HEART_RATE_AVERAGE", "activity"], // ── Sleep ── ["SLEEP_DURATION", "sleep"], diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 4e3385243..a40085b73 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -59,6 +59,9 @@ export function getPRDirection( // Mobility section treats higher as the achievement (the // recovery direction from a low-steadiness window). case "WALKING_STEADINESS": + // v1.5.5 — lean body mass is the muscle-mass-adjacent body-comp + // axis where higher is the goal. + case "LEAN_BODY_MASS": return PersonalRecordDirection.MAX; // MIN direction — lower value is the record. @@ -70,6 +73,11 @@ export function getPRDirection( case "PULSE_WAVE_VELOCITY": case "AUDIO_EXPOSURE_ENV": case "AUDIO_EXPOSURE_HEADPHONE": + // v1.5.5 — gait asymmetry + double-support percentages: Apple's + // Mobility section frames lower as the achievement (more + // symmetric gait + shorter double-support fraction = healthier). + case "WALKING_ASYMMETRY": + case "WALKING_DOUBLE_SUPPORT": return PersonalRecordDirection.MIN; // Explicitly no PersonalRecord — see comment block above. @@ -88,6 +96,14 @@ export function getPRDirection( // a row" is interesting but better surfaced as a streak than a // PR direction. Defer. case "AUDIO_EXPOSURE_EVENT": + // v1.5.5 — homeostatic / display-only metrics. Respiratory rate + // is goal-neutral (homeostatic), walking HR average is fitness + // dependent without a clean direction (a higher value can be + // either ill or harder-effort), and BMI direction is user-goal + // dependent — defer until the worker can read User.thresholdsJson. + case "RESPIRATORY_RATE": + case "WALKING_HEART_RATE_AVERAGE": + case "BODY_MASS_INDEX": return null; } } diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 28956f222..3a3b447a9 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -35,6 +35,13 @@ export const measurementTypeEnum = z.enum([ // ── v1.4.30 R-F T1.4 + T1.5 ── "WALKING_STEADINESS", "AUDIO_EXPOSURE_EVENT", + // ── v1.5.5 iOS-coord — six previously-deferred HK identifiers wired end-to-end ── + "RESPIRATORY_RATE", + "BODY_MASS_INDEX", + "LEAN_BODY_MASS", + "WALKING_HEART_RATE_AVERAGE", + "WALKING_ASYMMETRY", + "WALKING_DOUBLE_SUPPORT", ]); export const glucoseContextEnum = z.enum([ @@ -97,6 +104,23 @@ const unitMap: Record = { // 0–1440 covers the 24-hour day; in practice indoor users sit near 0 // and outdoor athletes accumulate a few hours. TIME_IN_DAYLIGHT: "minutes", + // ── v1.5.5 iOS-coord additions ── + // Respiratory rate breaths-per-minute — the HK identifier ships as + // `count/min`; we keep the more conventional clinical label. + RESPIRATORY_RATE: "breaths/min", + // BMI kg/m² — HealthKit ships the unitless ratio; the canonical + // display string mirrors clinical convention. + BODY_MASS_INDEX: "kg/m²", + // Lean body mass kg — body-composition partner to FAT_MASS. + LEAN_BODY_MASS: "kg", + // Walking heart rate average bpm — daily rollup; distinct from + // RESTING_HEART_RATE (sleep-window minimum) and spot PULSE. + WALKING_HEART_RATE_AVERAGE: "bpm", + // Walking gait percent (0-100 after server-side ×100 scaling). + // Same convention as WALKING_STEADINESS / BODY_FAT / OXYGEN_SATURATION + // — see the project convention block in `apple-health-mapping.ts`. + WALKING_ASYMMETRY: "%", + WALKING_DOUBLE_SUPPORT: "%", }; export function getUnitForType(type: string): string { @@ -176,6 +200,24 @@ const VALUE_RANGES: Record = { // Time in daylight (minutes/day) — full 24-hour window. Outdoor work // tops the range; sedentary indoor days sit near 0. TIME_IN_DAYLIGHT: { min: 0, max: 1440 }, + // ── v1.5.5 iOS-coord additions ── + // Respiratory rate breaths/min — adult range ~12-20 at rest; severe + // distress can spike past 40; bradypnea floor ~4. Generous bounds. + RESPIRATORY_RATE: { min: 3, max: 60 }, + // BMI kg/m² — sub-12 is starvation-class, 70 covers extreme obesity. + // Outside the band is almost certainly a height-or-weight typo. + BODY_MASS_INDEX: { min: 8, max: 70 }, + // Lean body mass kg — adult plausibility, ~25 kg (small adult) to + // 150 kg (extreme lean athlete). Same shape as FAT_FREE_MASS. + LEAN_BODY_MASS: { min: 10, max: 250 }, + // Walking heart rate average bpm — endurance athletes can sit in + // the high 70s while walking; ailing or post-stimulant readings can + // touch 200. Same upper bound as the spot PULSE / RESTING_HEART_RATE. + WALKING_HEART_RATE_AVERAGE: { min: 30, max: 220 }, + // Gait percent (0-100). Apple ships these as 0..1 fractions; after + // the ×100 server-side scaling the canonical band is 0..100. + WALKING_ASYMMETRY: { min: 0, max: 100 }, + WALKING_DOUBLE_SUPPORT: { min: 0, max: 100 }, }; export function validateMeasurementRange(