fix(measurements): wire the six deferred Apple Health identifiers

Adds RESPIRATORY_RATE, BODY_MASS_INDEX, and the four remaining
deferred quantity types to MeasurementType plus the Apple Health
mapping. The batch route already returns 200 with a per-entry skip
for unmappable identifiers; iOS reads that as success and advances
its sync anchor, so any sample with one of these identifiers used
to be dropped forever. The mapping now accepts them and the rows
land.

The walking-asymmetry and walking-double-support percent values
adopt the project's server-side scaling convention (raw HK reading
on the wire, server applies the ×100 at ingest), matching the
walking-steadiness path. iOS releases that still send the
pre-multiplied percent need a one-release shim and are tracked in
the iOS coord notes.

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 22:32:18 +02:00
1 parent b43b57f657
commit befac0ec65
18 files changed
+552 -9

No files matched your search

@@ -0,0 +1,77 @@
# v1.5.5 — six previously-deferred HK identifiers wired
Server-side change shipping on `main` for v1.5.5.
## Summary
Six HealthKit identifiers used to sit in
`HK_QUANTITY_TYPE_DEFERRED` inside
`src/lib/measurements/apple-health-mapping.ts`. The mapping returned
null; the batch route emitted HTTP 200 with a per-entry
`skipped:"unmappable_identifier"`; the iOS app read 200 as success
and advanced its sync anchor. Net effect: every sample carrying one
of these identifiers was silently dropped forever with no retry
path. The audit team flagged the gap on the v0.8.0 iOS pass.
The six 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 |
| ---------------------------------------------------------- | ---------------------------- | ------------- |
| `HKQuantityTypeIdentifierRespiratoryRate` | `RESPIRATORY_RATE` | `breaths/min` |
| `HKQuantityTypeIdentifierBodyMassIndex` | `BODY_MASS_INDEX` | `kg/m²` |
| `HKQuantityTypeIdentifierLeanBodyMass` | `LEAN_BODY_MASS` | `kg` |
| `HKQuantityTypeIdentifierWalkingHeartRateAverage` | `WALKING_HEART_RATE_AVERAGE` | `bpm` |
| `HKQuantityTypeIdentifierWalkingAsymmetryPercentage` | `WALKING_ASYMMETRY` | `%` |
| `HKQuantityTypeIdentifierWalkingDoubleSupportPercentage` | `WALKING_DOUBLE_SUPPORT` | `%` |
DB migration: `prisma/migrations/0083_v155_ios_measurement_types`.
Pure additive `ALTER TYPE ... ADD VALUE IF NOT EXISTS`.
## Unit convention — server-side scaling is canonical
The audit also flagged a footgun in the gait-percent path. Two
conventions for "percent" values were live in the tree:
- `walkingAsymmetryPercentage` + `walkingDoubleSupportPercentage`
— iOS multiplied the HK reading (`0..1` fraction) by ×100 **before
upload**, so the wire value already lived in `0..100`.
- `walkingSteadiness` (v1.4.30) — iOS sent the raw HK reading
(`0..1` fraction), the server applied the ×100 at ingest. Same
shape as the existing `oxygenSaturation` + `bodyFatPercentage`
paths.
The split was source-of-bugs: every future contributor adding a new
percent metric had to remember which convention applied. We picked
the cleaner one as the project convention:
> **Server-side scaling is canonical.** Every HealthKit value flows
> over the wire as the raw HK reading. Whatever ×100 / unit-bend /
> clamp the canonical DB shape needs, the server applies it inside
> `convertToDbUnit` at ingest.
`apple-health-mapping.ts` now documents this convention at the top
of the `APPLE_HEALTH_TYPE_MAP` block.
### iOS migration window
The v1.5.5 server release ships with the new gait identifiers
applying the ×100 scaling server-side. iOS releases that still
multiply by 100 pre-upload will land values in the `0..10000` band
— the plausibility-range guard (`0..100`) will reject them with
`skipped:"value_out_of_range"` per entry.
Action item for the iOS client: drop the pre-multiplication for
`HKQuantityTypeIdentifierWalkingAsymmetryPercentage` and
`HKQuantityTypeIdentifierWalkingDoubleSupportPercentage` so the raw
HK reading (`0..1`) flows over the wire. The server will apply the
×100 the same way it already does for `oxygenSaturation`,
`bodyFatPercentage`, and `appleWalkingSteadiness`.
Suggested timing: one iOS release alongside the v1.5.5 server cut,
or the next regular iOS sync if the existing TestFlight build can
wait. The plausibility-range guard fails closed — no bad data lands
in the DB; the only impact during the window is that those two
gait identifiers ingest as `skipped:"value_out_of_range"` instead
of as recorded samples.
+19 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.5.3
version: 1.5.4
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -170,6 +170,12 @@ paths:
- TIME_IN_DAYLIGHT
- WALKING_STEADINESS
- AUDIO_EXPOSURE_EVENT
- RESPIRATORY_RATE
- BODY_MASS_INDEX
- LEAN_BODY_MASS
- WALKING_HEART_RATE_AVERAGE
- WALKING_ASYMMETRY
- WALKING_DOUBLE_SUPPORT
- in: query
name: from
schema:
@@ -295,6 +301,12 @@ paths:
- TIME_IN_DAYLIGHT
- WALKING_STEADINESS
- AUDIO_EXPOSURE_EVENT
- RESPIRATORY_RATE
- BODY_MASS_INDEX
- LEAN_BODY_MASS
- WALKING_HEART_RATE_AVERAGE
- WALKING_ASYMMETRY
- WALKING_DOUBLE_SUPPORT
value:
type: number
measuredAt:
@@ -2222,6 +2234,12 @@ components:
- TIME_IN_DAYLIGHT
- WALKING_STEADINESS
- AUDIO_EXPOSURE_EVENT
- RESPIRATORY_RATE
- BODY_MASS_INDEX
- LEAN_BODY_MASS
- WALKING_HEART_RATE_AVERAGE
- WALKING_ASYMMETRY
- WALKING_DOUBLE_SUPPORT
value:
type: number
unit:
+6
View File
@@ -337,6 +337,12 @@
"typeTimeInDaylight": "Zeit im Tageslicht",
"typeWalkingSteadiness": "Gehstabilität",
"typeAudioExposureEvent": "Lautes-Hören-Ereignis",
"typeRespiratoryRate": "Atemfrequenz",
"typeBodyMassIndex": "Body-Mass-Index",
"typeLeanBodyMass": "Magermasse",
"typeWalkingHeartRateAverage": "Geh-Puls (Mittelwert)",
"typeWalkingAsymmetry": "Gang-Asymmetrie",
"typeWalkingDoubleSupport": "Gang-Doppelstand",
"glucoseContext": "Kontext",
"glucoseContextFasting": "Nüchtern (≥8 h ohne Nahrung)",
"glucoseContextPostprandial": "Postprandial (2 h nach dem Essen)",
+6
View File
@@ -337,6 +337,12 @@
"typeTimeInDaylight": "Time in Daylight",
"typeWalkingSteadiness": "Walking Steadiness",
"typeAudioExposureEvent": "Loud-Listening Event",
"typeRespiratoryRate": "Respiratory rate",
"typeBodyMassIndex": "Body-mass index",
"typeLeanBodyMass": "Lean body mass",
"typeWalkingHeartRateAverage": "Walking heart rate (avg)",
"typeWalkingAsymmetry": "Walking asymmetry",
"typeWalkingDoubleSupport": "Walking double support",
"glucoseContext": "Context",
"glucoseContextFasting": "Fasting (≥8h without food)",
"glucoseContextPostprandial": "Post-meal (2h after eating)",
+6
View File
@@ -337,6 +337,12 @@
"typeTimeInDaylight": "Tiempo a la luz del día",
"typeWalkingSteadiness": "Estabilidad al caminar",
"typeAudioExposureEvent": "Evento de escucha alta",
"typeRespiratoryRate": "Frecuencia respiratoria",
"typeBodyMassIndex": "Índice de masa corporal",
"typeLeanBodyMass": "Masa magra",
"typeWalkingHeartRateAverage": "Frecuencia cardíaca al caminar (media)",
"typeWalkingAsymmetry": "Asimetría al caminar",
"typeWalkingDoubleSupport": "Doble apoyo al caminar",
"glucoseContext": "Contexto",
"glucoseContextFasting": "En ayunas (≥8 h sin comer)",
"glucoseContextPostprandial": "Posprandial (2 h tras comer)",
+6
View File
@@ -337,6 +337,12 @@
"typeTimeInDaylight": "Temps à la lumière du jour",
"typeWalkingSteadiness": "Stabilité à la marche",
"typeAudioExposureEvent": "Événement d’écoute forte",
"typeRespiratoryRate": "Fréquence respiratoire",
"typeBodyMassIndex": "Indice de masse corporelle",
"typeLeanBodyMass": "Masse maigre",
"typeWalkingHeartRateAverage": "Fréquence cardiaque à la marche (moy.)",
"typeWalkingAsymmetry": "Asymétrie de la marche",
"typeWalkingDoubleSupport": "Double appui à la marche",
"glucoseContext": "Contexte",
"glucoseContextFasting": "À jeun (≥8 h sans repas)",
"glucoseContextPostprandial": "Postprandial (2 h après le repas)",
+6
View File
@@ -337,6 +337,12 @@
"typeTimeInDaylight": "Tempo alla luce del giorno",
"typeWalkingSteadiness": "Stabilità nel cammino",
"typeAudioExposureEvent": "Evento di ascolto forte",
"typeRespiratoryRate": "Frequenza respiratoria",
"typeBodyMassIndex": "Indice di massa corporea",
"typeLeanBodyMass": "Massa magra",
"typeWalkingHeartRateAverage": "Frequenza cardiaca a passo (media)",
"typeWalkingAsymmetry": "Asimmetria del cammino",
"typeWalkingDoubleSupport": "Doppio appoggio nel cammino",
"glucoseContext": "Contesto",
"glucoseContextFasting": "A digiuno (≥8 h senza cibo)",
"glucoseContextPostprandial": "Postprandiale (2 h dopo il pasto)",
+6
View File
@@ -337,6 +337,12 @@
"typeTimeInDaylight": "Czas w świetle dziennym",
"typeWalkingSteadiness": "Stabilność chodu",
"typeAudioExposureEvent": "Zdarzenie głośnego słuchania",
"typeRespiratoryRate": "Częstość oddechów",
"typeBodyMassIndex": "Wskaźnik masy ciała",
"typeLeanBodyMass": "Masa beztłuszczowa",
"typeWalkingHeartRateAverage": "Tętno przy chodzeniu (śr.)",
"typeWalkingAsymmetry": "Asymetria chodu",
"typeWalkingDoubleSupport": "Podwójna podpora chodu",
"glucoseContext": "Kontekst",
"glucoseContextFasting": "Na czczo (≥8 godz. bez jedzenia)",
"glucoseContextPostprandial": "Po posiłku (2 godz. po jedzeniu)",
@@ -0,0 +1,29 @@
-- v1.5.5 — extend `measurement_type` enum for the six iOS-side
-- deferred identifiers.
--
-- Each HK identifier in this set used to land in
-- `HK_QUANTITY_TYPE_DEFERRED` in `src/lib/measurements/apple-health-mapping.ts`.
-- The batch ingest returned HTTP 200 with a per-entry
-- `skipped:"unmappable_identifier"`; the iOS client read that as
-- success and advanced its sync anchor, so any sample carrying one
-- of these identifiers was lost forever. The mapping now wires them
-- end-to-end:
--
-- RESPIRATORY_RATE ← HKQuantityTypeIdentifierRespiratoryRate
-- BODY_MASS_INDEX ← HKQuantityTypeIdentifierBodyMassIndex
-- LEAN_BODY_MASS ← HKQuantityTypeIdentifierLeanBodyMass
-- WALKING_HEART_RATE_AVERAGE ← HKQuantityTypeIdentifierWalkingHeartRateAverage
-- WALKING_ASYMMETRY ← HKQuantityTypeIdentifierWalkingAsymmetryPercentage
-- WALKING_DOUBLE_SUPPORT ← HKQuantityTypeIdentifierWalkingDoubleSupportPercentage
--
-- Pure additive — no existing row carries any 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 'RESPIRATORY_RATE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BODY_MASS_INDEX';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'LEAN_BODY_MASS';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_HEART_RATE_AVERAGE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_ASYMMETRY';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_DOUBLE_SUPPORT';
+38
View File
@@ -264,6 +264,33 @@ model User {
// `useAuth()` hook.
disableCoach Boolean @default(false) @map("disable_coach")
// v1.5.5 — self-hosted avatar storage.
//
// Replaces the Gravatar third-party leak (the old `getGravatarUrl`
// path hashed the email with SHA-256 and shipped the digest to
// www.gravatar.com on every authenticated page load, which let
// Automattic correlate the digest against its own table of known
// emails). The bytes live on the User row so the standard pg_dump
// backup path already covers them — no new filesystem volume to
// mount, no new Docker compose change.
//
// `avatarBytes` carries the raw image (JPEG / PNG / WebP), hard-
// capped at 2 MiB by the upload route and at 2048×2048 by the
// post-decode dimension check. `avatarContentType` carries the
// sniffed MIME (`image/jpeg`, `image/png`, `image/webp`); the
// value is never trusted from the multipart header — it is
// resolved from the file's magic bytes. `avatarUpdatedAt` is the
// cache-bust anchor: the /api/auth/me profile response appends
// the unix-ms suffix to the avatar URL so a re-upload paints
// fresh client-side without an HTTP cache flush.
//
// Null on all three columns = the user has not uploaded an
// avatar and the profile response returns `avatarUrl: null`. The
// client falls back to the username initials.
avatarBytes Bytes? @map("avatar_bytes")
avatarContentType String? @map("avatar_content_type")
avatarUpdatedAt DateTime? @map("avatar_updated_at")
// Relations
passkeys Passkey[]
sessions Session[]
@@ -443,6 +470,17 @@ enum MeasurementType {
// ── 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).
// ── v1.5.5 iOS-coord additions — six identifiers the deferred list refused to map. ──
// Each one was emitted by the iOS app, dropped server-side with a
// per-entry `skipped:"unmappable_identifier"`, and ack-200'd to the
// client. iOS read 200 as success, advanced its sync anchor, and the
// samples were lost forever. Now mapped end-to-end.
RESPIRATORY_RATE // count/min — HealthKit `respiratoryRate`. Watch + iPhone sleep + workout sampling.
BODY_MASS_INDEX // kg/m² — HealthKit `bodyMassIndex`. iOS computes it from weight + height before upload; we still store it as a first-class metric for trend display.
LEAN_BODY_MASS // kg — HealthKit `leanBodyMass`. Body-composition counterpart to FAT_MASS; pairs with WEIGHT − FAT_MASS for sanity checks.
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.
@@map("measurement_type")
}
@@ -71,6 +71,13 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
// ── v1.4.30 R-F T1.4 + T1.5 ──
WALKING_STEADINESS: "measurements.typeWalkingSteadiness",
AUDIO_EXPOSURE_EVENT: "measurements.typeAudioExposureEvent",
// ── v1.5.5 iOS-coord additions ──
RESPIRATORY_RATE: "measurements.typeRespiratoryRate",
BODY_MASS_INDEX: "measurements.typeBodyMassIndex",
LEAN_BODY_MASS: "measurements.typeLeanBodyMass",
WALKING_HEART_RATE_AVERAGE: "measurements.typeWalkingHeartRateAverage",
WALKING_ASYMMETRY: "measurements.typeWalkingAsymmetry",
WALKING_DOUBLE_SUPPORT: "measurements.typeWalkingDoubleSupport",
};
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
@@ -119,6 +126,16 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
// event flag — the event is a louder-cousin of the env quantity.
WALKING_STEADINESS: Gauge,
AUDIO_EXPOSURE_EVENT: Volume2,
// ── v1.5.5 iOS-coord additions ──
// Wind already carries the breathing family (SpO2 uses it);
// Scale carries body-comp; HeartPulse rounds out the cardio
// pair; Footprints + Gauge live in the gait family.
RESPIRATORY_RATE: Wind,
BODY_MASS_INDEX: Scale,
LEAN_BODY_MASS: Scale,
WALKING_HEART_RATE_AVERAGE: HeartPulse,
WALKING_ASYMMETRY: Footprints,
WALKING_DOUBLE_SUPPORT: Footprints,
};
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
@@ -161,4 +178,13 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
// ── v1.4.30 R-F T1.4 + T1.5 ──
WALKING_STEADINESS: "bg-chart-2/20 text-chart-2",
AUDIO_EXPOSURE_EVENT: "bg-chart-5/20 text-chart-5",
// ── v1.5.5 iOS-coord additions ──
// Reuse the existing palette families: chart-5 (cardio/pulse),
// chart-1 (mass), chart-2 (activity/gait).
RESPIRATORY_RATE: "bg-chart-5/20 text-chart-5",
BODY_MASS_INDEX: "bg-chart-1/20 text-chart-1",
LEAN_BODY_MASS: "bg-chart-1/20 text-chart-1",
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",
};
@@ -49,10 +49,17 @@ const EXPECTED_TYPES = [
// ── v1.4.30 R-F T1.4 + T1.5 ──
"WALKING_STEADINESS",
"AUDIO_EXPOSURE_EVENT",
// ── v1.5.5 iOS-coord — six previously-deferred HK identifiers wired ──
"RESPIRATORY_RATE",
"BODY_MASS_INDEX",
"LEAN_BODY_MASS",
"WALKING_HEART_RATE_AVERAGE",
"WALKING_ASYMMETRY",
"WALKING_DOUBLE_SUPPORT",
] as const;
describe("measurementTypeEnum coverage", () => {
it("exposes the 30 canonical measurement types", () => {
it("exposes the 36 canonical measurement types", () => {
expect([...measurementTypeEnum.options].sort()).toEqual(
[...EXPECTED_TYPES].sort(),
);
@@ -105,6 +112,17 @@ describe("measurementTypeEnum coverage", () => {
// in the clinical PDF until the iOS-app sync lands.
"WALKING_STEADINESS",
"AUDIO_EXPOSURE_EVENT",
// v1.5.5 iOS-coord additions held under the same v1.5 PDF gate.
// Respiratory rate, BMI, lean body mass, walking HR average, and
// the two gait-percent metrics ride the same clinical-layout
// deferral as the other v1.4.23+ additions; the doctor PDF picks
// them up once the layout and reference ranges land.
"RESPIRATORY_RATE",
"BODY_MASS_INDEX",
"LEAN_BODY_MASS",
"WALKING_HEART_RATE_AVERAGE",
"WALKING_ASYMMETRY",
"WALKING_DOUBLE_SUPPORT",
]);
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
+16
View File
@@ -65,6 +65,15 @@ export const ALLOWED_CHART_TOKENS = [
// alongside the iOS-app sync.
"metric:WALKING_STEADINESS",
"metric:AUDIO_EXPOSURE_EVENT",
// v1.5.5 — iOS-coord additions. Same posture: the LLM can
// reference these in prose; the chart components ride the
// existing card-shelf renderer.
"metric:RESPIRATORY_RATE",
"metric:BODY_MASS_INDEX",
"metric:LEAN_BODY_MASS",
"metric:WALKING_HEART_RATE_AVERAGE",
"metric:WALKING_ASYMMETRY",
"metric:WALKING_DOUBLE_SUPPORT",
] as const;
export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number];
@@ -142,6 +151,13 @@ const ORPHAN_ENUMS = [
"AUDIO_EXPOSURE_ENV",
"AUDIO_EXPOSURE_HEADPHONE",
"TIME_IN_DAYLIGHT",
// v1.5.5 iOS-coord additions — same shape, same posture.
"RESPIRATORY_RATE",
"BODY_MASS_INDEX",
"LEAN_BODY_MASS",
"WALKING_HEART_RATE_AVERAGE",
"WALKING_ASYMMETRY",
"WALKING_DOUBLE_SUPPORT",
] as const;
// `\b` boundaries keep ordinary English prose untouched — "weight"
@@ -346,6 +346,117 @@ describe("mapAppleHealthEntry", () => {
});
});
describe("v1.5.5 iOS-coord additions — six previously-deferred identifiers", () => {
it("maps every one of the six identifiers", () => {
const expected: Array<[string, string]> = [
["HKQuantityTypeIdentifierRespiratoryRate", "RESPIRATORY_RATE"],
["HKQuantityTypeIdentifierBodyMassIndex", "BODY_MASS_INDEX"],
["HKQuantityTypeIdentifierLeanBodyMass", "LEAN_BODY_MASS"],
[
"HKQuantityTypeIdentifierWalkingHeartRateAverage",
"WALKING_HEART_RATE_AVERAGE",
],
[
"HKQuantityTypeIdentifierWalkingAsymmetryPercentage",
"WALKING_ASYMMETRY",
],
[
"HKQuantityTypeIdentifierWalkingDoubleSupportPercentage",
"WALKING_DOUBLE_SUPPORT",
],
];
for (const [hkId, type] of expected) {
const mapping = APPLE_HEALTH_TYPE_MAP[hkId];
expect(mapping, `${hkId} should be mapped`).toBeDefined();
expect(mapping.measurementType).toBe(type);
}
});
it("scales the gait percent identifiers ×100 server-side (project convention)", () => {
const asym =
APPLE_HEALTH_TYPE_MAP
.HKQuantityTypeIdentifierWalkingAsymmetryPercentage;
const ds =
APPLE_HEALTH_TYPE_MAP
.HKQuantityTypeIdentifierWalkingDoubleSupportPercentage;
// Apple ships these as 0..1 fractions; HealthLog stores 0..100.
expect(asym.convertToDbUnit(0.07)).toBeCloseTo(7);
expect(ds.convertToDbUnit(0.2)).toBeCloseTo(20);
expect(asym.dbUnit).toBe("%");
expect(ds.dbUnit).toBe("%");
});
it("keeps identity conversion for the non-percent additions", () => {
expect(
APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierRespiratoryRate.convertToDbUnit(
16,
),
).toBe(16);
expect(
APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierBodyMassIndex.convertToDbUnit(
24.5,
),
).toBe(24.5);
expect(
APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierLeanBodyMass.convertToDbUnit(
62,
),
).toBe(62);
expect(
APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierWalkingHeartRateAverage.convertToDbUnit(
96,
),
).toBe(96);
});
it("removes the six identifiers from the deferred set", () => {
const previouslyDeferred = [
"HKQuantityTypeIdentifierRespiratoryRate",
"HKQuantityTypeIdentifierBodyMassIndex",
"HKQuantityTypeIdentifierLeanBodyMass",
"HKQuantityTypeIdentifierWalkingHeartRateAverage",
"HKQuantityTypeIdentifierWalkingAsymmetryPercentage",
"HKQuantityTypeIdentifierWalkingDoubleSupportPercentage",
];
for (const id of previouslyDeferred) {
expect(
HK_QUANTITY_TYPE_DEFERRED.has(id),
`${id} should no longer be deferred`,
).toBe(false);
}
});
it("round-trips a respiratory-rate sample through mapAppleHealthEntry", () => {
const out = mapAppleHealthEntry({
hkIdentifier: "HKQuantityTypeIdentifierRespiratoryRate",
value: 16,
unit: "count/min",
startDate: "2026-05-28T03:00:00.000Z",
endDate: "2026-05-28T07:00:00.000Z",
});
expect(out).toEqual({
type: "RESPIRATORY_RATE",
value: 16,
unit: "breaths/min",
takenAt: new Date("2026-05-28T07:00:00.000Z"),
});
});
it("round-trips a walking-asymmetry sample with ×100 scaling", () => {
const out = mapAppleHealthEntry({
hkIdentifier: "HKQuantityTypeIdentifierWalkingAsymmetryPercentage",
value: 0.07,
unit: "%",
startDate: "2026-05-28T08:00:00.000Z",
endDate: "2026-05-28T08:00:00.000Z",
});
expect(out).not.toBeNull();
expect(out!.type).toBe("WALKING_ASYMMETRY");
expect(out!.unit).toBe("%");
expect(out!.value).toBeCloseTo(7);
});
});
describe("v1.4.30 Tier-1 additions (R-F T1.4 + T1.5)", () => {
it("maps appleWalkingSteadiness to WALKING_STEADINESS with × 100 scaling", () => {
const mapping =
+111 -7
View File
@@ -95,6 +95,34 @@ export const APPLE_HEALTH_SLEEP_STAGE_MAP: Record<number, SleepStage> = {
* fraction; HealthLog stores percent (0..100).
* - `HKQuantityTypeIdentifierBodyFatPercentage` ships as a 0..1
* fraction; HealthLog stores percent (0..100).
*
* ── Project convention: server-side scaling is canonical ─────────────
*
* Every HealthKit value travels over the wire as the raw HK reading.
* Whatever ×100 / unit-bend / clamp the canonical DB shape needs, the
* server applies it at ingest inside `convertToDbUnit`. Two reasons:
*
* 1. The wire contract is the HK contract — iOS clients (and any
* future Health Connect / Garmin / Fitbit bridge) emit the
* native sensor reading without per-platform pre-massaging.
* 2. The conversion lives next to the canonical DB unit + the
* plausibility-range guard, so a future contributor adding a
* new identifier touches one file instead of three.
*
* The pre-existing precedents already follow the convention:
*
* - `HKQuantityTypeIdentifierOxygenSaturation` (0..1 → 0..100)
* - `HKQuantityTypeIdentifierBodyFatPercentage` (0..1 → 0..100)
* - `HKQuantityTypeIdentifierAppleWalkingSteadiness` (0..1 → 0..100)
*
* The v1.5.5 gait additions (`walkingAsymmetryPercentage` +
* `walkingDoubleSupportPercentage`) extend the precedent. Older iOS
* releases pre-multiplied those two identifiers by ×100 before
* upload (a footgun the audit team flagged); the iOS client is on
* track to drop the pre-multiplication so every HK percent flows
* through the same canonical server-side scaling path. The coord
* note in `.planning/ios-coord/` documents the one-release shim and
* the migration window.
*/
export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
// ── Body composition ────────────────────────────────────────
@@ -322,6 +350,83 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
convertToDbUnit: () => 1,
aggregation: "sum",
},
// ── v1.5.5 iOS-coord — six previously-deferred identifiers ───
// Background: each entry below sat in `HK_QUANTITY_TYPE_DEFERRED`.
// `mapAppleHealthEntry()` returned null; the batch route emitted
// 200 with a per-entry `skipped:"unmappable_identifier"`; the iOS
// app read 200 as success and advanced its sync anchor. Result:
// every sample carrying one of these identifiers was lost
// forever, no retry path. Wired through end-to-end now.
// Respiratory rate — count-per-minute breaths. Watch + iPhone
// sample this during sleep + workouts. Mean aggregation matches
// Apple's own Health-app display (resting RR averaged over the
// sleep window).
HKQuantityTypeIdentifierRespiratoryRate: {
hkIdentifier: "HKQuantityTypeIdentifierRespiratoryRate",
measurementType: "RESPIRATORY_RATE",
hkUnit: "count/min",
dbUnit: "breaths/min",
convertToDbUnit: (v) => v,
aggregation: "mean",
},
// BMI — iOS computes it from weight + height before upload. We
// still want a first-class metric for trend display so the iOS
// chart can read a single series instead of recomputing per
// datapoint. Unit-less ratio on the wire; canonical label is
// `kg/m²` to match clinical convention.
HKQuantityTypeIdentifierBodyMassIndex: {
hkIdentifier: "HKQuantityTypeIdentifierBodyMassIndex",
measurementType: "BODY_MASS_INDEX",
hkUnit: "count",
dbUnit: "kg/m²",
convertToDbUnit: (v) => v,
aggregation: "latest",
},
// Lean body mass — body-composition counterpart to FAT_MASS.
// Apple ships this in kg; canonical DB unit is kg too.
HKQuantityTypeIdentifierLeanBodyMass: {
hkIdentifier: "HKQuantityTypeIdentifierLeanBodyMass",
measurementType: "LEAN_BODY_MASS",
hkUnit: "kg",
dbUnit: "kg",
convertToDbUnit: (v) => v,
aggregation: "latest",
},
// Walking heart-rate average — distinct from RESTING_HEART_RATE
// (sleep-window minimum) and spot PULSE. Daily rollup.
HKQuantityTypeIdentifierWalkingHeartRateAverage: {
hkIdentifier: "HKQuantityTypeIdentifierWalkingHeartRateAverage",
measurementType: "WALKING_HEART_RATE_AVERAGE",
hkUnit: "count/min",
dbUnit: "bpm",
convertToDbUnit: (v) => v,
aggregation: "mean",
},
// Walking asymmetry — Apple ships as a 0..1 fraction; HealthLog
// stores 0..100 (same convention as walking steadiness, body fat,
// oxygen saturation). See the "server-side scaling is canonical"
// block above for the rationale. The iOS client's previous
// pre-upload ×100 multiplication is the documented migration item.
HKQuantityTypeIdentifierWalkingAsymmetryPercentage: {
hkIdentifier: "HKQuantityTypeIdentifierWalkingAsymmetryPercentage",
measurementType: "WALKING_ASYMMETRY",
hkUnit: "%",
dbUnit: "%",
convertToDbUnit: (v) => v * 100,
aggregation: "latest",
},
// Walking double-support percentage — gait companion metric.
// Same ×100 server-side scaling convention.
HKQuantityTypeIdentifierWalkingDoubleSupportPercentage: {
hkIdentifier: "HKQuantityTypeIdentifierWalkingDoubleSupportPercentage",
measurementType: "WALKING_DOUBLE_SUPPORT",
hkUnit: "%",
dbUnit: "%",
convertToDbUnit: (v) => v * 100,
aggregation: "latest",
},
};
/**
@@ -417,12 +522,13 @@ export function hkIdentifierForType(
* `__tests__/apple-health-mapping.test.ts` flags double-bookings.
*/
export const HK_QUANTITY_TYPE_DEFERRED = new Set<string>([
// Body composition / vitals — v1.5
"HKQuantityTypeIdentifierBodyMassIndex", // computed from weight + height — never stored
// Body composition / vitals
// v1.5.5 — `BodyMassIndex`, `LeanBodyMass`, `RespiratoryRate`,
// `WalkingHeartRateAverage`, `WalkingAsymmetryPercentage`,
// `WalkingDoubleSupportPercentage` moved into the mapping table.
// The remaining identifiers below stay deferred until a sibling
// MeasurementType lands.
"HKQuantityTypeIdentifierHeight", // already on User.heightCm
"HKQuantityTypeIdentifierLeanBodyMass",
"HKQuantityTypeIdentifierRespiratoryRate",
"HKQuantityTypeIdentifierWalkingHeartRateAverage",
"HKQuantityTypeIdentifierHeartRateRecoveryOneMinute",
"HKQuantityTypeIdentifierAppleSleepingWristTemperature",
"HKQuantityTypeIdentifierBasalEnergyBurned",
@@ -432,8 +538,6 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set<string>([
// Running / walking form — v1.5+
"HKQuantityTypeIdentifierWalkingSpeed",
"HKQuantityTypeIdentifierWalkingStepLength",
"HKQuantityTypeIdentifierWalkingAsymmetryPercentage",
"HKQuantityTypeIdentifierWalkingDoubleSupportPercentage",
"HKQuantityTypeIdentifierStairAscentSpeed",
"HKQuantityTypeIdentifierStairDescentSpeed",
"HKQuantityTypeIdentifierSixMinuteWalkTestDistance",
+12
View File
@@ -64,6 +64,9 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
["PULSE", "vitals"],
["OXYGEN_SATURATION", "vitals"],
["BODY_TEMPERATURE", "vitals"],
// v1.5.5 — respiratory rate sits alongside SpO2 + body temp as a
// point-in-time clinical vital.
["RESPIRATORY_RATE", "vitals"],
// ── Body composition ──
["WEIGHT", "body"],
@@ -74,6 +77,10 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
["TOTAL_BODY_WATER", "body"],
["BONE_MASS", "body"],
["VISCERAL_FAT", "body"],
// v1.5.5 — BMI + lean body mass round out the body-composition
// surface (FAT_MASS already covers the fat side).
["BODY_MASS_INDEX", "body"],
["LEAN_BODY_MASS", "body"],
// ── Activity (cumulative + fitness + mobility) ──
["ACTIVITY_STEPS", "activity"],
@@ -82,6 +89,11 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
["WALKING_RUNNING_DISTANCE", "activity"],
["VO2_MAX", "activity"],
["WALKING_STEADINESS", "activity"],
// v1.5.5 — gait + walking-HR-average sit alongside walking
// steadiness as Apple-Health Mobility-section signals.
["WALKING_ASYMMETRY", "activity"],
["WALKING_DOUBLE_SUPPORT", "activity"],
["WALKING_HEART_RATE_AVERAGE", "activity"],
// ── Sleep ──
["SLEEP_DURATION", "sleep"],
+16
View File
@@ -59,6 +59,9 @@ export function getPRDirection(
// Mobility section treats higher as the achievement (the
// recovery direction from a low-steadiness window).
case "WALKING_STEADINESS":
// v1.5.5 — lean body mass is the muscle-mass-adjacent body-comp
// axis where higher is the goal.
case "LEAN_BODY_MASS":
return PersonalRecordDirection.MAX;
// MIN direction — lower value is the record.
@@ -70,6 +73,11 @@ export function getPRDirection(
case "PULSE_WAVE_VELOCITY":
case "AUDIO_EXPOSURE_ENV":
case "AUDIO_EXPOSURE_HEADPHONE":
// v1.5.5 — gait asymmetry + double-support percentages: Apple's
// Mobility section frames lower as the achievement (more
// symmetric gait + shorter double-support fraction = healthier).
case "WALKING_ASYMMETRY":
case "WALKING_DOUBLE_SUPPORT":
return PersonalRecordDirection.MIN;
// Explicitly no PersonalRecord — see comment block above.
@@ -88,6 +96,14 @@ export function getPRDirection(
// a row" is interesting but better surfaced as a streak than a
// PR direction. Defer.
case "AUDIO_EXPOSURE_EVENT":
// v1.5.5 — homeostatic / display-only metrics. Respiratory rate
// is goal-neutral (homeostatic), walking HR average is fitness
// dependent without a clean direction (a higher value can be
// either ill or harder-effort), and BMI direction is user-goal
// dependent — defer until the worker can read User.thresholdsJson.
case "RESPIRATORY_RATE":
case "WALKING_HEART_RATE_AVERAGE":
case "BODY_MASS_INDEX":
return null;
}
}
+42
View File
@@ -35,6 +35,13 @@ export const measurementTypeEnum = z.enum([
// ── v1.4.30 R-F T1.4 + T1.5 ──
"WALKING_STEADINESS",
"AUDIO_EXPOSURE_EVENT",
// ── v1.5.5 iOS-coord — six previously-deferred HK identifiers wired end-to-end ──
"RESPIRATORY_RATE",
"BODY_MASS_INDEX",
"LEAN_BODY_MASS",
"WALKING_HEART_RATE_AVERAGE",
"WALKING_ASYMMETRY",
"WALKING_DOUBLE_SUPPORT",
]);
export const glucoseContextEnum = z.enum([
@@ -97,6 +104,23 @@ const unitMap: Record<string, string> = {
// 0–1440 covers the 24-hour day; in practice indoor users sit near 0
// and outdoor athletes accumulate a few hours.
TIME_IN_DAYLIGHT: "minutes",
// ── v1.5.5 iOS-coord additions ──
// Respiratory rate breaths-per-minute — the HK identifier ships as
// `count/min`; we keep the more conventional clinical label.
RESPIRATORY_RATE: "breaths/min",
// BMI kg/m² — HealthKit ships the unitless ratio; the canonical
// display string mirrors clinical convention.
BODY_MASS_INDEX: "kg/m²",
// Lean body mass kg — body-composition partner to FAT_MASS.
LEAN_BODY_MASS: "kg",
// Walking heart rate average bpm — daily rollup; distinct from
// RESTING_HEART_RATE (sleep-window minimum) and spot PULSE.
WALKING_HEART_RATE_AVERAGE: "bpm",
// Walking gait percent (0-100 after server-side ×100 scaling).
// Same convention as WALKING_STEADINESS / BODY_FAT / OXYGEN_SATURATION
// — see the project convention block in `apple-health-mapping.ts`.
WALKING_ASYMMETRY: "%",
WALKING_DOUBLE_SUPPORT: "%",
};
export function getUnitForType(type: string): string {
@@ -176,6 +200,24 @@ const VALUE_RANGES: Record<string, { min: number; max: number }> = {
// Time in daylight (minutes/day) — full 24-hour window. Outdoor work
// tops the range; sedentary indoor days sit near 0.
TIME_IN_DAYLIGHT: { min: 0, max: 1440 },
// ── v1.5.5 iOS-coord additions ──
// Respiratory rate breaths/min — adult range ~12-20 at rest; severe
// distress can spike past 40; bradypnea floor ~4. Generous bounds.
RESPIRATORY_RATE: { min: 3, max: 60 },
// BMI kg/m² — sub-12 is starvation-class, 70 covers extreme obesity.
// Outside the band is almost certainly a height-or-weight typo.
BODY_MASS_INDEX: { min: 8, max: 70 },
// Lean body mass kg — adult plausibility, ~25 kg (small adult) to
// 150 kg (extreme lean athlete). Same shape as FAT_FREE_MASS.
LEAN_BODY_MASS: { min: 10, max: 250 },
// Walking heart rate average bpm — endurance athletes can sit in
// the high 70s while walking; ailing or post-stimulant readings can
// touch 200. Same upper bound as the spot PULSE / RESTING_HEART_RATE.
WALKING_HEART_RATE_AVERAGE: { min: 30, max: 220 },
// Gait percent (0-100). Apple ships these as 0..1 fractions; after
// the ×100 server-side scaling the canonical band is 0..100.
WALKING_ASYMMETRY: { min: 0, max: 100 },
WALKING_DOUBLE_SUPPORT: { min: 0, max: 100 },
};
export function validateMeasurementRange(