From 9d9e8570f77a4f0a02bb462e815530f8ff7fa28a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 18 Sep 2026 16:40:11 +0200 Subject: [PATCH] fix(apple-health): take a percent from a fraction or an already-scaled client Shipped iOS builds multiply oxygen saturation and body fat by 100 before upload. The importer scaled them again, so 0.97 was stored as 9700, the plausibility guard rejected the row as out of range inside an HTTP 200, and the client treats that skip as final and moves its HealthKit anchor past the sample. Every reading of those two types was lost, silently, on every install that syncs them. The contract says values travel raw and the server scales, and that is what the OpenAPI description publishes. One comment in the schema said the opposite, that the client must multiply by 100 first, and that is the line the app was built against. It is corrected here. The five percent-shaped HealthKit identifiers now take either spelling: a value above the fraction ceiling is already a percent and passes through, anything at or below it is scaled. The domains do not overlap, and the per-type range guard still rejects what is impossible either way. Current app builds stop losing readings without an app release. The tests pin both spellings, the range after conversion, and that the tolerant converter covers the percent class and nothing else: waist circumference uses the same factor to turn metres into centimetres, and a blanket change made a 1.02 m waist into 1.02 cm. Refs MBombeck/healthlog-iOS#7 --- prisma/schema.prisma | 2 +- .../__tests__/apple-health-mapping.test.ts | 70 ++++++++++++++++++- src/lib/measurements/apple-health-mapping.ts | 34 +++++++-- 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 09d54ef02..92ae22ddd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1374,7 +1374,7 @@ enum MeasurementType { BLOOD_GLUCOSE // Canonical storage: mg/dL. Display unit (mg/dL or mmol/L) is user preference. TOTAL_BODY_WATER // Canonical storage: kilograms (kg of water). Matches Withings' hydration measure (type 77) and Health Connect's TotalBodyWaterRecord. Display can compute % from same-day weight if desired. BONE_MASS // Canonical storage: kilograms (kg). Matches Withings' bone mass measure (type 88). - OXYGEN_SATURATION // Canonical storage: percent (0..100). Withings ScanWatch reports type 54. Health Connect's OxygenSaturationRecord uses Percentage (already 0..100). HealthKit uses HKQuantityTypeIdentifierOxygenSaturation as a fraction (0..1) — iOS DTO must multiply by 100 before submitting. + OXYGEN_SATURATION // Canonical storage: percent (0..100). Withings ScanWatch reports type 54. Health Connect's OxygenSaturationRecord uses Percentage (already 0..100). HealthKit uses HKQuantityTypeIdentifierOxygenSaturation as a fraction (0..1); clients send the raw HealthKit reading and the server scales it at ingest (see `apple-health-mapping.ts`). This line used to say the opposite, and the iOS client built against it, which cost every user their oxygen-saturation and body-fat readings until the importer learned to take both. // ── v1.4.23 Apple Health additions ── HEART_RATE_VARIABILITY // ms — HealthKit `heartRateVariabilitySDNN` (typically one sample per night). RESTING_HEART_RATE // bpm — HealthKit `restingHeartRate` (one sample per day; distinct from spot PULSE). diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 3dd28a7e9..61892652c 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -9,7 +9,10 @@ import { hkIdentifierForType, mapAppleHealthEntry, } from "../apple-health-mapping"; -import { measurementTypeEnum } from "@/lib/validations/measurement"; +import { + measurementTypeEnum, + validateMeasurementRange, +} from "@/lib/validations/measurement"; import type { MeasurementType } from "@/generated/prisma/client"; /** @@ -1013,3 +1016,68 @@ describe("v1.10.0 categorical events (WX-B)", () => { expect(out!.rhythmClassification).toBeUndefined(); }); }); + +describe("percent-shaped HealthKit types take a fraction or an already-scaled percent", () => { + const PERCENT_TYPES = [ + "HKQuantityTypeIdentifierBodyFatPercentage", + "HKQuantityTypeIdentifierOxygenSaturation", + "HKQuantityTypeIdentifierAppleWalkingSteadiness", + "HKQuantityTypeIdentifierWalkingAsymmetryPercentage", + "HKQuantityTypeIdentifierWalkingDoubleSupportPercentage", + ] as const; + + it("scales a raw HealthKit fraction, the documented wire form", () => { + for (const id of PERCENT_TYPES) { + const mapping = APPLE_HEALTH_TYPE_MAP[id]; + expect(mapping.convertToDbUnit(0.97), id).toBeCloseTo(97); + expect(mapping.convertToDbUnit(0.25), id).toBeCloseTo(25); + } + }); + + it("passes a value a client already scaled straight through", () => { + // Shipped iOS builds multiply oxygen saturation and body fat by 100 + // before upload. Scaling again put the row outside its plausibility + // range, the batch route skipped it, and the client advanced its anchor + // past a reading it then never read again. + for (const id of PERCENT_TYPES) { + const mapping = APPLE_HEALTH_TYPE_MAP[id]; + expect(mapping.convertToDbUnit(97), id).toBeCloseTo(97); + expect(mapping.convertToDbUnit(25), id).toBeCloseTo(25); + } + }); + + it("keeps a stored oxygen saturation inside its plausibility range either way", () => { + const mapping = + APPLE_HEALTH_TYPE_MAP["HKQuantityTypeIdentifierOxygenSaturation"]; + for (const wire of [0.97, 97]) { + expect( + validateMeasurementRange( + "OXYGEN_SATURATION", + mapping.convertToDbUnit(wire), + ), + ).toBeNull(); + } + }); + + it("covers every mapping that declares the percent-fraction unit reason, and only those", () => { + // The x100 in this file is not one thing: waist circumference converts + // metres to centimetres with the same factor. A blanket change to the + // tolerant converter turned a 1.02 m waist into 1.02 cm, which is how + // this test came to exist. + for (const [id, mapping] of Object.entries(APPLE_HEALTH_TYPE_MAP)) { + const isPercentClass = PERCENT_TYPES.includes( + id as (typeof PERCENT_TYPES)[number], + ); + const tolerant = + mapping.convertToDbUnit(2) === 2 && mapping.convertToDbUnit(0.5) === 50; + expect(tolerant, id).toBe(isPercentClass); + } + }); + + it("still converts a waist circumference from metres to centimetres", () => { + const mapping = + APPLE_HEALTH_TYPE_MAP["HKQuantityTypeIdentifierWaistCircumference"]; + expect(mapping.convertToDbUnit(0.86)).toBeCloseTo(86); + expect(mapping.convertToDbUnit(1.02)).toBeCloseTo(102); + }); +}); diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index f090a7065..b368c805a 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -218,7 +218,27 @@ const UNIT_FIXED_DIMENSIONLESS_COUNT = const UNIT_FIXED_PER_MINUTE = "Per-minute rate: Apple emits `count/min` in every locale and the Health app offers no alternative display unit, so there is nothing to convert from."; const UNIT_FIXED_PERCENT_FRACTION = - "Apple writes the percent unit but the VALUE rides as a 0..1 fraction; the x100 in `convertToDbUnit` already covers both spellings, so branching on the record unit would double-scale it."; + "Apple writes the percent unit but the VALUE rides as a 0..1 fraction; `percentFromFraction` in `convertToDbUnit` already covers both spellings, so branching on the record unit would double-scale it."; + +/** + * A HealthKit percent, from a fraction or from a client that already scaled. + * + * The convention is that HK values travel raw and the server scales. Shipped + * iOS builds break it for two identifiers: they multiply oxygen saturation and + * body fat by 100 before upload, so the server scaled a second time, the value + * left the plausibility range, the batch route skipped the row, and the client + * treated that skip as final and advanced its anchor. Every reading of those + * two types was lost, silently, on every install. + * + * The two domains do not overlap: a fraction is at most 1, and one percent + * oxygen saturation or body fat does not occur in a living person. So a value + * above 1 is already a percent and passes through, and anything at or below 1 + * is a fraction and gets scaled. A wrong reading still meets the per-type + * range guard afterwards, which is what rejects the impossible either way. + */ +function percentFromFraction(value: number): number { + return value > 1 ? value : value * 100; +} const UNIT_FIXED_EVENT_PIN = "Categorical event: `convertToDbUnit` pins the stored value to 1 fired event regardless of the reading, so no unit attribute can move it."; const UNIT_FIXED_AUDIO_SPL = @@ -245,7 +265,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { unitFixedReason: UNIT_FIXED_PERCENT_FRACTION, dbUnit: "%", // Apple ships 0..1 fraction; HealthLog stores 0..100. - convertToDbUnit: (v) => v * 100, + convertToDbUnit: percentFromFraction, aggregation: "latest", }, HKQuantityTypeIdentifierBodyTemperature: { @@ -269,7 +289,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkUnit: "m", dbUnit: "cm", // Apple ships length in metres; HealthLog stores waist in cm. - convertToDbUnit: (v) => v * 100, + convertToDbUnit: (v) => v * 100, // metres to centimetres, NOT a percent aggregation: "latest", }, @@ -385,7 +405,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { unitFixedReason: UNIT_FIXED_PERCENT_FRACTION, dbUnit: "%", // Apple ships 0..1 fraction; HealthLog stores 0..100. - convertToDbUnit: (v) => v * 100, + convertToDbUnit: percentFromFraction, aggregation: "latest", }, @@ -455,7 +475,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { unitFixedReason: UNIT_FIXED_PERCENT_FRACTION, dbUnit: "%", // Apple ships 0..1 fraction; HealthLog stores 0..100. - convertToDbUnit: (v) => v * 100, + convertToDbUnit: percentFromFraction, aggregation: "latest", }, // Environmental audio-exposure event — iOS 13+ category-type that @@ -632,7 +652,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkUnit: "%", unitFixedReason: UNIT_FIXED_PERCENT_FRACTION, dbUnit: "%", - convertToDbUnit: (v) => v * 100, + convertToDbUnit: percentFromFraction, aggregation: "latest", }, // Walking double-support percentage — gait companion metric. @@ -643,7 +663,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkUnit: "%", unitFixedReason: UNIT_FIXED_PERCENT_FRACTION, dbUnit: "%", - convertToDbUnit: (v) => v * 100, + convertToDbUnit: percentFromFraction, aggregation: "latest", },