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