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

@@ -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(