diff --git a/prisma/migrations/0052_apple_health_enum_extensions/migration.sql b/prisma/migrations/0052_apple_health_enum_extensions/migration.sql new file mode 100644 index 000000000..1945ebe97 --- /dev/null +++ b/prisma/migrations/0052_apple_health_enum_extensions/migration.sql @@ -0,0 +1,30 @@ +-- v1.4.25 W8d — Apple Health server-contract prep. Three new +-- MeasurementType enum values close the iOS-17/18 HK coverage gap +-- before the v1.5 iOS-Swift session: +-- * AUDIO_EXPOSURE_ENV — HK `environmentalAudioExposure` +-- (ambient noise; dBA SPL). Watch + iPhone microphone samples. +-- * AUDIO_EXPOSURE_HEADPHONE — HK `headphoneAudioExposure` +-- (AirPods listening-volume sampling; dBA SPL). +-- * TIME_IN_DAYLIGHT — HK `timeInDaylight` (iOS 17+; +-- minutes/day). Mood + sleep correlate. +-- +-- The previous task brief listed a fourth value, `WORKOUT_ROUTE`. It +-- has been dropped: workouts are first-class entities with their own +-- `Workout` table (Migration 0053) and the GPS route geometry lives +-- in `WorkoutRoute`. Apple HealthKit also separates `HKWorkout` from +-- `HKQuantitySample`. Carrying a sentinel enum value on the +-- Measurement table when no real Measurement row will ever be of +-- that type would be a dead slot that confuses every downstream +-- code path (analytics summariser, doctor-PDF allow-list, chart +-- registry). Forward-additive — a future analytics rollup row can +-- still be carried on a fresh enum value if the use case ever +-- materialises. +-- +-- Additive only, forward-only — every new enum value is unconditional +-- and no existing row needs to be backfilled. The Apple Health +-- mapping (`src/lib/measurements/apple-health-mapping.ts`) gains the +-- corresponding HK identifier rows in the same release. + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'AUDIO_EXPOSURE_ENV'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'AUDIO_EXPOSURE_HEADPHONE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'TIME_IN_DAYLIGHT'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a5e25b337..8830d020d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -263,6 +263,10 @@ enum MeasurementType { 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. + // ── v1.4.25 W8d Apple Health server-prep ── + AUDIO_EXPOSURE_ENV // dBA — HealthKit `environmentalAudioExposure`; ambient noise sample (e.g. concert, traffic). Watch + iPhone microphone. + AUDIO_EXPOSURE_HEADPHONE // dBA — HealthKit `headphoneAudioExposure`; AirPods listening-volume sampling. + TIME_IN_DAYLIGHT // minutes — HealthKit `timeInDaylight` (iOS 17+). Sun-exposure daily rollup; mood + sleep correlate. @@map("measurement_type") } diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index 745e6e1e9..c87cba09b 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -30,6 +30,9 @@ import { Thermometer, Gauge, Dumbbell, + Volume2, + Headphones, + Sun, type LucideIcon, } from "lucide-react"; @@ -61,6 +64,10 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { PULSE_WAVE_VELOCITY: "measurements.typePulseWaveVelocity", VASCULAR_AGE: "measurements.typeVascularAge", VISCERAL_FAT: "measurements.typeVisceralFat", + // ── v1.4.25 W8d Apple Health server-prep ── + AUDIO_EXPOSURE_ENV: "measurements.typeAudioExposureEnv", + AUDIO_EXPOSURE_HEADPHONE: "measurements.typeAudioExposureHeadphone", + TIME_IN_DAYLIGHT: "measurements.typeTimeInDaylight", }; export const MEASUREMENT_TYPE_ICONS: Record = { @@ -96,6 +103,13 @@ export const MEASUREMENT_TYPE_ICONS: Record = { PULSE_WAVE_VELOCITY: Activity, VASCULAR_AGE: HeartPulse, VISCERAL_FAT: Droplets, + // ── v1.4.25 W8d Apple Health server-prep ── + // Volume2 carries the ambient-audio family (concert/traffic icon + // convention), Headphones is the obvious AirPods-listening cue, and + // Sun mirrors Apple Health's own time-in-daylight tile. + AUDIO_EXPOSURE_ENV: Volume2, + AUDIO_EXPOSURE_HEADPHONE: Headphones, + TIME_IN_DAYLIGHT: Sun, }; export const MEASUREMENT_TYPE_COLORS: Record = { @@ -128,4 +142,11 @@ export const MEASUREMENT_TYPE_COLORS: Record = { PULSE_WAVE_VELOCITY: "bg-chart-5/20 text-chart-5", VASCULAR_AGE: "bg-chart-3/20 text-chart-3", VISCERAL_FAT: "bg-chart-4/20 text-chart-4", + // ── v1.4.25 W8d Apple Health server-prep ── + // chart-5 (pulse / sound family) carries audio exposure; chart-2 + // (activity / daylight family) carries time-in-daylight so the + // existing palette conventions hold. + AUDIO_EXPOSURE_ENV: "bg-chart-5/20 text-chart-5", + AUDIO_EXPOSURE_HEADPHONE: "bg-chart-5/20 text-chart-5", + TIME_IN_DAYLIGHT: "bg-chart-2/20 text-chart-2", }; diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index c4a08dd77..5dffea956 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -42,10 +42,14 @@ const EXPECTED_TYPES = [ "PULSE_WAVE_VELOCITY", "VASCULAR_AGE", "VISCERAL_FAT", + // ── v1.4.25 W8d Apple Health server-prep ── + "AUDIO_EXPOSURE_ENV", + "AUDIO_EXPOSURE_HEADPHONE", + "TIME_IN_DAYLIGHT", ] as const; describe("measurementTypeEnum coverage", () => { - it("exposes the 25 canonical measurement types", () => { + it("exposes the 28 canonical measurement types", () => { expect([...measurementTypeEnum.options].sort()).toEqual( [...EXPECTED_TYPES].sort(), ); @@ -85,6 +89,13 @@ describe("measurementTypeEnum coverage", () => { "PULSE_WAVE_VELOCITY", "VASCULAR_AGE", "VISCERAL_FAT", + // v1.4.25 W8d Apple Health additions held under the same v1.5 gate. + // Audio exposure and time-in-daylight are lifestyle / environment + // metrics; reference ranges + clinical layout land alongside the + // first iOS-app sync, not the v1.4.25 PDF. + "AUDIO_EXPOSURE_ENV", + "AUDIO_EXPOSURE_HEADPHONE", + "TIME_IN_DAYLIGHT", ]); 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 c8b6e2384..f231f064d 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -54,6 +54,12 @@ export const ALLOWED_CHART_TOKENS = [ "metric:PULSE_WAVE_VELOCITY", "metric:VASCULAR_AGE", "metric:VISCERAL_FAT", + // v1.4.25 W8d — Apple Health server-prep. Same posture as v1.4.23 / + // W5d: the LLM can reference these in prose; the corresponding + // dedicated chart components land alongside the iOS-app sync. + "metric:AUDIO_EXPOSURE_ENV", + "metric:AUDIO_EXPOSURE_HEADPHONE", + "metric:TIME_IN_DAYLIGHT", ] as const; export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number]; @@ -126,6 +132,11 @@ const ORPHAN_ENUMS = [ "PULSE_WAVE_VELOCITY", "VASCULAR_AGE", "VISCERAL_FAT", + // v1.4.25 W8d Apple Health server-prep — multi-word upper-snake + // names that should never surface verbatim in user-facing prose. + "AUDIO_EXPOSURE_ENV", + "AUDIO_EXPOSURE_HEADPHONE", + "TIME_IN_DAYLIGHT", ] as const; // `\b` boundaries keep ordinary English prose untouched — "weight" diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index a78434c5d..e51f30b6c 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -28,6 +28,10 @@ export const measurementTypeEnum = z.enum([ "PULSE_WAVE_VELOCITY", "VASCULAR_AGE", "VISCERAL_FAT", + // ── v1.4.25 W8d Apple Health server-prep ── + "AUDIO_EXPOSURE_ENV", + "AUDIO_EXPOSURE_HEADPHONE", + "TIME_IN_DAYLIGHT", ]); export const glucoseContextEnum = z.enum([ @@ -88,6 +92,17 @@ const unitMap: Record = { // Withings reports visceral fat as a 1–12 rating, not a percent. The // string mirrors what Withings prints in Health Mate. VISCERAL_FAT: "rating", + // ── v1.4.25 W8d Apple Health server-prep ── + // Sound-pressure level — A-weighted decibels (dBA). HealthKit reports + // both audio-exposure metrics in dBASPL; we store the unweighted "dBA" + // label because the A-weighting is implicit (every HealthKit audio + // sample carries it). 30 dBA = quiet bedroom; 140 dBA = pain threshold. + AUDIO_EXPOSURE_ENV: "dBA", + AUDIO_EXPOSURE_HEADPHONE: "dBA", + // Daily-rollup pattern (one sample = one day's outdoor-light minutes). + // 0–1440 covers the 24-hour day; in practice indoor users sit near 0 + // and outdoor athletes accumulate a few hours. + TIME_IN_DAYLIGHT: "minutes", }; export function getUnitForType(type: string): string { @@ -155,6 +170,18 @@ const VALUE_RANGES: Record = { VASCULAR_AGE: { min: 10, max: 130 }, // Visceral fat rating 1–12 (Withings' own scale; not a percent). VISCERAL_FAT: { min: 0, max: 30 }, + // ── v1.4.25 W8d Apple Health server-prep ── + // Audio exposure dBA — Apple's "loud audio" warning sits at 80 dBA; + // concerts run 100–115 dBA; 140 dBA is the pain threshold. The floor + // of 30 dBA covers a quiet bedroom; anything below is silence and + // almost certainly a sensor artefact. + AUDIO_EXPOSURE_ENV: { min: 30, max: 140 }, + // Headphone audio caps slightly below the open-air upper edge because + // sealed-driver listening physically cannot reach a concert PA's SPL. + AUDIO_EXPOSURE_HEADPHONE: { min: 30, max: 140 }, + // Time in daylight (minutes/day) — full 24-hour window. Outdoor work + // tops the range; sedentary indoor days sit near 0. + TIME_IN_DAYLIGHT: { min: 0, max: 1440 }, }; export function validateMeasurementRange(