feat(schema): Apple Health measurement types + sleep stages + dedup index

Extend MeasurementType with the seven HealthKit-shaped values the iOS
batch ingest needs (HRV, resting HR, active energy, flights climbed,
distance, VO2 max, body temperature) and add APPLE_HEALTH to the
MeasurementSource enum. Sleep duration unit shifts from hours to
minutes so per-stage HKCategorySample rows persist without precision
loss; the new SleepStage enum mirrors the existing GlucoseContext
column shape and a CHECK constraint scopes it to SLEEP_DURATION rows.

The composite unique index `(user_id, type, source, external_id)`
gives the new batch endpoint an idempotent dedup key while the legacy
`(user_id, type, measured_at, source)` index continues to back the
manual UI's no-duplicate-at-the-same-wall-clock guard. NULL externalId
rows stay distinct under Postgres semantics so manual entries never
collide.

Migration is strictly additive: new enum values append, new columns
are nullable, no rows mutate.

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:15:56 +02:00
1 parent 8569a1879f
commit 95209ebf79
9 files changed
+256 -8

No files matched your search

+7
View File
@@ -283,6 +283,13 @@
"typeTotalBodyWater": "Gesamtkörperwasser",
"typeBoneMass": "Knochenmasse",
"typeOxygenSaturation": "Sauerstoffsättigung",
"typeHeartRateVariability": "Herzfrequenzvariabilität",
"typeRestingHeartRate": "Ruheherzfrequenz",
"typeActiveEnergyBurned": "Aktive Energie",
"typeFlightsClimbed": "Treppen",
"typeWalkingRunningDistance": "Geh- und Laufstrecke",
"typeVo2Max": "VO₂max",
"typeBodyTemperature": "Körpertemperatur",
"glucoseContext": "Kontext",
"glucoseContextFasting": "Nüchtern (≥8 h ohne Nahrung)",
"glucoseContextPostprandial": "Postprandial (2 h nach dem Essen)",
+7
View File
@@ -283,6 +283,13 @@
"typeTotalBodyWater": "Total body water",
"typeBoneMass": "Bone mass",
"typeOxygenSaturation": "Oxygen saturation",
"typeHeartRateVariability": "Heart-rate variability",
"typeRestingHeartRate": "Resting heart rate",
"typeActiveEnergyBurned": "Active energy",
"typeFlightsClimbed": "Flights climbed",
"typeWalkingRunningDistance": "Walking + running distance",
"typeVo2Max": "VO₂ max",
"typeBodyTemperature": "Body temperature",
"glucoseContext": "Context",
"glucoseContextFasting": "Fasting (≥8h without food)",
"glucoseContextPostprandial": "Post-meal (2h after eating)",
@@ -0,0 +1,67 @@
-- v1.4.23 — Apple Health backend foundation
--
-- Adds the enum values, columns, and composite unique index the
-- HealthKit batch-ingest endpoint needs. Strictly additive:
-- * No existing rows are rewritten.
-- * New columns are nullable.
-- * New enum values append (no reordering).
-- * The new composite unique index treats NULL externalId rows as
-- distinct (Postgres semantics) so manual entries — which have no
-- external id — don't collide.
--
-- Unit semantics change (advisory, no row mutation): `SLEEP_DURATION`
-- shifts from hours to minutes so per-stage HealthKit category samples
-- can be stored without precision loss. No production data exists for
-- this enum value yet (per the v1.4.23 W1 research), so the change is
-- effectively schema-only — the application enforces the new unit going
-- forward via `getUnitForType()`.
-- ── MeasurementType — append 7 Apple-Health-shaped enum values ──────
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'HEART_RATE_VARIABILITY';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'RESTING_HEART_RATE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'ACTIVE_ENERGY_BURNED';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'FLIGHTS_CLIMBED';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_RUNNING_DISTANCE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'VO2_MAX';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BODY_TEMPERATURE';
-- ── MeasurementSource — append APPLE_HEALTH ─────────────────────────
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'APPLE_HEALTH';
-- ── SleepStage — new enum mirroring HKCategoryValueSleepAnalysis ────
CREATE TYPE "sleep_stage" AS ENUM (
'IN_BED',
'AWAKE',
'ASLEEP',
'REM',
'CORE',
'DEEP'
);
-- ── Measurement — new nullable columns + composite unique index ─────
ALTER TABLE "measurements"
ADD COLUMN "external_source_version" TEXT,
ADD COLUMN "sleep_stage" "sleep_stage";
-- Enforce: sleep_stage is only set for SLEEP_DURATION rows. Mirrors the
-- glucose_context CHECK constraint from migration 0021.
ALTER TABLE "measurements"
ADD CONSTRAINT "measurements_sleep_stage_requires_type"
CHECK (
sleep_stage IS NULL
OR (sleep_stage IS NOT NULL AND type = 'SLEEP_DURATION')
);
-- Composite unique index — dedup key for the Apple Health batch ingest
-- endpoint. Postgres treats NULL as distinct in unique indexes, so
-- manual entries (external_id = NULL) don't collide with each other or
-- with imported rows. Withings ingest historically used externalId
-- alongside the legacy `(userId, type, measuredAt, source)` unique
-- index — both indexes coexist; the legacy one stays as the manual-UI
-- "no duplicate at the same wall-clock" guard.
CREATE UNIQUE INDEX "measurements_user_id_type_source_external_id_key"
ON "measurements" ("user_id", "type", "source", "external_id");
+45 -3
View File
@@ -192,12 +192,20 @@ enum MeasurementType {
BLOOD_PRESSURE_DIA
PULSE
BODY_FAT
SLEEP_DURATION
SLEEP_DURATION // v1.4.23: unit semantics shifted from hours to minutes so HealthKit category samples (per-stage rows) survive without precision loss.
ACTIVITY_STEPS
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.
// ── 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).
ACTIVE_ENERGY_BURNED // kcal — HealthKit `activeEnergyBurned`, cumulative; daily-rollup pattern.
FLIGHTS_CLIMBED // count — HealthKit `flightsClimbed`, cumulative.
WALKING_RUNNING_DISTANCE // metres — HealthKit `distanceWalkingRunning`; SI metres in DB, UI converts per locale.
VO2_MAX // mL/(kg·min) — HealthKit `vo2Max`; one sample every few days at most.
BODY_TEMPERATURE // celsius — HealthKit `bodyTemperature`; iOS DTO already encodes this enum value.
@@map("measurement_type")
}
@@ -217,28 +225,62 @@ enum MeasurementSource {
MANUAL
WITHINGS
IMPORT
APPLE_HEALTH // v1.4.23 — HealthKit batch ingest. iOS DTO mirrors this exact spelling (not "HEALTHKIT").
@@map("measurement_source")
}
/// Per-stage sleep label for HealthKit `HKCategoryValueSleepAnalysis`
/// samples. Non-NULL only when `Measurement.type = SLEEP_DURATION`,
/// mirroring the `GlucoseContext` pattern. A single night produces one
/// row per stage (analytics sums them for `total_minutes`).
enum SleepStage {
IN_BED
AWAKE
ASLEEP // legacy iOS 15- `asleepUnspecified` — pre-iOS-16 sleep tracking with no stage breakdown.
REM
CORE
DEEP
@@map("sleep_stage")
}
model Measurement {
id String @id @default(cuid())
userId String @map("user_id")
type MeasurementType
value Float
unit String // kg, mmHg, bpm, %, hours, steps, mg/dL
unit String // kg, mmHg, bpm, %, minutes (sleep), steps, mg/dL, kcal, m, ms, mL/(kg·min), celsius
source MeasurementSource @default(MANUAL)
measuredAt DateTime @map("measured_at")
notes String? // encrypted at app level if sensitive
externalId String? @map("external_id") // Withings grpid for dedup
// External-system dedup key. Withings used `grpid` (numeric); v1.4.23
// adds Apple Health which uses `HKSample.uuid` (string). NULL for
// manual entries — Postgres treats NULLs as distinct in unique
// indexes, so two manual rows with the same (user, type, source) are
// still allowed.
externalId String? @map("external_id")
// v1.4.23 — opaque marker for the source's schema version (HealthKit
// sample's `metadata.HKWasUserEntered`, Withings model code, …).
// Stored as a string so the ingest path can replay it back unchanged.
externalSourceVersion String? @map("external_source_version")
// Only set for BLOOD_GLUCOSE measurements. NULL for all others.
glucoseContext GlucoseContext? @map("glucose_context")
// v1.4.23 — per-stage sleep label. NULL for non-sleep rows; non-NULL
// only for `type = SLEEP_DURATION`. Mirrors the glucoseContext
// pattern. A single night produces ~5 rows (one per stage).
sleepStage SleepStage? @map("sleep_stage")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Original measuredAt-based dedup — kept for legacy Withings ingest
// and the manual UI's "no duplicate at the same wall-clock" guard.
@@unique([userId, type, measuredAt, source])
// v1.4.23 — Apple Health batch dedup key. NULL externalId is distinct
// (Postgres semantics) so manual entries don't collide here.
@@unique([userId, type, source, externalId])
@@index([userId, type, measuredAt])
@@index([externalId])
@@map("measurements")
@@ -24,6 +24,11 @@ import {
Footprints,
Bone,
Wind,
HeartPulse,
Flame,
TrendingUp,
Thermometer,
Gauge,
type LucideIcon,
} from "lucide-react";
@@ -39,6 +44,14 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
TOTAL_BODY_WATER: "measurements.typeTotalBodyWater",
BONE_MASS: "measurements.typeBoneMass",
OXYGEN_SATURATION: "measurements.typeOxygenSaturation",
// ── v1.4.23 Apple Health additions ──
HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability",
RESTING_HEART_RATE: "measurements.typeRestingHeartRate",
ACTIVE_ENERGY_BURNED: "measurements.typeActiveEnergyBurned",
FLIGHTS_CLIMBED: "measurements.typeFlightsClimbed",
WALKING_RUNNING_DISTANCE: "measurements.typeWalkingRunningDistance",
VO2_MAX: "measurements.typeVo2Max",
BODY_TEMPERATURE: "measurements.typeBodyTemperature",
};
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
@@ -53,6 +66,14 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
TOTAL_BODY_WATER: Droplet,
BONE_MASS: Bone,
OXYGEN_SATURATION: Wind,
// ── v1.4.23 Apple Health additions ──
HEART_RATE_VARIABILITY: HeartPulse,
RESTING_HEART_RATE: Heart,
ACTIVE_ENERGY_BURNED: Flame,
FLIGHTS_CLIMBED: TrendingUp,
WALKING_RUNNING_DISTANCE: Footprints,
VO2_MAX: Gauge,
BODY_TEMPERATURE: Thermometer,
};
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
@@ -67,4 +88,12 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
TOTAL_BODY_WATER: "bg-chart-2/20 text-chart-2",
BONE_MASS: "bg-chart-4/20 text-chart-4",
OXYGEN_SATURATION: "bg-chart-5/20 text-chart-5",
// ── v1.4.23 Apple Health additions ──
HEART_RATE_VARIABILITY: "bg-chart-5/20 text-chart-5",
RESTING_HEART_RATE: "bg-chart-3/20 text-chart-3",
ACTIVE_ENERGY_BURNED: "bg-chart-4/20 text-chart-4",
FLIGHTS_CLIMBED: "bg-chart-2/20 text-chart-2",
WALKING_RUNNING_DISTANCE: "bg-chart-2/20 text-chart-2",
VO2_MAX: "bg-chart-1/20 text-chart-1",
BODY_TEMPERATURE: "bg-chart-4/20 text-chart-4",
};
@@ -26,10 +26,18 @@ const EXPECTED_TYPES = [
"TOTAL_BODY_WATER",
"BONE_MASS",
"OXYGEN_SATURATION",
// ── v1.4.23 Apple Health additions ──
"HEART_RATE_VARIABILITY",
"RESTING_HEART_RATE",
"ACTIVE_ENERGY_BURNED",
"FLIGHTS_CLIMBED",
"WALKING_RUNNING_DISTANCE",
"VO2_MAX",
"BODY_TEMPERATURE",
] as const;
describe("measurementTypeEnum coverage", () => {
it("exposes the 11 canonical measurement types", () => {
it("exposes the 18 canonical measurement types", () => {
expect([...measurementTypeEnum.options].sort()).toEqual(
[...EXPECTED_TYPES].sort(),
);
@@ -39,12 +47,24 @@ describe("measurementTypeEnum coverage", () => {
// - BLOOD_GLUCOSE renders through the per-context `glucoseStats` section
// - SLEEP_DURATION + ACTIVITY_STEPS are intentionally omitted from the
// clinical PDF (lifestyle, not a vital sign — see source comment).
// - v1.4.23 Apple Health metrics (HRV, resting HR, active energy,
// flights, distance, VO2 max, body temperature) are excluded from
// the v1.4.23 release of the doctor PDF — they ship into the
// clinical surface alongside the iOS app's first paying-customer
// sync in v1.5 once layout + reference ranges are agreed.
// Updates to this set MUST be paired with a comment in
// doctor-report-pdf-core.ts so the rationale stays discoverable.
const PDF_VITAL_EXCLUSIONS = new Set([
"BLOOD_GLUCOSE",
"SLEEP_DURATION",
"ACTIVITY_STEPS",
"HEART_RATE_VARIABILITY",
"RESTING_HEART_RATE",
"ACTIVE_ENERGY_BURNED",
"FLIGHTS_CLIMBED",
"WALKING_RUNNING_DISTANCE",
"VO2_MAX",
"BODY_TEMPERATURE",
]);
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
+10
View File
@@ -33,6 +33,16 @@ export const ALLOWED_CHART_TOKENS = [
"metric:BONE_MASS",
"metric:OXYGEN_SATURATION",
"metric:MOOD",
// v1.4.23 — Apple Health additions. The tokens are accepted by the
// allowlist so the LLM can reference them; the corresponding chart
// components ship in v1.5 alongside the iOS app surface.
"metric:HEART_RATE_VARIABILITY",
"metric:RESTING_HEART_RATE",
"metric:ACTIVE_ENERGY_BURNED",
"metric:FLIGHTS_CLIMBED",
"metric:WALKING_RUNNING_DISTANCE",
"metric:VO2_MAX",
"metric:BODY_TEMPERATURE",
] as const;
export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number];
@@ -13,11 +13,21 @@ describe("measurement validation", () => {
expect(getUnitForType("BLOOD_PRESSURE_DIA")).toBe("mmHg");
expect(getUnitForType("PULSE")).toBe("bpm");
expect(getUnitForType("BODY_FAT")).toBe("%");
expect(getUnitForType("SLEEP_DURATION")).toBe("hours");
// v1.4.23 — sleep duration shifted from hours to minutes so per-stage
// HealthKit category samples can be stored without precision loss.
expect(getUnitForType("SLEEP_DURATION")).toBe("minutes");
expect(getUnitForType("ACTIVITY_STEPS")).toBe("steps");
expect(getUnitForType("BLOOD_GLUCOSE")).toBe("mg/dL");
expect(getUnitForType("TOTAL_BODY_WATER")).toBe("kg");
expect(getUnitForType("BONE_MASS")).toBe("kg");
// ── v1.4.23 Apple Health canonical units ──
expect(getUnitForType("HEART_RATE_VARIABILITY")).toBe("ms");
expect(getUnitForType("RESTING_HEART_RATE")).toBe("bpm");
expect(getUnitForType("ACTIVE_ENERGY_BURNED")).toBe("kcal");
expect(getUnitForType("FLIGHTS_CLIMBED")).toBe("flights");
expect(getUnitForType("WALKING_RUNNING_DISTANCE")).toBe("m");
expect(getUnitForType("VO2_MAX")).toBe("mL/(kg·min)");
expect(getUnitForType("BODY_TEMPERATURE")).toBe("celsius");
});
it("returns 'unknown' for unrecognised types", () => {
+59 -3
View File
@@ -12,6 +12,14 @@ export const measurementTypeEnum = z.enum([
"TOTAL_BODY_WATER",
"BONE_MASS",
"OXYGEN_SATURATION",
// ── v1.4.23 Apple Health additions ──
"HEART_RATE_VARIABILITY",
"RESTING_HEART_RATE",
"ACTIVE_ENERGY_BURNED",
"FLIGHTS_CLIMBED",
"WALKING_RUNNING_DISTANCE",
"VO2_MAX",
"BODY_TEMPERATURE",
]);
export const glucoseContextEnum = z.enum([
@@ -21,7 +29,21 @@ export const glucoseContextEnum = z.enum([
"BEDTIME",
]);
export const measurementSourceEnum = z.enum(["MANUAL", "WITHINGS", "IMPORT"]);
export const sleepStageEnum = z.enum([
"IN_BED",
"AWAKE",
"ASLEEP",
"REM",
"CORE",
"DEEP",
]);
export const measurementSourceEnum = z.enum([
"MANUAL",
"WITHINGS",
"IMPORT",
"APPLE_HEALTH",
]);
const unitMap: Record<string, string> = {
WEIGHT: "kg",
@@ -29,12 +51,23 @@ const unitMap: Record<string, string> = {
BLOOD_PRESSURE_DIA: "mmHg",
PULSE: "bpm",
BODY_FAT: "%",
SLEEP_DURATION: "hours",
// v1.4.23 — sleep duration shifted from hours to minutes so HealthKit
// category-sample stages can be stored without precision loss. Older
// surfaces that need hours convert at read time (`minutes / 60`).
SLEEP_DURATION: "minutes",
ACTIVITY_STEPS: "steps",
BLOOD_GLUCOSE: "mg/dL",
TOTAL_BODY_WATER: "kg",
BONE_MASS: "kg",
OXYGEN_SATURATION: "%",
// ── v1.4.23 Apple Health canonical units ──
HEART_RATE_VARIABILITY: "ms",
RESTING_HEART_RATE: "bpm",
ACTIVE_ENERGY_BURNED: "kcal",
FLIGHTS_CLIMBED: "flights",
WALKING_RUNNING_DISTANCE: "m",
VO2_MAX: "mL/(kg·min)",
BODY_TEMPERATURE: "celsius",
};
export function getUnitForType(type: string): string {
@@ -48,7 +81,10 @@ const VALUE_RANGES: Record<string, { min: number; max: number }> = {
BLOOD_PRESSURE_DIA: { min: 20, max: 200 },
PULSE: { min: 20, max: 300 },
BODY_FAT: { min: 1, max: 80 },
SLEEP_DURATION: { min: 0, max: 24 },
// Minutes (v1.4.23 unit change). Per-stage rows can run from a few
// seconds (a brief awakening) to 600+ minutes (a long IN_BED block),
// so the upper bound covers a 24-hour day.
SLEEP_DURATION: { min: 0, max: 1440 },
ACTIVITY_STEPS: { min: 0, max: 200000 },
BLOOD_GLUCOSE: { min: 20, max: 800 }, // mg/dL — covers severe hypo to severe hyperglycemia
TOTAL_BODY_WATER: { min: 5, max: 100 }, // kg of water — adults typically 30–55 kg
@@ -58,6 +94,26 @@ const VALUE_RANGES: Record<string, { min: number; max: number }> = {
// critically-low reading still gets logged for the doctor to see; below 50%
// is incompatible with sustained life and almost certainly a sensor glitch.
OXYGEN_SATURATION: { min: 50, max: 100 },
// ── v1.4.23 Apple Health ranges ──
// HRV SDNN ms — Apple's own "high HRV" threshold sits ~80 ms; lows can
// reach 5–10 ms in stressed/sick samples. 200 ms is a generous upper
// bound for unusually relaxed athletic windows.
HEART_RATE_VARIABILITY: { min: 1, max: 200 },
// Resting HR bpm — endurance athletes can reach the high 30s; severe
// tachycardia caps below 220.
RESTING_HEART_RATE: { min: 25, max: 220 },
// Active energy kcal/sample — a daily-rollup sample tops out around
// 6–8000 kcal even for ultra-endurance days.
ACTIVE_ENERGY_BURNED: { min: 0, max: 10000 },
// Flights — vertical-feet/3 ≈ flights; cap generous.
FLIGHTS_CLIMBED: { min: 0, max: 1000 },
// Walking/running distance per sample (metres). 200 km covers an
// ultra-marathon or a multi-stage day.
WALKING_RUNNING_DISTANCE: { min: 0, max: 200000 },
// VO2 max mL/(kg·min) — elite endurance hovers ~85; sedentary floor ~10.
VO2_MAX: { min: 5, max: 100 },
// Body temperature °C — survivable lows ~28; severe hyperthermia ~45.
BODY_TEMPERATURE: { min: 28, max: 45 },
};
export function validateMeasurementRange(