From 7f95a2a44f4bd0b536b55f0625c33c820255dc16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 28 May 2026 23:37:44 +0200 Subject: [PATCH] fix(measurements): wire walking step length and walking speed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iOS team had gated both client-side waiting on the server map. Both flow raw SI on the wire (metres and metres per second respectively) — the server-side ×100 scaling the percent gait metrics use does NOT apply here. The convention block at the top of apple-health-mapping.ts now spells the split out so a future gait-metric addition picks the right path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../v155-step-length-speed-followup.md | 95 +++++++++++++++++++ docs/api/openapi.yaml | 6 ++ messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/pl.json | 2 + .../migration.sql | 27 ++++++ prisma/schema.prisma | 8 ++ .../measurements/measurement-list-meta.ts | 16 ++++ .../measurement-type-enum-coverage.test.ts | 9 +- src/lib/insights/chart-tokens.ts | 8 ++ .../__tests__/apple-health-mapping.test.ts | 69 ++++++++++++++ src/lib/measurements/apple-health-mapping.ts | 47 ++++++++- src/lib/measurements/categories.ts | 3 + src/lib/personal-records/pr-direction.ts | 14 +++ src/lib/validations/measurement.ts | 21 ++++ 18 files changed, 332 insertions(+), 3 deletions(-) create mode 100644 .planning/ios-coord/v155-step-length-speed-followup.md create mode 100644 prisma/migrations/0086_v155_ios_walking_step_length_speed/migration.sql diff --git a/.planning/ios-coord/v155-step-length-speed-followup.md b/.planning/ios-coord/v155-step-length-speed-followup.md new file mode 100644 index 000000000..1e4ae68a0 --- /dev/null +++ b/.planning/ios-coord/v155-step-length-speed-followup.md @@ -0,0 +1,95 @@ +# v1.5.5 — walking step length + walking speed wired (follow-up) + +Server-side change shipping on `main`, follows +[`v155-wire-six-deferred-identifiers.md`](./v155-wire-six-deferred-identifiers.md). + +## Summary + +After the six-identifier wire-up landed, the iOS team flagged two +more HK quantity types they needed mapped before they could flip +them client-side: `walkingStepLength` and `walkingSpeed`. Both sat +in `HK_QUANTITY_TYPE_DEFERRED` inside +`src/lib/measurements/apple-health-mapping.ts`; the batch route +returned HTTP 200 with a per-entry `skipped:"unmappable_identifier"`, +and the iOS app advanced its sync anchor on the 200. Every sample +carrying one of these identifiers was lost forever, no retry path. + +Both identifiers now wire end-to-end with new `MeasurementType` +values + Zod validator entries + DB plausibility ranges + category / +PR-direction / list-meta / chart-token coverage: + +| HK identifier | MeasurementType | DB unit | +| ---------------------------------------------- | --------------------- | ------- | +| `HKQuantityTypeIdentifierWalkingStepLength` | `WALKING_STEP_LENGTH` | `m` | +| `HKQuantityTypeIdentifierWalkingSpeed` | `WALKING_SPEED` | `m/s` | + +DB migration: +`prisma/migrations/0086_v155_ios_walking_step_length_speed`. Pure +additive `ALTER TYPE ... ADD VALUE IF NOT EXISTS`. + +## Unit convention — raw SI, NO server-side scaling + +The percent gait metrics added in the earlier wire-up +(`walkingAsymmetryPercentage`, `walkingDoubleSupportPercentage`) +ride the ×100 server-side scaling path because Apple ships them as +0..1 fractions. The two follow-up identifiers are different: + +- `walkingStepLength` ships as raw metres on the wire. +- `walkingSpeed` ships as raw metres-per-second on the wire. + +Both pass through `convertToDbUnit` as identity — no scaling, no +clamp. The convention block at the top of `apple-health-mapping.ts` +now spells the split out (percent path vs raw SI path) so a future +contributor wiring a new gait identifier picks the correct bucket. + +## iOS action + +iOS can flip both identifiers client-side now. Wire format expected +by the server: + +```json +{ + "hkIdentifier": "HKQuantityTypeIdentifierWalkingStepLength", + "value": 0.72, + "unit": "m", + "startDate": "2026-05-28T09:00:00.000Z", + "endDate": "2026-05-28T09:00:00.000Z" +} +``` + +```json +{ + "hkIdentifier": "HKQuantityTypeIdentifierWalkingSpeed", + "value": 1.34, + "unit": "m/s", + "startDate": "2026-05-28T09:00:00.000Z", + "endDate": "2026-05-28T09:00:00.000Z" +} +``` + +The server stores both verbatim — no pre-multiplication needed and +no pre-multiplication accepted (the plausibility-range guard caps +step length at 2.0 m and speed at 3.0 m/s; a stray ×100 would land +in `skipped:"value_out_of_range"`). + +## Plausibility ranges + +Both ranges are tuned for the adult walking band with margin for +the extremes (race-walk speeds for the upper edge, shuffling gait +for the lower): + +- `WALKING_STEP_LENGTH`: 0.1–2.0 m +- `WALKING_SPEED`: 0.1–3.0 m/s + +## Personal-record direction + +- `WALKING_SPEED` → `MAX`. Walking speed is the "sixth vital sign" + in older-adult medicine — faster casual gait correlates with + cardiovascular fitness, sarcopenia recovery, and overall mobility + resilience. Apple's own Mobility section frames higher as the + achievement. +- `WALKING_STEP_LENGTH` → `null` (no PR direction). Step length is + a state metric: taller users have longer strides regardless of + fitness, and very long strides can also signal an unsafe + overstride. The trend chart still surfaces deltas, but the PR + detection worker skips this type. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7d459d279..9ef425aa1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -176,6 +176,8 @@ paths: - WALKING_HEART_RATE_AVERAGE - WALKING_ASYMMETRY - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED - in: query name: from schema: @@ -307,6 +309,8 @@ paths: - WALKING_HEART_RATE_AVERAGE - WALKING_ASYMMETRY - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED value: type: number measuredAt: @@ -2343,6 +2347,8 @@ components: - WALKING_HEART_RATE_AVERAGE - WALKING_ASYMMETRY - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED value: type: number unit: diff --git a/messages/de.json b/messages/de.json index 7d58514cd..45c6923d3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -369,6 +369,8 @@ "typeWalkingHeartRateAverage": "Geh-Puls (Mittelwert)", "typeWalkingAsymmetry": "Gang-Asymmetrie", "typeWalkingDoubleSupport": "Gang-Doppelstand", + "typeWalkingStepLength": "Schrittlänge", + "typeWalkingSpeed": "Gehgeschwindigkeit", "glucoseContext": "Kontext", "glucoseContextFasting": "Nüchtern (≥8 h ohne Nahrung)", "glucoseContextPostprandial": "Postprandial (2 h nach dem Essen)", diff --git a/messages/en.json b/messages/en.json index 3b5a181be..a7c152585 100644 --- a/messages/en.json +++ b/messages/en.json @@ -369,6 +369,8 @@ "typeWalkingHeartRateAverage": "Walking heart rate (avg)", "typeWalkingAsymmetry": "Walking asymmetry", "typeWalkingDoubleSupport": "Walking double support", + "typeWalkingStepLength": "Walking step length", + "typeWalkingSpeed": "Walking speed", "glucoseContext": "Context", "glucoseContextFasting": "Fasting (≥8h without food)", "glucoseContextPostprandial": "Post-meal (2h after eating)", diff --git a/messages/es.json b/messages/es.json index 0dd400697..6f7c8653f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -369,6 +369,8 @@ "typeWalkingHeartRateAverage": "Frecuencia cardíaca al caminar (media)", "typeWalkingAsymmetry": "Asimetría al caminar", "typeWalkingDoubleSupport": "Doble apoyo al caminar", + "typeWalkingStepLength": "Longitud de zancada", + "typeWalkingSpeed": "Velocidad al caminar", "glucoseContext": "Contexto", "glucoseContextFasting": "En ayunas (≥8 h sin comer)", "glucoseContextPostprandial": "Posprandial (2 h tras comer)", diff --git a/messages/fr.json b/messages/fr.json index 19e3d926b..217aad16a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -369,6 +369,8 @@ "typeWalkingHeartRateAverage": "Fréquence cardiaque à la marche (moy.)", "typeWalkingAsymmetry": "Asymétrie de la marche", "typeWalkingDoubleSupport": "Double appui à la marche", + "typeWalkingStepLength": "Longueur du pas", + "typeWalkingSpeed": "Vitesse de marche", "glucoseContext": "Contexte", "glucoseContextFasting": "À jeun (≥8 h sans repas)", "glucoseContextPostprandial": "Postprandial (2 h après le repas)", diff --git a/messages/it.json b/messages/it.json index 10dde6625..8bb1a9b95 100644 --- a/messages/it.json +++ b/messages/it.json @@ -369,6 +369,8 @@ "typeWalkingHeartRateAverage": "Frequenza cardiaca a passo (media)", "typeWalkingAsymmetry": "Asimmetria del cammino", "typeWalkingDoubleSupport": "Doppio appoggio nel cammino", + "typeWalkingStepLength": "Lunghezza del passo", + "typeWalkingSpeed": "Velocità del cammino", "glucoseContext": "Contesto", "glucoseContextFasting": "A digiuno (≥8 h senza cibo)", "glucoseContextPostprandial": "Postprandiale (2 h dopo il pasto)", diff --git a/messages/pl.json b/messages/pl.json index 9ffd003b4..11c8dc5f3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -369,6 +369,8 @@ "typeWalkingHeartRateAverage": "Tętno przy chodzeniu (śr.)", "typeWalkingAsymmetry": "Asymetria chodu", "typeWalkingDoubleSupport": "Podwójna podpora chodu", + "typeWalkingStepLength": "Długość kroku", + "typeWalkingSpeed": "Prędkość chodu", "glucoseContext": "Kontekst", "glucoseContextFasting": "Na czczo (≥8 godz. bez jedzenia)", "glucoseContextPostprandial": "Po posiłku (2 godz. po jedzeniu)", diff --git a/prisma/migrations/0086_v155_ios_walking_step_length_speed/migration.sql b/prisma/migrations/0086_v155_ios_walking_step_length_speed/migration.sql new file mode 100644 index 000000000..fa8fa646a --- /dev/null +++ b/prisma/migrations/0086_v155_ios_walking_step_length_speed/migration.sql @@ -0,0 +1,27 @@ +-- v1.5.5 — extend `measurement_type` enum for the two remaining iOS +-- mobility identifiers (gait step length + walking speed). +-- +-- Background: 0083 wired six previously-deferred Apple Health +-- identifiers. The iOS team flagged two more gait metrics they need +-- mapped before they can flip them client-side: +-- +-- WALKING_STEP_LENGTH ← HKQuantityTypeIdentifierWalkingStepLength +-- WALKING_SPEED ← HKQuantityTypeIdentifierWalkingSpeed +-- +-- Crucial unit convention: both ship raw SI units on the wire +-- (metres and metres-per-second respectively). The ×100 server-side +-- scaling the percent gait metrics use (`WALKING_ASYMMETRY`, +-- `WALKING_DOUBLE_SUPPORT`, `WALKING_STEADINESS`) does NOT apply to +-- these two — they pass through `convertToDbUnit` as identity. The +-- convention block at the top of +-- `src/lib/measurements/apple-health-mapping.ts` spells the split +-- out so a future contributor adding a new gait metric picks the +-- right path. +-- +-- Pure additive — no existing row carries either of these values. +-- The `IF NOT EXISTS` guard keeps the migration idempotent and lets +-- the runner replay it on a partially-applied environment without +-- failing on the duplicate-enum-value error code. + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_STEP_LENGTH'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_SPEED'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1ff81cdff..ad04de413 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -490,6 +490,14 @@ enum MeasurementType { WALKING_HEART_RATE_AVERAGE // bpm — HealthKit `walkingHeartRateAverage`. Daily rollup; cardio-fitness signal distinct from RESTING_HEART_RATE and spot PULSE. WALKING_ASYMMETRY // percent (0-100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `walkingAsymmetryPercentage`. Mobility section sibling to WALKING_STEADINESS. WALKING_DOUBLE_SUPPORT // percent (0-100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `walkingDoubleSupportPercentage`. Mobility section sibling. + // ── v1.5.5 iOS-coord follow-up — gait raw-SI pair ── + // Both identifiers flow over the wire in raw SI units (no ×100 + // scaling). The percent gait metrics above use the server-side + // scaling path because Apple ships them as 0..1 fractions; the two + // below are already in metres and metres-per-second respectively + // and pass through `convertToDbUnit` as identity. + WALKING_STEP_LENGTH // metres — HealthKit `walkingStepLength`. Mobility section gait metric; adult walking typically 0.5–0.8 m. + WALKING_SPEED // metres per second — HealthKit `walkingSpeed`. Mobility section gait metric; healthy adult casual gait ≈ 1.2–1.4 m/s. @@map("measurement_type") } diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index 3d5ea97cf..2686c99c0 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -78,6 +78,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { WALKING_HEART_RATE_AVERAGE: "measurements.typeWalkingHeartRateAverage", WALKING_ASYMMETRY: "measurements.typeWalkingAsymmetry", WALKING_DOUBLE_SUPPORT: "measurements.typeWalkingDoubleSupport", + // ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ── + WALKING_STEP_LENGTH: "measurements.typeWalkingStepLength", + WALKING_SPEED: "measurements.typeWalkingSpeed", }; export const MEASUREMENT_TYPE_ICONS: Record = { @@ -136,6 +139,12 @@ export const MEASUREMENT_TYPE_ICONS: Record = { WALKING_HEART_RATE_AVERAGE: HeartPulse, WALKING_ASYMMETRY: Footprints, WALKING_DOUBLE_SUPPORT: Footprints, + // ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ── + // Footprints carries the stride/length signal; Gauge mirrors + // the velocity-reading shape (same family as VO2_MAX + + // WALKING_STEADINESS). + WALKING_STEP_LENGTH: Footprints, + WALKING_SPEED: Gauge, }; export const MEASUREMENT_TYPE_COLORS: Record = { @@ -187,4 +196,11 @@ export const MEASUREMENT_TYPE_COLORS: Record = { WALKING_HEART_RATE_AVERAGE: "bg-chart-3/20 text-chart-3", WALKING_ASYMMETRY: "bg-chart-2/20 text-chart-2", WALKING_DOUBLE_SUPPORT: "bg-chart-2/20 text-chart-2", + // ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ── + // Stay in chart-2 (Dracula green) — the entire Mobility cluster + // (steadiness + asymmetry + double-support + step length + speed) + // shares the activity-family colour so the gait cards read as one + // visual group on Insights. + WALKING_STEP_LENGTH: "bg-chart-2/20 text-chart-2", + WALKING_SPEED: "bg-chart-2/20 text-chart-2", }; diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index 533a87eed..f644ffabe 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -56,10 +56,13 @@ const EXPECTED_TYPES = [ "WALKING_HEART_RATE_AVERAGE", "WALKING_ASYMMETRY", "WALKING_DOUBLE_SUPPORT", + // ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ── + "WALKING_STEP_LENGTH", + "WALKING_SPEED", ] as const; describe("measurementTypeEnum coverage", () => { - it("exposes the 36 canonical measurement types", () => { + it("exposes the 38 canonical measurement types", () => { expect([...measurementTypeEnum.options].sort()).toEqual( [...EXPECTED_TYPES].sort(), ); @@ -123,6 +126,10 @@ describe("measurementTypeEnum coverage", () => { "WALKING_HEART_RATE_AVERAGE", "WALKING_ASYMMETRY", "WALKING_DOUBLE_SUPPORT", + // v1.5.5 iOS-coord follow-up — raw-SI gait pair held under the + // same clinical-layout deferral as the rest of the Mobility set. + "WALKING_STEP_LENGTH", + "WALKING_SPEED", ]); it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => { diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index 7af7d26be..c51b1f2f8 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -74,6 +74,11 @@ export const ALLOWED_CHART_TOKENS = [ "metric:WALKING_HEART_RATE_AVERAGE", "metric:WALKING_ASYMMETRY", "metric:WALKING_DOUBLE_SUPPORT", + // v1.5.5 iOS-coord follow-up — raw-SI gait pair. Same posture: + // the LLM can reference these in prose; the chart components + // ride the existing card-shelf renderer. + "metric:WALKING_STEP_LENGTH", + "metric:WALKING_SPEED", ] as const; export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number]; @@ -158,6 +163,9 @@ const ORPHAN_ENUMS = [ "WALKING_HEART_RATE_AVERAGE", "WALKING_ASYMMETRY", "WALKING_DOUBLE_SUPPORT", + // v1.5.5 iOS-coord follow-up — raw-SI gait pair, same shape. + "WALKING_STEP_LENGTH", + "WALKING_SPEED", ] as const; // `\b` boundaries keep ordinary English prose untouched — "weight" diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index d62d9a82d..549ad5f12 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -457,6 +457,75 @@ describe("v1.5.5 iOS-coord additions — six previously-deferred identifiers", ( }); }); +describe("v1.5.5 iOS-coord follow-up — raw-SI gait pair", () => { + it("maps walkingStepLength + walkingSpeed to the new MeasurementTypes", () => { + const sl = APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierWalkingStepLength; + const sp = APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierWalkingSpeed; + expect(sl).toBeDefined(); + expect(sp).toBeDefined(); + expect(sl.measurementType).toBe("WALKING_STEP_LENGTH"); + expect(sp.measurementType).toBe("WALKING_SPEED"); + expect(sl.dbUnit).toBe("m"); + expect(sp.dbUnit).toBe("m/s"); + }); + + it("passes raw SI values through unchanged (no ×100 scaling)", () => { + // Crucial convention pin: the percent gait metrics scale ×100 + // server-side because Apple ships 0..1 fractions; step length + // and speed are already in metres / metres-per-second and MUST + // round-trip as identity. A future contributor wiring a new + // gait identifier should add to the right bucket — this test + // is the regression sentinel for that decision. + const sl = APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierWalkingStepLength; + const sp = APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierWalkingSpeed; + expect(sl.convertToDbUnit(0.72)).toBe(0.72); + expect(sl.convertToDbUnit(1.5)).toBe(1.5); + expect(sp.convertToDbUnit(1.3)).toBe(1.3); + expect(sp.convertToDbUnit(2.1)).toBe(2.1); + }); + + it("removes both identifiers from the deferred set", () => { + expect( + HK_QUANTITY_TYPE_DEFERRED.has("HKQuantityTypeIdentifierWalkingStepLength"), + ).toBe(false); + expect( + HK_QUANTITY_TYPE_DEFERRED.has("HKQuantityTypeIdentifierWalkingSpeed"), + ).toBe(false); + }); + + it("round-trips a walking-step-length sample through mapAppleHealthEntry", () => { + const out = mapAppleHealthEntry({ + hkIdentifier: "HKQuantityTypeIdentifierWalkingStepLength", + value: 0.72, + unit: "m", + startDate: "2026-05-28T09:00:00.000Z", + endDate: "2026-05-28T09:00:00.000Z", + }); + expect(out).toEqual({ + type: "WALKING_STEP_LENGTH", + value: 0.72, + unit: "m", + takenAt: new Date("2026-05-28T09:00:00.000Z"), + }); + }); + + it("round-trips a walking-speed sample through mapAppleHealthEntry", () => { + const out = mapAppleHealthEntry({ + hkIdentifier: "HKQuantityTypeIdentifierWalkingSpeed", + value: 1.34, + unit: "m/s", + startDate: "2026-05-28T09:00:00.000Z", + endDate: "2026-05-28T09:00:00.000Z", + }); + expect(out).toEqual({ + type: "WALKING_SPEED", + value: 1.34, + unit: "m/s", + takenAt: new Date("2026-05-28T09:00:00.000Z"), + }); + }); +}); + describe("v1.4.30 Tier-1 additions (R-F T1.4 + T1.5)", () => { it("maps appleWalkingSteadiness to WALKING_STEADINESS with × 100 scaling", () => { const mapping = diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index 874f04552..a5d0e5899 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -123,6 +123,25 @@ export const APPLE_HEALTH_SLEEP_STAGE_MAP: Record = { * through the same canonical server-side scaling path. The coord * note in `.planning/ios-coord/` documents the one-release shim and * the migration window. + * + * ── Convention split — percent vs raw SI ─────────────────────────── + * + * The ×100 scaling applies ONLY to identifiers Apple ships as a + * 0..1 fraction: + * + * - `oxygenSaturation` + * - `bodyFatPercentage` + * - `appleWalkingSteadiness` + * - `walkingAsymmetryPercentage` + * - `walkingDoubleSupportPercentage` + * + * Identifiers that already ride raw SI units on the wire pass + * through `convertToDbUnit` as identity — no scaling, no clamp. The + * v1.5.5 follow-up additions `walkingStepLength` (metres) and + * `walkingSpeed` (metres per second) belong to this second bucket + * and must NOT be scaled. A future contributor adding a new gait + * metric: check Apple's HK unit; percent → ×100 path, m/m·s/kg/etc + * → identity path. */ export const APPLE_HEALTH_TYPE_MAP: Record = { // ── Body composition ──────────────────────────────────────── @@ -427,6 +446,30 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { convertToDbUnit: (v) => v * 100, aggregation: "latest", }, + + // ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ─────────── + // Walking step length — Apple ships raw metres; canonical DB + // unit is metres too. NO scaling — the ×100 convention applies + // ONLY to the percent gait metrics above. See the convention + // block at the top of this file for the split. + HKQuantityTypeIdentifierWalkingStepLength: { + hkIdentifier: "HKQuantityTypeIdentifierWalkingStepLength", + measurementType: "WALKING_STEP_LENGTH", + hkUnit: "m", + dbUnit: "m", + convertToDbUnit: (v) => v, + aggregation: "mean", + }, + // Walking speed — Apple ships raw metres-per-second; canonical + // DB unit is m/s too. NO scaling — see the convention block. + HKQuantityTypeIdentifierWalkingSpeed: { + hkIdentifier: "HKQuantityTypeIdentifierWalkingSpeed", + measurementType: "WALKING_SPEED", + hkUnit: "m/s", + dbUnit: "m/s", + convertToDbUnit: (v) => v, + aggregation: "mean", + }, }; /** @@ -536,8 +579,8 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ "HKQuantityTypeIdentifierAppleStandTime", // implied by Workout rows "HKCategoryTypeIdentifierAppleStandHour", // implied by Workout rows // Running / walking form — v1.5+ - "HKQuantityTypeIdentifierWalkingSpeed", - "HKQuantityTypeIdentifierWalkingStepLength", + // v1.5.5 — `WalkingStepLength` + `WalkingSpeed` moved into the + // mapping table (raw SI on the wire — metres and m/s respectively). "HKQuantityTypeIdentifierStairAscentSpeed", "HKQuantityTypeIdentifierStairDescentSpeed", "HKQuantityTypeIdentifierSixMinuteWalkTestDistance", diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index 73aa8c410..5ee820e9c 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -94,6 +94,9 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< ["WALKING_ASYMMETRY", "activity"], ["WALKING_DOUBLE_SUPPORT", "activity"], ["WALKING_HEART_RATE_AVERAGE", "activity"], + // v1.5.5 follow-up — gait raw-SI pair join the same Mobility cluster. + ["WALKING_STEP_LENGTH", "activity"], + ["WALKING_SPEED", "activity"], // ── Sleep ── ["SLEEP_DURATION", "sleep"], diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index a40085b73..95ee21890 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -62,6 +62,13 @@ export function getPRDirection( // v1.5.5 — lean body mass is the muscle-mass-adjacent body-comp // axis where higher is the goal. case "LEAN_BODY_MASS": + // v1.5.5 follow-up — walking speed is a well-established + // clinical fitness marker (often called the "sixth vital + // sign" in older-adult medicine); faster gait correlates + // with cardiovascular fitness, sarcopenia recovery, and + // overall mobility resilience. MAX direction matches Apple's + // own Mobility-section framing. + case "WALKING_SPEED": return PersonalRecordDirection.MAX; // MIN direction — lower value is the record. @@ -104,6 +111,13 @@ export function getPRDirection( case "RESPIRATORY_RATE": case "WALKING_HEART_RATE_AVERAGE": case "BODY_MASS_INDEX": + // v1.5.5 follow-up — walking step length is a state metric: + // taller users have longer strides regardless of fitness, and + // there is no clean "higher is better" axis (very long strides + // can also signal an unsafe overstride). Defer to a null PR + // direction; the future worker can still surface trend deltas + // without claiming a record direction. + case "WALKING_STEP_LENGTH": return null; } } diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 3a3b447a9..38fe9d8a0 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -42,6 +42,11 @@ export const measurementTypeEnum = z.enum([ "WALKING_HEART_RATE_AVERAGE", "WALKING_ASYMMETRY", "WALKING_DOUBLE_SUPPORT", + // ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ── + // Both ship raw SI on the wire (metres / metres-per-second); see + // the convention block in `apple-health-mapping.ts`. + "WALKING_STEP_LENGTH", + "WALKING_SPEED", ]); export const glucoseContextEnum = z.enum([ @@ -121,6 +126,12 @@ const unitMap: Record = { // — see the project convention block in `apple-health-mapping.ts`. WALKING_ASYMMETRY: "%", WALKING_DOUBLE_SUPPORT: "%", + // ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ── + // Step length is metres; speed is metres per second. Both flow + // raw on the wire — no server-side scaling. The unit strings + // match HealthKit's `m` / `m/s` defaults. + WALKING_STEP_LENGTH: "m", + WALKING_SPEED: "m/s", }; export function getUnitForType(type: string): string { @@ -218,6 +229,16 @@ const VALUE_RANGES: Record = { // the ×100 server-side scaling the canonical band is 0..100. WALKING_ASYMMETRY: { min: 0, max: 100 }, WALKING_DOUBLE_SUPPORT: { min: 0, max: 100 }, + // ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ── + // Step length (metres) — adult walking sits around 0.5–0.8 m; the + // 0.1 floor captures shuffling gait and the 2.0 ceiling covers + // unusually tall sprinters without catching obvious sensor noise. + WALKING_STEP_LENGTH: { min: 0.1, max: 2.0 }, + // Walking speed (m/s) — healthy adult casual gait ≈ 1.2–1.4 m/s; + // brisk walking tops out near 2.2 m/s before transitioning to a + // run. The 0.1 floor captures a very slow shuffle; 3.0 covers + // race-walking record territory. + WALKING_SPEED: { min: 0.1, max: 3.0 }, }; export function validateMeasurementRange(