feat(measurements): Apple Health identifier mapping helper

New `src/lib/measurements/apple-health-mapping.ts` translates inbound
HealthKit samples into the row shape the `Measurement` model expects.
Symmetrical to the Withings `MEASURE_TYPE_MAP` but keyed by HealthKit
string identifier so the upcoming batch endpoint can do a single
table lookup per entry.

Each mapping carries its canonical DB unit, a unit-conversion helper
(identity for SI-aligned types, ×100 for HealthKit's 0..1 fraction
quantities), an aggregation hint for downstream summarisation, and
flags the explicit-consent metrics (HRV, VO2 Max, sleep) so the audit
trail can pick them out.

Sleep is the one category type — the helper returns one row per stage
with the new `sleepStage` column populated; per-stage minutes go
straight in as `value`. Unknown identifiers return `null` so the
batch endpoint can mark the entry as skipped instead of failing the
whole batch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-11 00:17:56 +02:00
1 parent 95209ebf79
commit 30c0f76a34
2 files changed
+517

No files matched your search

@@ -0,0 +1,199 @@
import { describe, expect, it } from "vitest";
import {
APPLE_HEALTH_SLEEP_STAGE_MAP,
APPLE_HEALTH_TYPE_MAP,
mapAppleHealthEntry,
} from "../apple-health-mapping";
import { measurementTypeEnum } from "@/lib/validations/measurement";
describe("APPLE_HEALTH_TYPE_MAP", () => {
it("only references measurement types that exist in the canonical enum", () => {
const enumValues = new Set<string>(measurementTypeEnum.options);
for (const mapping of Object.values(APPLE_HEALTH_TYPE_MAP)) {
expect(
enumValues.has(mapping.measurementType),
`${mapping.hkIdentifier} → unknown MeasurementType ${mapping.measurementType}`,
).toBe(true);
}
});
it("uses each mapping key as the canonical hkIdentifier", () => {
for (const [key, mapping] of Object.entries(APPLE_HEALTH_TYPE_MAP)) {
expect(mapping.hkIdentifier).toBe(key);
}
});
it("flags the explicit-consent metrics as privacy sensitive", () => {
const sensitive = Object.values(APPLE_HEALTH_TYPE_MAP)
.filter((m) => m.isPrivacySensitive)
.map((m) => m.hkIdentifier)
.sort();
expect(sensitive).toEqual(
[
"HKCategoryTypeIdentifierSleepAnalysis",
"HKQuantityTypeIdentifierHeartRateVariabilitySDNN",
"HKQuantityTypeIdentifierVO2Max",
].sort(),
);
});
it("converts oxygen saturation from 0..1 fraction to 0..100 percent", () => {
const mapping = APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierOxygenSaturation;
expect(mapping.convertToDbUnit(0.97)).toBeCloseTo(97);
});
it("converts body fat percentage from 0..1 fraction to 0..100 percent", () => {
const mapping =
APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierBodyFatPercentage;
expect(mapping.convertToDbUnit(0.245)).toBeCloseTo(24.5);
});
it("uses identity conversion for the SI-aligned identifiers", () => {
for (const id of [
"HKQuantityTypeIdentifierBodyMass",
"HKQuantityTypeIdentifierBloodPressureSystolic",
"HKQuantityTypeIdentifierBloodPressureDiastolic",
"HKQuantityTypeIdentifierHeartRate",
"HKQuantityTypeIdentifierRestingHeartRate",
"HKQuantityTypeIdentifierHeartRateVariabilitySDNN",
"HKQuantityTypeIdentifierStepCount",
"HKQuantityTypeIdentifierActiveEnergyBurned",
"HKQuantityTypeIdentifierFlightsClimbed",
"HKQuantityTypeIdentifierDistanceWalkingRunning",
"HKQuantityTypeIdentifierVO2Max",
"HKQuantityTypeIdentifierBloodGlucose",
"HKQuantityTypeIdentifierBodyTemperature",
]) {
const mapping = APPLE_HEALTH_TYPE_MAP[id];
expect(mapping.convertToDbUnit(42)).toBe(42);
}
});
});
describe("APPLE_HEALTH_SLEEP_STAGE_MAP", () => {
it("maps the iOS 16+ stage codepoints to DB enum values", () => {
expect(APPLE_HEALTH_SLEEP_STAGE_MAP[0]).toBe("IN_BED");
expect(APPLE_HEALTH_SLEEP_STAGE_MAP[1]).toBe("ASLEEP");
expect(APPLE_HEALTH_SLEEP_STAGE_MAP[2]).toBe("AWAKE");
expect(APPLE_HEALTH_SLEEP_STAGE_MAP[3]).toBe("CORE");
expect(APPLE_HEALTH_SLEEP_STAGE_MAP[4]).toBe("DEEP");
expect(APPLE_HEALTH_SLEEP_STAGE_MAP[5]).toBe("REM");
});
it("does not double-claim a codepoint for two stages", () => {
const codepoints = Object.keys(APPLE_HEALTH_SLEEP_STAGE_MAP);
expect(new Set(codepoints).size).toBe(codepoints.length);
});
});
describe("mapAppleHealthEntry", () => {
const ts = "2026-05-10T07:30:00.000Z";
it("returns null for an unknown identifier", () => {
expect(
mapAppleHealthEntry({
hkIdentifier: "HKQuantityTypeIdentifierHallucinated",
value: 1,
unit: "kg",
startDate: ts,
endDate: ts,
}),
).toBeNull();
});
it("returns null for an invalid endDate", () => {
expect(
mapAppleHealthEntry({
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 80,
unit: "kg",
startDate: ts,
endDate: "not-a-date",
}),
).toBeNull();
});
it("anchors `takenAt` on the sample's endDate", () => {
const out = mapAppleHealthEntry({
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 81.5,
unit: "kg",
startDate: "2026-05-10T07:00:00.000Z",
endDate: ts,
});
expect(out).not.toBeNull();
expect(out!.takenAt.toISOString()).toBe(ts);
});
it("applies the unit conversion for fraction-shaped quantities", () => {
const out = mapAppleHealthEntry({
hkIdentifier: "HKQuantityTypeIdentifierOxygenSaturation",
value: 0.97,
unit: "fraction",
startDate: ts,
endDate: ts,
});
expect(out).not.toBeNull();
expect(out!.type).toBe("OXYGEN_SATURATION");
expect(out!.unit).toBe("%");
expect(out!.value).toBeCloseTo(97);
expect(out!.sleepStage).toBeUndefined();
});
it("yields a body weight row for a HKQuantityTypeIdentifierBodyMass sample", () => {
const out = mapAppleHealthEntry({
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 81.5,
unit: "kg",
startDate: ts,
endDate: ts,
});
expect(out).toEqual({
type: "WEIGHT",
value: 81.5,
unit: "kg",
takenAt: new Date(ts),
});
});
it("yields a per-stage sleep row for a sleepAnalysis sample", () => {
const out = mapAppleHealthEntry({
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
value: 95, // minutes spent in this stage
unit: "min",
startDate: "2026-05-10T01:00:00.000Z",
endDate: "2026-05-10T02:35:00.000Z",
sleepStage: 4, // DEEP
});
expect(out).toEqual({
type: "SLEEP_DURATION",
value: 95,
unit: "minutes",
takenAt: new Date("2026-05-10T02:35:00.000Z"),
sleepStage: "DEEP",
});
});
it("returns null for a sleepAnalysis sample without a stage codepoint", () => {
const out = mapAppleHealthEntry({
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
value: 60,
unit: "min",
startDate: ts,
endDate: ts,
});
expect(out).toBeNull();
});
it("returns null for a sleepAnalysis sample with an unknown stage codepoint", () => {
const out = mapAppleHealthEntry({
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
value: 60,
unit: "min",
startDate: ts,
endDate: ts,
sleepStage: 99,
});
expect(out).toBeNull();
});
});
@@ -0,0 +1,318 @@
/**
* Apple HealthKit identifier → HealthLog measurement mapping.
*
* Mirrors the Withings `MEASURE_TYPE_MAP` in `src/lib/withings/client.ts`
* but keyed by the HealthKit string identifier rather than a numeric
* type code. The `POST /api/measurements/batch` endpoint walks each
* inbound HealthKit sample through `mapAppleHealthEntry()` to produce
* the row shape the existing `Measurement` model expects.
*
* Adding a new identifier is a one-place change — append an entry to
* `APPLE_HEALTH_TYPE_MAP`. The unit-conversion helper, the canonical
* DB unit, and the aggregation hint all live alongside the identifier
* so future analytics work has a single discoverable surface.
*/
import type {
MeasurementType,
SleepStage,
} from "@/generated/prisma/client";
/**
* How a batch of HealthKit samples for the same identifier should be
* reduced into stored `Measurement` rows. The batch ingest endpoint
* stores every sample as-is in v1.4.23 — the aggregation hint is
* advisory metadata for downstream summarisation (analytics, AI
* Coach evidence chips). Future work can move the reduction earlier
* in the pipeline once we observe the production sample volume.
*/
export type AppleHealthAggregation =
| "sum"
| "mean"
| "latest"
| "max"
| "median";
export interface AppleHealthMapping {
/** HealthKit identifier (e.g. `HKQuantityTypeIdentifierBodyMass`). */
hkIdentifier: string;
/** HealthLog `MeasurementType` enum value. */
measurementType: MeasurementType;
/** Apple's HK unit string (e.g. `kg`, `count/min`, `mL/(kg*min)`). */
hkUnit: string;
/** HealthLog canonical DB unit (must match `getUnitForType()`). */
dbUnit: string;
/** Convert a HK sample value into the canonical DB unit. Identity for most. */
convertToDbUnit: (hkValue: number) => number;
/** Advisory aggregation hint — see `AppleHealthAggregation` doc. */
aggregation: AppleHealthAggregation;
/**
* `true` for metrics behind an explicit consent screen (HRV, VO2 Max,
* sleep stages). The iOS app is responsible for asking; the server
* trusts the inbound batch. Recorded here so the server-side audit
* trail can flag privacy-sensitive ingest separately if needed.
*/
isPrivacySensitive?: boolean;
/**
* Numeric `HKCategoryValueSleepAnalysis` → `SleepStage` map, only
* populated for `HKCategoryTypeIdentifierSleepAnalysis`. Apple's
* value enum is integer-valued; we accept the integer in the inbound
* sample and look up the DB-side stage here.
*/
sleepStageMap?: Record<number, SleepStage>;
}
/**
* Numeric values of `HKCategoryValueSleepAnalysis`. Apple's enum is
* platform-versioned; the values below match the iOS 16+ codepoints.
* Legacy iOS 15- only emits `inBed` (0) and `asleepUnspecified` (1) —
* the latter maps to `ASLEEP` so old samples still round-trip.
*
* Source: Apple's `HealthKit/HKCategoryValueSleepAnalysis.h` header.
*/
export const APPLE_HEALTH_SLEEP_STAGE_MAP: Record<number, SleepStage> = {
0: "IN_BED",
1: "ASLEEP", // legacy iOS 15- `asleepUnspecified`
2: "AWAKE",
3: "CORE", // iOS 16+ `asleepCore`
4: "DEEP", // iOS 16+ `asleepDeep`
5: "REM", // iOS 16+ `asleepREM`
};
/**
* The mapping table. `convertToDbUnit` is identity for most metrics
* because we picked DB units that match Apple's defaults. Two
* exceptions:
*
* - `HKQuantityTypeIdentifierOxygenSaturation` ships as a 0..1
* fraction; HealthLog stores percent (0..100).
* - `HKQuantityTypeIdentifierBodyFatPercentage` ships as a 0..1
* fraction; HealthLog stores percent (0..100).
*/
export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
// ── Body composition ────────────────────────────────────────
HKQuantityTypeIdentifierBodyMass: {
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
measurementType: "WEIGHT",
hkUnit: "kg",
dbUnit: "kg",
convertToDbUnit: (v) => v,
aggregation: "latest",
},
HKQuantityTypeIdentifierBodyFatPercentage: {
hkIdentifier: "HKQuantityTypeIdentifierBodyFatPercentage",
measurementType: "BODY_FAT",
hkUnit: "%",
dbUnit: "%",
// Apple ships 0..1 fraction; HealthLog stores 0..100.
convertToDbUnit: (v) => v * 100,
aggregation: "latest",
},
HKQuantityTypeIdentifierBodyTemperature: {
hkIdentifier: "HKQuantityTypeIdentifierBodyTemperature",
measurementType: "BODY_TEMPERATURE",
hkUnit: "degC",
dbUnit: "celsius",
convertToDbUnit: (v) => v,
aggregation: "latest",
},
// ── Cardiovascular ──────────────────────────────────────────
HKQuantityTypeIdentifierBloodPressureSystolic: {
hkIdentifier: "HKQuantityTypeIdentifierBloodPressureSystolic",
measurementType: "BLOOD_PRESSURE_SYS",
hkUnit: "mmHg",
dbUnit: "mmHg",
convertToDbUnit: (v) => v,
aggregation: "latest",
},
HKQuantityTypeIdentifierBloodPressureDiastolic: {
hkIdentifier: "HKQuantityTypeIdentifierBloodPressureDiastolic",
measurementType: "BLOOD_PRESSURE_DIA",
hkUnit: "mmHg",
dbUnit: "mmHg",
convertToDbUnit: (v) => v,
aggregation: "latest",
},
HKQuantityTypeIdentifierHeartRate: {
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
measurementType: "PULSE",
hkUnit: "count/min",
dbUnit: "bpm",
convertToDbUnit: (v) => v,
aggregation: "latest",
},
HKQuantityTypeIdentifierRestingHeartRate: {
hkIdentifier: "HKQuantityTypeIdentifierRestingHeartRate",
measurementType: "RESTING_HEART_RATE",
hkUnit: "count/min",
dbUnit: "bpm",
convertToDbUnit: (v) => v,
aggregation: "latest",
},
HKQuantityTypeIdentifierHeartRateVariabilitySDNN: {
hkIdentifier: "HKQuantityTypeIdentifierHeartRateVariabilitySDNN",
measurementType: "HEART_RATE_VARIABILITY",
hkUnit: "ms",
dbUnit: "ms",
convertToDbUnit: (v) => v,
aggregation: "mean",
isPrivacySensitive: true,
},
// ── Activity (cumulative) ───────────────────────────────────
HKQuantityTypeIdentifierStepCount: {
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
// Reuses existing enum value (the W1 research recommended this
// over a parallel STEP_COUNT value to avoid analytics-side
// duplication; see `.planning/phase-W1-v1423-research.md`).
measurementType: "ACTIVITY_STEPS",
hkUnit: "count",
dbUnit: "steps",
convertToDbUnit: (v) => v,
aggregation: "sum",
},
HKQuantityTypeIdentifierActiveEnergyBurned: {
hkIdentifier: "HKQuantityTypeIdentifierActiveEnergyBurned",
measurementType: "ACTIVE_ENERGY_BURNED",
hkUnit: "kcal",
dbUnit: "kcal",
convertToDbUnit: (v) => v,
aggregation: "sum",
},
HKQuantityTypeIdentifierFlightsClimbed: {
hkIdentifier: "HKQuantityTypeIdentifierFlightsClimbed",
measurementType: "FLIGHTS_CLIMBED",
hkUnit: "count",
dbUnit: "flights",
convertToDbUnit: (v) => v,
aggregation: "sum",
},
HKQuantityTypeIdentifierDistanceWalkingRunning: {
hkIdentifier: "HKQuantityTypeIdentifierDistanceWalkingRunning",
measurementType: "WALKING_RUNNING_DISTANCE",
hkUnit: "m",
dbUnit: "m",
convertToDbUnit: (v) => v,
aggregation: "sum",
},
// ── Fitness ─────────────────────────────────────────────────
HKQuantityTypeIdentifierVO2Max: {
hkIdentifier: "HKQuantityTypeIdentifierVO2Max",
measurementType: "VO2_MAX",
hkUnit: "mL/(kg*min)",
dbUnit: "mL/(kg·min)",
convertToDbUnit: (v) => v,
aggregation: "latest",
isPrivacySensitive: true,
},
// ── Other in-scope metrics already mapped via existing enums ──
HKQuantityTypeIdentifierBloodGlucose: {
hkIdentifier: "HKQuantityTypeIdentifierBloodGlucose",
measurementType: "BLOOD_GLUCOSE",
hkUnit: "mg/dL",
dbUnit: "mg/dL",
convertToDbUnit: (v) => v,
aggregation: "latest",
},
HKQuantityTypeIdentifierOxygenSaturation: {
hkIdentifier: "HKQuantityTypeIdentifierOxygenSaturation",
measurementType: "OXYGEN_SATURATION",
hkUnit: "fraction",
dbUnit: "%",
// Apple ships 0..1 fraction; HealthLog stores 0..100.
convertToDbUnit: (v) => v * 100,
aggregation: "latest",
},
// ── Sleep (category type) ───────────────────────────────────
HKCategoryTypeIdentifierSleepAnalysis: {
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
measurementType: "SLEEP_DURATION",
hkUnit: "category",
dbUnit: "minutes",
// value here is duration-in-minutes (caller computes
// `endDate - startDate` and converts to minutes); the per-stage
// label arrives in `sleepStage` and is mapped via `sleepStageMap`.
convertToDbUnit: (v) => v,
aggregation: "sum",
isPrivacySensitive: true,
sleepStageMap: APPLE_HEALTH_SLEEP_STAGE_MAP,
},
};
/** Input to `mapAppleHealthEntry()`. */
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. */
unit: string;
/** ISO timestamp string (e.g. HealthKit `startDate`). */
startDate: string;
/** ISO timestamp string (e.g. HealthKit `endDate`). */
endDate: string;
/**
* For `HKCategoryTypeIdentifierSleepAnalysis` only — the integer
* `HKCategoryValueSleepAnalysis` codepoint. Ignored for quantity
* types.
*/
sleepStage?: number;
}
/** Output of `mapAppleHealthEntry()`. */
export interface AppleHealthEntryOutput {
type: MeasurementType;
value: number;
unit: string;
takenAt: Date;
/** Set only for sleep entries. */
sleepStage?: SleepStage;
}
/**
* Map a single inbound HealthKit sample into the row shape the
* `Measurement` model expects. Returns `null` if the identifier is
* unknown — the caller should treat that as a "skipped" entry rather
* than a hard error so a single rogue sample doesn't poison a batch.
*
* - `takenAt` is the sample's `endDate` because that's the moment the
* measurement was completed (Apple's convention; matches our
* existing `measuredAt` semantics for Withings).
* - For sleep samples the inbound `value` is duration-in-minutes
* (caller computes `endDate - startDate` and divides by 60_000); we
* reproduce that contract here without re-deriving from the dates so
* the helper is decoupled from clock-skew between iOS and the server.
*/
export function mapAppleHealthEntry(
input: AppleHealthEntryInput,
): AppleHealthEntryOutput | null {
const mapping = APPLE_HEALTH_TYPE_MAP[input.hkIdentifier];
if (!mapping) return null;
const takenAt = new Date(input.endDate);
if (Number.isNaN(takenAt.getTime())) return null;
const value = mapping.convertToDbUnit(input.value);
if (mapping.sleepStageMap !== undefined) {
if (input.sleepStage === undefined) return null;
const stage = mapping.sleepStageMap[input.sleepStage];
if (!stage) return null;
return {
type: mapping.measurementType,
value,
unit: mapping.dbUnit,
takenAt,
sleepStage: stage,
};
}
return {
type: mapping.measurementType,
value,
unit: mapping.dbUnit,
takenAt,
};
}