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
This commit is contained in:
Marc-André Bombeck committed 2026-09-18 16:40:11 +02:00
1 parent 244d0e4b69
commit 9d9e8570f7
3 files changed
+97 -9

No files matched your search

+1 -1
View File
@@ -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).
@@ -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);
});
});
+27 -7
View File
@@ -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<string, AppleHealthMapping> = {
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<string, AppleHealthMapping> = {
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<string, AppleHealthMapping> = {
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<string, AppleHealthMapping> = {
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<string, AppleHealthMapping> = {
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<string, AppleHealthMapping> = {
hkUnit: "%",
unitFixedReason: UNIT_FIXED_PERCENT_FRACTION,
dbUnit: "%",
convertToDbUnit: (v) => v * 100,
convertToDbUnit: percentFromFraction,
aggregation: "latest",
},