mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user