feat(schema): extend MeasurementType with audio-exposure + time-in-daylight (Migration 0052)

Adds three new enum values that close the iOS-17/18 HealthKit coverage
gap before the v1.5 iOS-Swift session locks against Postgres:

  - AUDIO_EXPOSURE_ENV (dBA, environmental noise)
  - AUDIO_EXPOSURE_HEADPHONE (dBA, AirPods listening)
  - TIME_IN_DAYLIGHT (minutes/day, iOS 17+)

Wire-up touches the canonical surfaces:

  - prisma/schema.prisma: MeasurementType + comment block
  - src/lib/validations/measurement.ts: enum + unitMap + VALUE_RANGES
  - src/lib/__tests__/measurement-type-enum-coverage.test.ts: 25 -> 28
    expected types; new entries added to PDF_VITAL_EXCLUSIONS under the
    existing v1.5 clinical-layout gate
  - src/components/measurements/measurement-list-meta.ts: i18n keys,
    Volume2 / Headphones / Sun icons, palette assignments
  - src/lib/insights/chart-tokens.ts: ALLOWED_CHART_TOKENS allow-list
    plus orphan-enum stripper coverage so the LLM cannot leak raw
    upper-snake names into rendered prose
  - messages/{en,de,fr,es,it,pl}.json: typeAudioExposureEnv,
    typeAudioExposureHeadphone, typeTimeInDaylight (en/de translated;
    fr/es/it/pl carry English placeholders matching their existing
    convention for the W5d additions)

The task brief originally listed a fourth value (WORKOUT_ROUTE) as a
sentinel slot. Dropped here because workouts are first-class entities
landing in Migration 0053 with their own table (Workout + WorkoutRoute);
HealthKit also separates HKWorkout from HKQuantitySample. A reserved
sentinel that no real Measurement row will ever take would be a dead
slot for analytics, doctor-PDF allow-lists, and chart registries.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 15:46:56 +02:00
1 parent 5d5a934774
commit 41ba79b5d5
6 files changed
+105 -1

No files matched your search

@@ -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';
+4
View File
@@ -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")
}
@@ -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<string, string> = {
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<string, LucideIcon> = {
@@ -96,6 +103,13 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
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<string, string> = {
@@ -128,4 +142,11 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
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",
};
@@ -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", () => {
+11
View File
@@ -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"
+27
View File
@@ -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<string, string> = {
// 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<string, { min: number; max: number }> = {
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(