diff --git a/prisma/migrations/0049_withings_full_metrics/migration.sql b/prisma/migrations/0049_withings_full_metrics/migration.sql new file mode 100644 index 000000000..2d766e514 --- /dev/null +++ b/prisma/migrations/0049_withings_full_metrics/migration.sql @@ -0,0 +1,25 @@ +-- v1.4.25 W5d — Withings full coverage. Adds seven new +-- MeasurementType enum values so the Withings ingest path can land +-- body composition (Body+, Body Cardio, Body Comp, Body Scan), +-- skin temperature (ScanWatch dermal reading — DISTINCT from core +-- body temperature; sharing the bucket would corrupt analytics +-- because surface temps run ~32 °C and core ~37 °C), pulse-wave +-- velocity + vascular age (Body Cardio / Body Scan exclusives), and +-- visceral fat (Body Comp / Body Scan rating). +-- +-- See `.planning/research/withings-api-coverage.md` §3 for the gap +-- scoring + the rationale of which meastypes ship in v1.4.25 vs +-- defer to v1.5. +-- +-- Additive only, forward-only — every new enum value is unconditional +-- and no existing row needs to be backfilled. The Withings client +-- starts emitting these values once `src/lib/withings/client.ts` +-- gains the meastype → enum mapping (separate commit). + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'FAT_FREE_MASS'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'FAT_MASS'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'MUSCLE_MASS'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'SKIN_TEMPERATURE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'PULSE_WAVE_VELOCITY'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'VASCULAR_AGE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'VISCERAL_FAT'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d04123f10..9911e498d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -255,6 +255,14 @@ enum MeasurementType { 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. + // ── v1.4.25 W5d Withings full coverage ── + FAT_FREE_MASS // kg — Withings meastype 5 (Body+, Body Cardio, Body Comp, Body Scan). Algorithmically = weight − fat mass. + FAT_MASS // kg — Withings meastype 8. Mass form of BODY_FAT (kg, not %). + MUSCLE_MASS // kg — Withings meastype 76. Body+ family. + SKIN_TEMPERATURE // celsius — Withings meastype 73 (ScanWatch dermal reading). DISTINCT from BODY_TEMPERATURE — surface temps run ~32 °C, core ~37 °C; sharing a bucket would corrupt analytics. + PULSE_WAVE_VELOCITY // m/s — Withings meastype 91 (Body Cardio / Body Scan). Indicator of arterial stiffness. + VASCULAR_AGE // years — Withings meastype 155 (Body Scan). Composite of PWV + age. + VISCERAL_FAT // rating — Withings meastype 170 (Body Comp / Body Scan). Withings' rating scale (1–12); not directly comparable to a percent. @@map("measurement_type") } diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 0ea54ace3..a78434c5d 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -20,6 +20,14 @@ export const measurementTypeEnum = z.enum([ "WALKING_RUNNING_DISTANCE", "VO2_MAX", "BODY_TEMPERATURE", + // ── v1.4.25 W5d Withings full coverage ── + "FAT_FREE_MASS", + "FAT_MASS", + "MUSCLE_MASS", + "SKIN_TEMPERATURE", + "PULSE_WAVE_VELOCITY", + "VASCULAR_AGE", + "VISCERAL_FAT", ]); export const glucoseContextEnum = z.enum([ @@ -68,6 +76,18 @@ const unitMap: Record = { WALKING_RUNNING_DISTANCE: "m", VO2_MAX: "mL/(kg·min)", BODY_TEMPERATURE: "celsius", + // ── v1.4.25 W5d Withings full coverage ── + FAT_FREE_MASS: "kg", + FAT_MASS: "kg", + MUSCLE_MASS: "kg", + // Distinct from BODY_TEMPERATURE — surface temps run ~32 °C; sharing + // the bucket would corrupt analytics. Same canonical unit (°C). + SKIN_TEMPERATURE: "celsius", + PULSE_WAVE_VELOCITY: "m/s", + VASCULAR_AGE: "years", + // Withings reports visceral fat as a 1–12 rating, not a percent. The + // string mirrors what Withings prints in Health Mate. + VISCERAL_FAT: "rating", }; export function getUnitForType(type: string): string { @@ -114,6 +134,27 @@ const VALUE_RANGES: Record = { VO2_MAX: { min: 5, max: 100 }, // Body temperature °C — survivable lows ~28; severe hyperthermia ~45. BODY_TEMPERATURE: { min: 28, max: 45 }, + // ── v1.4.25 W5d Withings full coverage ── + // Fat-free mass kg — adult plausibility, ~30 kg (small adult) to 120 kg + // (large lean athlete). Same bounds as weight minus a fat-mass floor. + FAT_FREE_MASS: { min: 10, max: 250 }, + // Fat mass kg — pairs with FAT_FREE_MASS so totals reconcile to weight. + FAT_MASS: { min: 0, max: 250 }, + // Muscle mass kg — sub-component of fat-free mass; widest sensible bound. + MUSCLE_MASS: { min: 5, max: 200 }, + // Skin temperature °C — surface temps run cooler than core. ScanWatch's + // dermal sensor reports a relative offset that lands in the 25–40 °C + // band; treat anything outside as a sensor glitch. + SKIN_TEMPERATURE: { min: 20, max: 45 }, + // Pulse-wave velocity m/s — clinical ranges sit ~4–15 m/s. Higher + // numbers indicate stiffer arteries (cardiovascular risk). + PULSE_WAVE_VELOCITY: { min: 1, max: 30 }, + // Vascular age in years — Withings derives this from PWV + chronological + // age; the value is a "biological age" not a chronological one. Hard cap + // at 130 because that's the human longevity record. + VASCULAR_AGE: { min: 10, max: 130 }, + // Visceral fat rating 1–12 (Withings' own scale; not a percent). + VISCERAL_FAT: { min: 0, max: 30 }, }; export function validateMeasurementRange(