diff --git a/messages/de.json b/messages/de.json index 84abc175d..52dff214e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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)", diff --git a/messages/en.json b/messages/en.json index 18686dd38..9ec1a84bd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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)", diff --git a/prisma/migrations/0036_apple_health_measurement_types/migration.sql b/prisma/migrations/0036_apple_health_measurement_types/migration.sql new file mode 100644 index 000000000..251bd30e7 --- /dev/null +++ b/prisma/migrations/0036_apple_health_measurement_types/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 10c6a0e0e..5ab916862 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index b61dafc1f..0f0f53b8a 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -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 = { 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 = { @@ -53,6 +66,14 @@ export const MEASUREMENT_TYPE_ICONS: Record = { 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 = { @@ -67,4 +88,12 @@ export const MEASUREMENT_TYPE_COLORS: Record = { 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", }; diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index 2a324991f..efd9ecbb5 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -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", () => { diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index 79bf35225..c9f5b0693 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -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]; diff --git a/src/lib/validations/__tests__/measurement.test.ts b/src/lib/validations/__tests__/measurement.test.ts index 87c0b92b9..54e2d919b 100644 --- a/src/lib/validations/__tests__/measurement.test.ts +++ b/src/lib/validations/__tests__/measurement.test.ts @@ -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", () => { diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 4dcdf4523..0ea54ace3 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -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 = { WEIGHT: "kg", @@ -29,12 +51,23 @@ const unitMap: Record = { 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 = { 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 = { // 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(