fix(import): honour the unit an Apple Health record carries

Apple stamps every quantity `<Record>` of an export archive with the
account's own display unit, not the unit the mapping table assumes: a
metric archive reports walking distance in km, an imperial one in mi,
body mass in lb, energy in kJ, temperature in degF. The mapper accepted
`input.unit` and never read it, so a 2.484 km day was folded into the
day's total as 2.484 m and a decade of imported history read as a couple
of metres walked.

`hk-units.ts` is now the one place a HealthKit unit is converted, shared
by the record path and the workout path (which carried its own km/mi
branch — the duplicate is what let the record path stay wrong). It
converts only inside a family and only for units Apple can emit, and
returns the reading untouched for anything it cannot place, so an
archive that already speaks the table's unit is unchanged.

Every mapping-table entry whose unit cannot be converted — a
dimensionless count, a pinned event, a logarithmic dB level, a compound
rate, the percent units whose value rides as a 0..1 fraction — now
carries a written reason, and a structural guard fails on an entry that
offers neither a convertible unit nor a reason.

Refs #944
This commit is contained in:
Marc-André Bombeck committed 2026-09-09 22:31:31 +02:00
1 parent 704ef11eab
commit 5c7c7afd59
5 files changed
+515 -11

No files matched your search

@@ -0,0 +1,165 @@
/**
* Structural guard on the Apple Health mapping table's unit column
* (issue #944).
*
* `AppleHealthMapping.hkUnit` records the unit HealthLog ASSUMES a
* HealthKit reading arrives in, and `convertToDbUnit` is written against
* that assumption. Apple's `export.xml` does not honour it: every quantity
* `<Record>` carries the account's own display unit, so a metric archive
* reports walking distance in `km` and an imperial one in `mi`. For ten
* years those readings were stored as if the number were already metres —
* the whole history understated by a factor of a thousand — because the
* mapper accepted `input.unit` and never read it.
*
* So an entry now has exactly two legal shapes:
*
* 1. its `hkUnit` is one `convertHkValue()` knows a factor for, in which
* case `mapAppleHealthEntry()` converts a differing record unit into
* it before `convertToDbUnit` runs; or
* 2. it carries a written `unitFixedReason` saying why no unit attribute
* Apple could write would change the reading (a dimensionless count,
* a pinned event, a logarithmic dB level, a compound rate).
*
* The reason arm alone would be a guard that cannot fail: an entry could
* declare a convertible unit while the mapper quietly ignored it. The
* behavioural block below therefore feeds every convertible entry a
* reading in a SIBLING unit and asserts the stored number moved by the
* documented factor — so deleting the conversion turns this guard red,
* not just the one reproduction test.
*
* A tripwire, not a proof: a reviewer who waves through a wrong factor
* defeats it.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
APPLE_HEALTH_TYPE_MAP,
mapAppleHealthEntry,
} from "@/lib/measurements/apple-health-mapping";
import {
convertHkValue,
isConvertibleHkUnit,
} from "@/lib/measurements/hk-units";
const ENTRIES = Object.values(APPLE_HEALTH_TYPE_MAP);
/**
* Units from every family the table touches. The behavioural block picks
* the first one that converts into an entry's `hkUnit` and actually moves
* the number, so a new family only needs one representative here.
*/
const SIBLING_UNITS = [
"km",
"mi",
"cm",
"lb",
"st",
"kJ",
"cal",
"degF",
"K",
"mmol<180.156>/L",
"kPa",
"km/hr",
"hr",
"ms",
];
function siblingUnitFor(hkUnit: string): string | null {
for (const candidate of SIBLING_UNITS) {
const converted = convertHkValue(1, candidate, hkUnit);
if (converted !== null && converted !== 1) return candidate;
}
return null;
}
describe("Apple Health mapping — the record's own unit (issue #944)", () => {
it("examines the whole mapping table", () => {
// An empty match set is the failure mode this repo keeps rediscovering:
// a guard is green because it matched nothing, not because nothing is
// wrong. Pin the floor.
expect(ENTRIES.length).toBeGreaterThan(40);
});
it.each(ENTRIES)(
"$hkIdentifier either converts its record unit or says why it cannot",
(mapping) => {
if (isConvertibleHkUnit(mapping.hkUnit)) {
expect(mapping.unitFixedReason).toBeUndefined();
return;
}
expect(
mapping.unitFixedReason,
`${mapping.hkIdentifier} pins hkUnit "${mapping.hkUnit}" with no conversion and no written reason`,
).toBeTypeOf("string");
// Long enough to be a sentence rather than a shrug.
expect(mapping.unitFixedReason!.length).toBeGreaterThan(40);
},
);
const convertible = ENTRIES.filter((mapping) =>
isConvertibleHkUnit(mapping.hkUnit),
);
it("has convertible entries to exercise", () => {
expect(convertible.length).toBeGreaterThan(10);
});
it.each(convertible)(
"$hkIdentifier honours a sibling unit on the record itself",
(mapping) => {
const sibling = siblingUnitFor(mapping.hkUnit);
expect(
sibling,
`no sibling unit listed for the family of "${mapping.hkUnit}"`,
).not.toBeNull();
const base = {
hkIdentifier: mapping.hkIdentifier,
value: 2,
startDate: "2026-05-14T08:00:00.000Z",
endDate: "2026-05-14T08:30:00.000Z",
};
const native = mapAppleHealthEntry({ ...base, unit: mapping.hkUnit });
const foreign = mapAppleHealthEntry({ ...base, unit: sibling! });
expect(native).not.toBeNull();
expect(foreign).not.toBeNull();
const expected = mapping.convertToDbUnit(
convertHkValue(2, sibling!, mapping.hkUnit)!,
);
expect(foreign!.value).toBeCloseTo(expected, 9);
expect(foreign!.value).not.toBeCloseTo(native!.value, 9);
},
);
it("keeps a unit it cannot place from rescaling the reading", () => {
const out = mapAppleHealthEntry({
hkIdentifier: "HKQuantityTypeIdentifierDistanceWalkingRunning",
value: 1234,
unit: "furlong",
startDate: "2026-05-14T08:00:00.000Z",
endDate: "2026-05-14T08:30:00.000Z",
});
expect(out?.value).toBe(1234);
});
it("leaves the km/mi factors in one shared module", () => {
// The workout path used to carry its own copy of the km/mi branch; the
// duplicate is what let the `<Record>` path stay wrong for ten years.
const shared = join(process.cwd(), "src/lib/measurements/hk-units.ts");
const owners = [
"src/lib/measurements/apple-health-mapping.ts",
"src/lib/measurements/import-apple-health-export.ts",
];
expect(readFileSync(shared, "utf8")).toContain("1609.344");
for (const rel of owners) {
const source = readFileSync(join(process.cwd(), rel), "utf8");
expect(source, `${rel} re-implements a length factor`).not.toMatch(
/1609\.344|\* 1000\b/,
);
}
});
});
@@ -0,0 +1,77 @@
import { describe, it, expect } from "vitest";
import {
convertHkValue,
hkDistanceToMetres,
isConvertibleHkUnit,
} from "../hk-units";
describe("convertHkValue", () => {
it("converts the km reading Apple writes on a metric archive", () => {
expect(convertHkValue(2.484, "km", "m")).toBeCloseTo(2484, 9);
});
it("converts the mi reading Apple writes on an imperial archive", () => {
expect(convertHkValue(1.543, "mi", "m")).toBeCloseTo(2483.217792, 9);
});
it("returns the reading untouched when the units already agree", () => {
expect(convertHkValue(2.484, "m", "m")).toBe(2.484);
});
it("converts mass, energy and temperature the same way", () => {
expect(convertHkValue(180, "lb", "kg")).toBeCloseTo(81.6466266, 6);
expect(convertHkValue(1000, "kJ", "kcal")).toBeCloseTo(239.005736, 6);
expect(convertHkValue(98.6, "degF", "degC")).toBeCloseTo(37, 9);
});
it("strips the molar mass Apple annotates a glucose unit with", () => {
expect(convertHkValue(5.5, "mmol<180.156>/L", "mg/dL")).toBeCloseTo(
99.0858,
4,
);
});
it("keeps the small calorie apart from the large one", () => {
expect(convertHkValue(1, "Cal", "kcal")).toBe(1);
expect(convertHkValue(1000, "cal", "kcal")).toBeCloseTo(1, 9);
});
it("refuses a unit it cannot place rather than guessing", () => {
expect(convertHkValue(1, "furlong", "m")).toBeNull();
expect(convertHkValue(1, "", "m")).toBeNull();
expect(convertHkValue(1, undefined, "m")).toBeNull();
});
it("refuses to cross families", () => {
expect(convertHkValue(1, "kg", "m")).toBeNull();
expect(convertHkValue(1, "min", "kcal")).toBeNull();
});
it("refuses a non-finite reading", () => {
expect(convertHkValue(Number.NaN, "km", "m")).toBeNull();
});
});
describe("hkDistanceToMetres", () => {
it("converts the workout distance units Apple ships", () => {
expect(hkDistanceToMetres(6.5, "km")).toBeCloseTo(6500, 9);
expect(hkDistanceToMetres(3, "mi")).toBeCloseTo(4828.032, 9);
expect(hkDistanceToMetres(500, "m")).toBe(500);
});
it("falls back to the raw number for a missing unit attribute", () => {
expect(hkDistanceToMetres(500, "")).toBe(500);
expect(hkDistanceToMetres(500, undefined)).toBe(500);
});
});
describe("isConvertibleHkUnit", () => {
it("recognises the units the mapping table pins", () => {
expect(isConvertibleHkUnit("m")).toBe(true);
expect(isConvertibleHkUnit("kg")).toBe(true);
expect(isConvertibleHkUnit("count")).toBe(false);
expect(isConvertibleHkUnit("event")).toBe(false);
expect(isConvertibleHkUnit("%")).toBe(false);
});
});
+82 -4
View File
@@ -28,6 +28,7 @@ import type {
SleepStage,
RhythmClassification,
} from "@/generated/prisma/client";
import { convertHkValue } from "./hk-units";
/**
* How a batch of HealthKit samples for the same identifier should be
@@ -47,6 +48,21 @@ export interface AppleHealthMapping {
measurementType: MeasurementType;
/** Apple's HK unit string (e.g. `kg`, `count/min`, `mL/(kg*min)`). */
hkUnit: string;
/**
* issue #944 — why this entry's `hkUnit` can be trusted as fixed.
*
* Apple's `export.xml` writes the ACCOUNT's display unit on every
* quantity `<Record>`, not the unit this table assumes, so
* `mapAppleHealthEntry()` converts the reading from the record's own
* unit into `hkUnit` before `convertToDbUnit` runs. That only works for
* a unit `convertHkValue()` knows a factor for. An entry whose `hkUnit`
* is NOT convertible (a dimensionless count, a pinned event, a
* logarithmic dB level, a compound rate) must say here why no record
* unit can change its reading — the structural guard
* `src/__tests__/apple-health-unit-audit-guard.test.ts` fails on an
* entry that offers neither.
*/
unitFixedReason?: string;
/** HealthLog canonical DB unit (must match `getUnitForType()`). */
dbUnit: string;
/** Convert a HK sample value into the canonical DB unit. Identity for most. */
@@ -189,6 +205,28 @@ export const APPLE_HEALTH_SLEEP_STAGE_MAP: Record<number, SleepStage> = {
* metric: check Apple's HK unit; percent → ×100 path, m/m·s/kg/etc
* → identity path.
*/
/**
* issue #944 — the written reasons a mapping entry may cite for keeping a
* fixed `hkUnit`. Each one answers the same question: what could Apple
* possibly write in this record's `unit` attribute that would change the
* reading? Shared constants rather than free text so the audit reads as
* one decision per class instead of forty near-duplicates.
*/
const UNIT_FIXED_DIMENSIONLESS_COUNT =
"Dimensionless tally: Apple emits `count` in every locale and HKUnit has no sibling spelling for it, so no record unit can rescale the reading.";
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.";
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 =
"Sound-pressure level: Apple emits `dBASPL` only, and a decibel is logarithmic — a sibling unit would need its own formula, never a factor.";
const UNIT_FIXED_COMPOSITE_VO2 =
"Compound rate Apple emits verbatim as `mL/(kg*min)`; the Health app exposes no other spelling and the compound has no sibling unit to convert from.";
const UNIT_FIXED_SLEEP_CATEGORY =
"Sleep samples carry a symbolic category value, not a quantity: the importer derives the duration in minutes from the sample's own dates, so the record's unit attribute (empty in Apple's archive) says nothing about the reading.";
export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
// ── Body composition ────────────────────────────────────────
HKQuantityTypeIdentifierBodyMass: {
@@ -203,6 +241,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierBodyFatPercentage",
measurementType: "BODY_FAT",
hkUnit: "%",
unitFixedReason: UNIT_FIXED_PERCENT_FRACTION,
dbUnit: "%",
// Apple ships 0..1 fraction; HealthLog stores 0..100.
convertToDbUnit: (v) => v * 100,
@@ -254,6 +293,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
measurementType: "PULSE",
hkUnit: "count/min",
unitFixedReason: UNIT_FIXED_PER_MINUTE,
dbUnit: "bpm",
convertToDbUnit: (v) => v,
aggregation: "latest",
@@ -262,6 +302,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierRestingHeartRate",
measurementType: "RESTING_HEART_RATE",
hkUnit: "count/min",
unitFixedReason: UNIT_FIXED_PER_MINUTE,
dbUnit: "bpm",
convertToDbUnit: (v) => v,
aggregation: "latest",
@@ -284,6 +325,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
// duplication; see `.planning/phase-W1-v1423-research.md`).
measurementType: "ACTIVITY_STEPS",
hkUnit: "count",
unitFixedReason: UNIT_FIXED_DIMENSIONLESS_COUNT,
dbUnit: "steps",
convertToDbUnit: (v) => v,
aggregation: "sum",
@@ -300,6 +342,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierFlightsClimbed",
measurementType: "FLIGHTS_CLIMBED",
hkUnit: "count",
unitFixedReason: UNIT_FIXED_DIMENSIONLESS_COUNT,
dbUnit: "flights",
convertToDbUnit: (v) => v,
aggregation: "sum",
@@ -318,6 +361,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierVO2Max",
measurementType: "VO2_MAX",
hkUnit: "mL/(kg*min)",
unitFixedReason: UNIT_FIXED_COMPOSITE_VO2,
dbUnit: "mL/(kg·min)",
convertToDbUnit: (v) => v,
aggregation: "latest",
@@ -337,6 +381,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierOxygenSaturation",
measurementType: "OXYGEN_SATURATION",
hkUnit: "fraction",
unitFixedReason: UNIT_FIXED_PERCENT_FRACTION,
dbUnit: "%",
// Apple ships 0..1 fraction; HealthLog stores 0..100.
convertToDbUnit: (v) => v * 100,
@@ -348,6 +393,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
measurementType: "SLEEP_DURATION",
hkUnit: "category",
unitFixedReason: UNIT_FIXED_SLEEP_CATEGORY,
dbUnit: "minutes",
// value here is duration-in-minutes (caller computes
// `endDate - startDate` and converts to minutes); the per-stage
@@ -367,6 +413,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierEnvironmentalAudioExposure",
measurementType: "AUDIO_EXPOSURE_ENV",
hkUnit: "dBASPL",
unitFixedReason: UNIT_FIXED_AUDIO_SPL,
dbUnit: "dBA",
convertToDbUnit: (v) => v,
aggregation: "mean",
@@ -379,6 +426,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierHeadphoneAudioExposure",
measurementType: "AUDIO_EXPOSURE_HEADPHONE",
hkUnit: "dBASPL",
unitFixedReason: UNIT_FIXED_AUDIO_SPL,
dbUnit: "dBA",
convertToDbUnit: (v) => v,
aggregation: "mean",
@@ -403,6 +451,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierAppleWalkingSteadiness",
measurementType: "WALKING_STEADINESS",
hkUnit: "%",
unitFixedReason: UNIT_FIXED_PERCENT_FRACTION,
dbUnit: "%",
// Apple ships 0..1 fraction; HealthLog stores 0..100.
convertToDbUnit: (v) => v * 100,
@@ -416,6 +465,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent",
measurementType: "AUDIO_EXPOSURE_EVENT",
hkUnit: "count",
unitFixedReason: UNIT_FIXED_EVENT_PIN,
dbUnit: "count",
convertToDbUnit: () => 1,
aggregation: "sum",
@@ -427,6 +477,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKCategoryTypeIdentifierHeadphoneAudioExposureEvent",
measurementType: "AUDIO_EXPOSURE_EVENT",
hkUnit: "count",
unitFixedReason: UNIT_FIXED_EVENT_PIN,
dbUnit: "count",
convertToDbUnit: () => 1,
aggregation: "sum",
@@ -449,6 +500,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKCategoryTypeIdentifierIrregularHeartRhythmEvent",
measurementType: "IRREGULAR_RHYTHM_NOTIFICATION",
hkUnit: "event",
unitFixedReason: UNIT_FIXED_EVENT_PIN,
dbUnit: "event",
convertToDbUnit: () => 1,
aggregation: "sum",
@@ -462,6 +514,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKCategoryTypeIdentifierHighHeartRateEvent",
measurementType: "HIGH_HEART_RATE_EVENT",
hkUnit: "event",
unitFixedReason: UNIT_FIXED_EVENT_PIN,
dbUnit: "event",
convertToDbUnit: () => 1,
aggregation: "sum",
@@ -474,6 +527,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKCategoryTypeIdentifierLowHeartRateEvent",
measurementType: "LOW_HEART_RATE_EVENT",
hkUnit: "event",
unitFixedReason: UNIT_FIXED_EVENT_PIN,
dbUnit: "event",
convertToDbUnit: () => 1,
aggregation: "sum",
@@ -487,6 +541,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent",
measurementType: "WALKING_STEADINESS_EVENT",
hkUnit: "event",
unitFixedReason: UNIT_FIXED_EVENT_PIN,
dbUnit: "event",
convertToDbUnit: () => 1,
aggregation: "sum",
@@ -501,6 +556,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKCategoryTypeIdentifierSleepApneaEvent",
measurementType: "BREATHING_DISTURBANCE_EVENT",
hkUnit: "event",
unitFixedReason: UNIT_FIXED_EVENT_PIN,
dbUnit: "event",
convertToDbUnit: () => 1,
aggregation: "sum",
@@ -524,6 +580,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierRespiratoryRate",
measurementType: "RESPIRATORY_RATE",
hkUnit: "count/min",
unitFixedReason: UNIT_FIXED_PER_MINUTE,
dbUnit: "breaths/min",
convertToDbUnit: (v) => v,
aggregation: "mean",
@@ -537,6 +594,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierBodyMassIndex",
measurementType: "BODY_MASS_INDEX",
hkUnit: "count",
unitFixedReason: UNIT_FIXED_DIMENSIONLESS_COUNT,
dbUnit: "kg/m²",
convertToDbUnit: (v) => v,
aggregation: "latest",
@@ -557,6 +615,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierWalkingHeartRateAverage",
measurementType: "WALKING_HEART_RATE_AVERAGE",
hkUnit: "count/min",
unitFixedReason: UNIT_FIXED_PER_MINUTE,
dbUnit: "bpm",
convertToDbUnit: (v) => v,
aggregation: "mean",
@@ -570,6 +629,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierWalkingAsymmetryPercentage",
measurementType: "WALKING_ASYMMETRY",
hkUnit: "%",
unitFixedReason: UNIT_FIXED_PERCENT_FRACTION,
dbUnit: "%",
convertToDbUnit: (v) => v * 100,
aggregation: "latest",
@@ -580,6 +640,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierWalkingDoubleSupportPercentage",
measurementType: "WALKING_DOUBLE_SUPPORT",
hkUnit: "%",
unitFixedReason: UNIT_FIXED_PERCENT_FRACTION,
dbUnit: "%",
convertToDbUnit: (v) => v * 100,
aggregation: "latest",
@@ -622,6 +683,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierHeartRateRecoveryOneMinute",
measurementType: "CARDIO_RECOVERY",
hkUnit: "count/min",
unitFixedReason: UNIT_FIXED_PER_MINUTE,
dbUnit: "bpm",
convertToDbUnit: (v) => v,
aggregation: "latest",
@@ -645,6 +707,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierNumberOfTimesFallen",
measurementType: "FALL_COUNT",
hkUnit: "count",
unitFixedReason: UNIT_FIXED_DIMENSIONLESS_COUNT,
dbUnit: "count",
convertToDbUnit: (v) => v,
aggregation: "sum",
@@ -685,6 +748,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances",
measurementType: "BREATHING_DISTURBANCES",
hkUnit: "count",
unitFixedReason: UNIT_FIXED_DIMENSIONLESS_COUNT,
dbUnit: "count",
convertToDbUnit: (v) => v,
aggregation: "latest",
@@ -1140,7 +1204,12 @@ export interface AppleHealthEntryInput {
hkIdentifier: string;
/** Numeric value as Apple delivers it (pre-conversion). */
value: number;
/** Apple's unit string, captured for audit; not currently validated. */
/**
* Apple's unit string for this reading. issue #944 — the mapper converts
* the value from this unit into the table's `hkUnit` when the two differ
* and a factor is known (`km` -> `m`, `lb` -> `kg`, `degF` -> `degC`, …);
* an unknown or unplaceable unit leaves the reading untouched.
*/
unit: string;
/** ISO timestamp string (e.g. HealthKit `startDate`). */
startDate: string;
@@ -1221,7 +1290,16 @@ export function mapAppleHealthEntry(
const takenAt = new Date(input.endDate);
if (Number.isNaN(takenAt.getTime())) return null;
const value = mapping.convertToDbUnit(input.value);
// issue #944 — `convertToDbUnit` is written against the table's fixed
// `hkUnit`, but Apple's `export.xml` stamps each `<Record>` with the
// ACCOUNT's own display unit: a metric archive reports walking distance
// in `km`, an imperial one in `mi`, body mass in `lb`, energy in `kJ`.
// Normalise the reading into `hkUnit` first; a unit we cannot place
// leaves the value untouched (see `convertHkValue`).
const toHkUnit = (raw: number): number =>
convertHkValue(raw, input.unit, mapping.hkUnit) ?? raw;
const value = mapping.convertToDbUnit(toHkUnit(input.value));
const out: AppleHealthEntryOutput = {
type: mapping.measurementType,
@@ -1235,10 +1313,10 @@ export function mapAppleHealthEntry(
// hourly HR bucket). A finite check guards a malformed payload; the
// caller only persists these on the HR-bucket row.
if (typeof input.valueMin === "number" && Number.isFinite(input.valueMin)) {
out.valueMin = mapping.convertToDbUnit(input.valueMin);
out.valueMin = mapping.convertToDbUnit(toHkUnit(input.valueMin));
}
if (typeof input.valueMax === "number" && Number.isFinite(input.valueMax)) {
out.valueMax = mapping.convertToDbUnit(input.valueMax);
out.valueMax = mapping.convertToDbUnit(toHkUnit(input.valueMax));
}
if (mapping.sleepStageMap) {
+185
View File
@@ -0,0 +1,185 @@
/**
* HealthKit unit normalisation (issue #944).
*
* Apple writes a `unit` attribute on every quantity `<Record>` of an
* `export.xml` archive, and the string it writes is the account's OWN
* display unit rather than the unit HealthLog's mapping table assumes:
* a metric account exports walking distance as `km`, an imperial one as
* `mi`, and the same split shows up on body mass (`kg` / `lb` / `st`),
* energy (`kcal` / `kJ`) and temperature (`degC` / `degF`).
*
* Before this module the export import fed the raw number straight into
* `AppleHealthMapping.convertToDbUnit`, which is written against the
* table's fixed `hkUnit` — so a 2.484 km day was stored as 2.484 m. The
* workout path had a hand-rolled km/mi→m branch of its own; both paths
* now share `convertHkValue()`.
*
* Contract, deliberately conservative:
*
* - Same unit in and out → the value is returned untouched, so an
* archive (or an iOS batch) that already speaks the table's unit is
* byte-for-byte unchanged.
* - Unknown unit, empty unit, or two units from different families →
* `null`, and every caller falls back to the raw value. A unit we
* cannot prove a factor for must never silently rescale a reading.
* - Only units Apple's `HKUnit` can actually emit are listed. A unit
* string is matched exactly (after trimming and after stripping the
* molar-mass annotation Apple writes on glucose), never lower-cased:
* `cal` (small calorie) and `Cal` (kilocalorie) are different units
* that differ only in case.
*/
interface HkUnitSpec {
/** Units convert only within one family. */
family: string;
/** This unit → the family's base unit. */
toBase: (value: number) => number;
/** The family's base unit → this unit. */
fromBase: (value: number) => number;
}
/** A unit that is a plain multiple of its family's base unit. */
function scaled(family: string, perBase: number): HkUnitSpec {
return {
family,
toBase: (value) => value * perBase,
fromBase: (value) => value / perBase,
};
}
/**
* Every unit string Apple emits that HealthLog can convert, keyed by the
* exact `HKUnit.unitString` spelling. Base units: metre, kilogram,
* kilocalorie, second, degree Celsius, mmHg, mg/dL, metres per second.
*/
const HK_UNITS: Record<string, HkUnitSpec> = {
// ── Length (base: metre) ───────────────────────────────────
m: scaled("length", 1),
km: scaled("length", 1000),
cm: scaled("length", 0.01),
mm: scaled("length", 0.001),
in: scaled("length", 0.0254),
ft: scaled("length", 0.3048),
yd: scaled("length", 0.9144),
mi: scaled("length", 1609.344),
// ── Mass (base: kilogram) ──────────────────────────────────
kg: scaled("mass", 1),
g: scaled("mass", 0.001),
lb: scaled("mass", 0.45359237),
oz: scaled("mass", 0.028349523125),
st: scaled("mass", 6.35029318),
// ── Energy (base: kilocalorie) ─────────────────────────────
kcal: scaled("energy", 1),
Cal: scaled("energy", 1), // Apple's large calorie == kilocalorie
cal: scaled("energy", 0.001), // small calorie — case is load-bearing
kJ: scaled("energy", 1 / 4.184),
J: scaled("energy", 1 / 4184),
// ── Time (base: second) ────────────────────────────────────
s: scaled("time", 1),
ms: scaled("time", 0.001),
min: scaled("time", 60),
hr: scaled("time", 3600),
d: scaled("time", 86_400),
// ── Pressure (base: mmHg) ──────────────────────────────────
mmHg: scaled("pressure", 1),
kPa: scaled("pressure", 7.500_616_827_041_698),
Pa: scaled("pressure", 0.007_500_616_827_041_698),
// ── Blood glucose (base: mg/dL) ────────────────────────────
// Apple annotates the molar unit with glucose's molar mass, which the
// normaliser strips before the lookup.
"mg/dL": scaled("glucose", 1),
"mmol/L": scaled("glucose", 18.0156), // 180.156 g/mol ÷ 10
// ── Speed (base: metres per second) ────────────────────────
"m/s": scaled("speed", 1),
"km/hr": scaled("speed", 1000 / 3600),
"mi/hr": scaled("speed", 1609.344 / 3600),
// ── Temperature (base: degree Celsius) — affine, not scaled ─
degC: {
family: "temperature",
toBase: (value) => value,
fromBase: (value) => value,
},
degF: {
family: "temperature",
toBase: (value) => ((value - 32) * 5) / 9,
fromBase: (value) => (value * 9) / 5 + 32,
},
K: {
family: "temperature",
toBase: (value) => value - 273.15,
fromBase: (value) => value + 273.15,
},
};
/**
* Apple writes molar units with the substance's molar mass inline —
* `mmol<180.156>/L` for blood glucose. The annotation is informational;
* strip it so the lookup sees the plain unit.
*/
const MOLAR_MASS_ANNOTATION = /<[^>]*>/g;
function normaliseUnitString(unit: string): string {
return unit.trim().replace(MOLAR_MASS_ANNOTATION, "");
}
function lookup(unit: string | null | undefined): HkUnitSpec | null {
if (typeof unit !== "string") return null;
const normalised = normaliseUnitString(unit);
if (!normalised) return null;
return HK_UNITS[normalised] ?? null;
}
/**
* `true` when this module knows a conversion factor for the unit — i.e.
* a `<Record>` that carries a sibling unit from the same family will be
* converted rather than trusted blindly. The mapping-table guard in
* `src/__tests__/apple-health-unit-audit-guard.test.ts` uses this to
* decide whether an entry's fixed `hkUnit` needs a written reason.
*/
export function isConvertibleHkUnit(unit: string | null | undefined): boolean {
return lookup(unit) !== null;
}
/**
* Convert a HealthKit value between two `HKUnit` strings. Returns `null`
* — never a guess — when either unit is unknown or the two belong to
* different families; callers keep the raw value in that case.
*/
export function convertHkValue(
value: number,
fromUnit: string | null | undefined,
toUnit: string,
): number | null {
if (!Number.isFinite(value)) return null;
const from = lookup(fromUnit);
if (!from) return null;
const to = lookup(toUnit);
if (!to) return null;
if (from.family !== to.family) return null;
// Same unit: return the reading untouched rather than round-tripping it
// through the base and picking up a float error.
if (normaliseUnitString(String(fromUnit)) === normaliseUnitString(toUnit)) {
return value;
}
return to.fromBase(from.toBase(value));
}
/**
* Convert a distance a HealthKit archive reports in its own unit into
* metres, the canonical DB unit. Falls back to the raw number for a unit
* this module cannot place (including the empty attribute an archive may
* write) — the pre-#944 behaviour of the workout path, preserved.
*/
export function hkDistanceToMetres(
value: number,
unit: string | null | undefined,
): number {
return convertHkValue(value, unit, "m") ?? value;
}
@@ -42,6 +42,7 @@ import {
} from "@/lib/measurements/drain-per-sample-cumulative";
import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement";
import { resolveHkWorkoutSportType } from "@/lib/measurements/hk-workout-activity-type-map";
import { hkDistanceToMetres } from "@/lib/measurements/hk-units";
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
@@ -947,14 +948,12 @@ export async function streamParseExportXml(
const totalDistance = Number.parseFloat(attrs.totalDistance ?? "");
const totalEnergy = Number.parseFloat(attrs.totalEnergyBurned ?? "");
const distanceUnit = attrs.totalDistanceUnit ?? "";
// Apple ships km for HKWorkout.totalDistance by default; metres
// is the canonical DB unit. Convert when the unit is km.
// Apple ships the ACCOUNT's own length unit on HKWorkout.totalDistance
// (km for a metric account, mi for an imperial one); metres is the
// canonical DB unit. issue #944 — the same conversion the `<Record>`
// path now runs, so the two can never drift apart again.
const distanceM = Number.isFinite(totalDistance)
? distanceUnit === "km"
? totalDistance * 1000
: distanceUnit === "mi"
? totalDistance * 1609.344
: totalDistance
? hkDistanceToMetres(totalDistance, distanceUnit)
: null;
const externalId = hashSampleKey(