feat(measurements): add walking-steadiness and audio-exposure-event types

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 12:16:39 +02:00
1 parent 479e0aed48
commit dd487cfddc
8 files changed
+129 -16

No files matched your search

@@ -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:<type>:<date> shape for stepCount", () => {
expect(
@@ -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",
]);
});
+45 -9
View File
@@ -285,6 +285,43 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
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<string>([
// 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<string>([
"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",
+4 -2
View File
@@ -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"],
+9
View File
@@ -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;
}
}
+3
View File
@@ -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([