mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
3 files changed
+97
-9
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
|
||||
Reference in new issue
Block a user