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", },