mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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';
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user