From dd487cfddcd0766d9cf7d3d0165cfbc2dac8a5e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 12:16:39 +0200 Subject: [PATCH] feat(measurements): add walking-steadiness and audio-exposure-event types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R-F T1.4 + T1.5 close-out. Two new MeasurementType enum values unlock the iOS Tier-1 surfaces Apple's own Health app shows prominently. Schema (migration 0064, additive): - measurement_type gains WALKING_STEADINESS + AUDIO_EXPOSURE_EVENT WALKING_STEADINESS — iOS 15+ Mobility daily rollup. Apple ships a 0..1 fraction; HealthLog stores 0..100 percent (same convention as oxygen saturation + body fat). AUDIO_EXPOSURE_EVENT — iOS 13+ category-type that fires when the rolling 7-day average crosses the WHO 80-dBA loud-listening threshold. Both the environmental + headphone events map to the same MeasurementType so chart-card consumers pick them up uniformly; the notes field carries the source token at iOS write time. Updates ride the same release: - Zod measurementTypeEnum gains both values - apple-health-mapping.ts maps three HK identifiers (AppleWalkingSteadiness, EnvironmentalAudioExposureEvent, HeadphoneAudioExposureEvent) and removes the same identifiers from HK_QUANTITY_TYPE_DEFERRED - categories.ts assigns WALKING_STEADINESS to activity and AUDIO_EXPOSURE_EVENT to hearing - pr-direction.ts pins WALKING_STEADINESS to MAX (higher steadiness is the achievement) and AUDIO_EXPOSURE_EVENT to no-PR (threshold- cross flag, not goal-axis) iOS contract: the two new enum values are net-new — iOS clients that predate the codegen pass will not encounter them in read paths because no source writes them yet. New iOS code paths can post the new HK identifiers via the existing batch endpoint without further server changes. --- .../migration.sql | 15 ++++++ prisma/schema.prisma | 3 ++ .../__tests__/apple-health-mapping.test.ts | 54 +++++++++++++++++-- .../measurements/__tests__/categories.test.ts | 1 + src/lib/measurements/apple-health-mapping.ts | 54 +++++++++++++++---- src/lib/measurements/categories.ts | 6 ++- src/lib/personal-records/pr-direction.ts | 9 ++++ src/lib/validations/measurement.ts | 3 ++ 8 files changed, 129 insertions(+), 16 deletions(-) create mode 100644 prisma/migrations/0064_v1430_measurement_type_additions/migration.sql diff --git a/prisma/migrations/0064_v1430_measurement_type_additions/migration.sql b/prisma/migrations/0064_v1430_measurement_type_additions/migration.sql new file mode 100644 index 000000000..55efc846e --- /dev/null +++ b/prisma/migrations/0064_v1430_measurement_type_additions/migration.sql @@ -0,0 +1,15 @@ +-- v1.4.30 — extend `measurement_type` enum (R-F T1.4 + T1.5) +-- +-- Two additive enum values for Tier-1 HealthKit surfaces: +-- WALKING_STEADINESS — % daily rollup, mobility-risk signal +-- AUDIO_EXPOSURE_EVENT — count flag for loud-listening events +-- +-- Pure additive — no existing row carries either value. Postgres +-- enum extensions run with `IF NOT EXISTS` so the migration is +-- idempotent and runs in a single transaction (the v9.6+ requirement +-- that enum adds must commit before they can be referenced no longer +-- applies because the additive Zod + map updates ship in the same +-- release). + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_STEADINESS'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'AUDIO_EXPOSURE_EVENT'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 61120aee6..9b358c5bd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -329,6 +329,9 @@ enum MeasurementType { 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. + // ── v1.4.30 R-F T1.4 + T1.5 Tier-1 additions ── + WALKING_STEADINESS // percent (0-100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `appleWalkingSteadiness` (iOS 15+). Daily rollup. Falls + mobility-risk signal Apple's own Health app shows prominently in the Mobility section. + AUDIO_EXPOSURE_EVENT // count (event flag: 1 per fired loud-listening event) — HealthKit `environmentalAudioExposureEvent` + `headphoneAudioExposureEvent` category-events. Apple fires these when the rolling 7-day average crosses the WHO 80-dBA threshold. The notes field carries the source identifier (env vs headphone). @@map("measurement_type") } diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 2719769a5..9bf17f521 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -131,8 +131,8 @@ describe("HK_QUANTITY_TYPE_DEFERRED", () => { // Cardiovascular / clinical "HKQuantityTypeIdentifierAtrialFibrillationBurden", "HKQuantityTypeIdentifierPeripheralPerfusionIndex", - // Mobility - "HKQuantityTypeIdentifierAppleWalkingSteadiness", + // Mobility — `AppleWalkingSteadiness` moved into the mapped + // set in v1.4.30. The remaining identifiers stay deferred. "HKQuantityTypeIdentifierNumberOfTimesFallen", "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", // Respiratory / pulmonary @@ -151,9 +151,9 @@ describe("HK_QUANTITY_TYPE_DEFERRED", () => { "HKCategoryTypeIdentifierHighHeartRateEvent", "HKCategoryTypeIdentifierIrregularHeartRhythmEvent", "HKCategoryTypeIdentifierLowCardioFitnessEvent", - // Audio-exposure events - "HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent", - "HKCategoryTypeIdentifierHeadphoneAudioExposureEvent", + // Audio-exposure events — environmental + headphone moved into + // the mapped set in v1.4.30 (AUDIO_EXPOSURE_EVENT). The general + // sound-reduction flag stays deferred. "HKCategoryTypeIdentifierEnvironmentalSoundReduction", // Behavioural / habit "HKCategoryTypeIdentifierHandwashingEvent", @@ -346,6 +346,50 @@ describe("mapAppleHealthEntry", () => { }); }); +describe("v1.4.30 Tier-1 additions (R-F T1.4 + T1.5)", () => { + it("maps appleWalkingSteadiness to WALKING_STEADINESS with × 100 scaling", () => { + const mapping = + APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierAppleWalkingSteadiness; + expect(mapping).toBeDefined(); + expect(mapping.measurementType).toBe("WALKING_STEADINESS"); + expect(mapping.convertToDbUnit(0.85)).toBeCloseTo(85); + expect(mapping.dbUnit).toBe("%"); + }); + + it("maps environmental + headphone audio-exposure events to AUDIO_EXPOSURE_EVENT (count 1)", () => { + const envMapping = + APPLE_HEALTH_TYPE_MAP.HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent; + expect(envMapping).toBeDefined(); + expect(envMapping.measurementType).toBe("AUDIO_EXPOSURE_EVENT"); + expect(envMapping.convertToDbUnit(0)).toBe(1); + expect(envMapping.convertToDbUnit(99)).toBe(1); + + const hpMapping = + APPLE_HEALTH_TYPE_MAP.HKCategoryTypeIdentifierHeadphoneAudioExposureEvent; + expect(hpMapping).toBeDefined(); + expect(hpMapping.measurementType).toBe("AUDIO_EXPOSURE_EVENT"); + expect(hpMapping.convertToDbUnit(0)).toBe(1); + }); + + it("does not double-book the new identifiers as deferred AND mapped", () => { + expect( + HK_QUANTITY_TYPE_DEFERRED.has( + "HKQuantityTypeIdentifierAppleWalkingSteadiness", + ), + ).toBe(false); + expect( + HK_QUANTITY_TYPE_DEFERRED.has( + "HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent", + ), + ).toBe(false); + expect( + HK_QUANTITY_TYPE_DEFERRED.has( + "HKCategoryTypeIdentifierHeadphoneAudioExposureEvent", + ), + ).toBe(false); + }); +}); + describe("dailyStatsExternalId (v1.4.30 — R-A Option A handoff lock)", () => { it("produces the canonical stats:: shape for stepCount", () => { expect( diff --git a/src/lib/measurements/__tests__/categories.test.ts b/src/lib/measurements/__tests__/categories.test.ts index f52fc5a58..6b445a6a6 100644 --- a/src/lib/measurements/__tests__/categories.test.ts +++ b/src/lib/measurements/__tests__/categories.test.ts @@ -49,6 +49,7 @@ describe("measurementTypesForCategory", () => { it("lists every type assigned to a category", () => { expect(measurementTypesForCategory("hearing").sort()).toEqual([ "AUDIO_EXPOSURE_ENV", + "AUDIO_EXPOSURE_EVENT", "AUDIO_EXPOSURE_HEADPHONE", ]); }); diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index c1141bcf6..2aa0a160f 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -285,6 +285,43 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { convertToDbUnit: (v) => v, aggregation: "sum", }, + + // ── v1.4.30 R-F T1.4 + T1.5 Tier-1 additions ──────────────── + // Walking steadiness — iOS 15+ Mobility daily rollup. Apple ships + // a 0..1 fraction; HealthLog stores 0..100 percent (same convention + // as oxygen saturation + body fat). + HKQuantityTypeIdentifierAppleWalkingSteadiness: { + hkIdentifier: "HKQuantityTypeIdentifierAppleWalkingSteadiness", + measurementType: "WALKING_STEADINESS", + hkUnit: "%", + dbUnit: "%", + // Apple ships 0..1 fraction; HealthLog stores 0..100. + convertToDbUnit: (v) => v * 100, + aggregation: "latest", + }, + // Environmental audio-exposure event — iOS 13+ category-type that + // fires when the rolling 7-day average crosses the WHO 80-dBA loud- + // listening threshold. Stored as a 1.0 count per fired event; the + // `notes` field carries the source token ("env" vs "headphone"). + HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent: { + hkIdentifier: "HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent", + measurementType: "AUDIO_EXPOSURE_EVENT", + hkUnit: "count", + dbUnit: "count", + convertToDbUnit: () => 1, + aggregation: "sum", + }, + // Headphone audio-exposure event — same shape as the environmental + // sibling; both share the AUDIO_EXPOSURE_EVENT MeasurementType so + // chart-card consumers can pick them up uniformly. + HKCategoryTypeIdentifierHeadphoneAudioExposureEvent: { + hkIdentifier: "HKCategoryTypeIdentifierHeadphoneAudioExposureEvent", + measurementType: "AUDIO_EXPOSURE_EVENT", + hkUnit: "count", + dbUnit: "count", + convertToDbUnit: () => 1, + aggregation: "sum", + }, }; /** @@ -467,9 +504,9 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ // opt-in than the existing Health-share prompt. "HKQuantityTypeIdentifierAtrialFibrillationBurden", "HKQuantityTypeIdentifierPeripheralPerfusionIndex", - // Mobility (iOS 15+) — surface as a wellness signal in v1.5 once the - // Insights cardio sub-page has room for a steadiness gauge. - "HKQuantityTypeIdentifierAppleWalkingSteadiness", + // Mobility (iOS 15+) — `AppleWalkingSteadiness` moved into the + // mapping table in v1.4.30 (R-F T1.5). The remaining identifiers + // stay deferred until a wellness sub-page surface lands. "HKQuantityTypeIdentifierNumberOfTimesFallen", "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", // Respiratory / pulmonary clinical (iOS 17) — pair with FHIR clinical @@ -491,12 +528,11 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ "HKCategoryTypeIdentifierHighHeartRateEvent", "HKCategoryTypeIdentifierIrregularHeartRhythmEvent", "HKCategoryTypeIdentifierLowCardioFitnessEvent", - // Audio-exposure events (iOS 13+) — the continuous AUDIO_EXPOSURE_* - // quantity identifiers ARE mapped above; these are the - // "loud-event-fired" flags that pair with them. Defer until we - // surface event chips in the Insights audio sub-page. - "HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent", - "HKCategoryTypeIdentifierHeadphoneAudioExposureEvent", + // Audio-exposure events (iOS 13+) — Environmental + Headphone + // moved into the mapping table in v1.4.30 (R-F T1.4) as + // AUDIO_EXPOSURE_EVENT. The general "sound reduction" flag stays + // deferred until we surface event chips in the Insights audio + // sub-page. "HKCategoryTypeIdentifierEnvironmentalSoundReduction", // Behavioural / habit category-types — not in HealthLog scope yet. "HKCategoryTypeIdentifierHandwashingEvent", diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index 9c2d83c71..a522aafae 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -77,19 +77,21 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< ["BONE_MASS", "body"], ["VISCERAL_FAT", "body"], - // ── Activity (cumulative + fitness) ── + // ── Activity (cumulative + fitness + mobility) ── ["ACTIVITY_STEPS", "activity"], ["ACTIVE_ENERGY_BURNED", "activity"], ["FLIGHTS_CLIMBED", "activity"], ["WALKING_RUNNING_DISTANCE", "activity"], ["VO2_MAX", "activity"], + ["WALKING_STEADINESS", "activity"], // ── Sleep ── ["SLEEP_DURATION", "sleep"], - // ── Hearing (audio exposure quantities) ── + // ── Hearing (audio exposure quantities + events) ── ["AUDIO_EXPOSURE_ENV", "hearing"], ["AUDIO_EXPOSURE_HEADPHONE", "hearing"], + ["AUDIO_EXPOSURE_EVENT", "hearing"], // ── Environment ── ["TIME_IN_DAYLIGHT", "environment"], diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 7b032a795..4e3385243 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -55,6 +55,10 @@ export function getPRDirection( case "BONE_MASS": case "MUSCLE_MASS": case "TIME_IN_DAYLIGHT": + // v1.4.30 — walking steadiness on a 0-100 scale; Apple's own + // Mobility section treats higher as the achievement (the + // recovery direction from a low-steadiness window). + case "WALKING_STEADINESS": return PersonalRecordDirection.MAX; // MIN direction — lower value is the record. @@ -79,6 +83,11 @@ export function getPRDirection( case "WEIGHT": case "SLEEP_DURATION": case "FAT_FREE_MASS": + // v1.4.30 — audio-exposure events fire on a threshold-cross, not + // on a goal axis. A "record" for "fewest loud-listening days in + // a row" is interesting but better surfaced as a streak than a + // PR direction. Defer. + case "AUDIO_EXPOSURE_EVENT": return null; } } diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 73668a9e3..5625a7048 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -32,6 +32,9 @@ export const measurementTypeEnum = z.enum([ "AUDIO_EXPOSURE_ENV", "AUDIO_EXPOSURE_HEADPHONE", "TIME_IN_DAYLIGHT", + // ── v1.4.30 R-F T1.4 + T1.5 ── + "WALKING_STEADINESS", + "AUDIO_EXPOSURE_EVENT", ]); export const glucoseContextEnum = z.enum([