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(