fix(measurements): wire walking step length and walking speed

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 23:38:21 +02:00
1 parent 8d5d039ed1
commit 7f95a2a44f
18 files changed
+332 -3

No files matched your search

@@ -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.
+6
View File
@@ -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:
+2
View File
@@ -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)",
+2
View File
@@ -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)",
+2
View File
@@ -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)",
+2
View File
@@ -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)",
+2
View File
@@ -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)",
+2
View File
@@ -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)",
@@ -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';
+8
View File
@@ -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")
}
@@ -78,6 +78,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
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<string, LucideIcon> = {
@@ -136,6 +139,12 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
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<string, string> = {
@@ -187,4 +196,11 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
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",
};
@@ -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", () => {
+8
View File
@@ -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"
@@ -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 =
+45 -2
View File
@@ -123,6 +123,25 @@ export const APPLE_HEALTH_SLEEP_STAGE_MAP: Record<number, SleepStage> = {
* 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<string, AppleHealthMapping> = {
// ── Body composition ────────────────────────────────────────
@@ -427,6 +446,30 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
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<string>([
"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",
+3
View File
@@ -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"],
+14
View File
@@ -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;
}
}
+21
View File
@@ -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<string, string> = {
// — 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<string, { min: number; max: number }> = {
// 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(