Merge pull request #1000 from MBombeck/fix/hk-percent-tolerance

fix(apple-health): take a percent from a fraction or an already-scaled client
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-09-18 16:58:59 +02:00
commit 1fe90f3fab
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",
},