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,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")
}