From 6e6bc61800fabbb551affe0b7a12522ea70d7dd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 11:51:25 +0200 Subject: [PATCH] feat(api): lock the daily-stats externalId shape for cumulative HealthKit types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R-A Option A handoff prep. Adds dailyStatsExternalId() helper alongside the v1.4.29 CUMULATIVE_HK_TYPES set; the helper mints "stats::" so iOS can UPSERT one row per day per cumulative type via the existing batch endpoint, the unique index collapses re-syncs idempotently, and a future PATCH-on- divergence call updates a late-watch-sync revision without inserting a second row. Handoff docs (06 + 08) lock the shape for the iOS client. Cumulative-type set unchanged from v1.4.29; spot metrics keep the per-sample uuid externalId. Server tolerates both shapes during the cutover window — the drain script (next commit) collapses pre-existing per-sample rows post-cutover. Tests cover the helper round-trip, the cumulative-type identifier wall, and the iOS-trusted date-string passthrough. --- .../06-ios-responsibilities.md | 38 +++++++++++++ .../v15-ios-handoff/08-locked-contracts.md | 56 ++++++++++++++++++- .../__tests__/apple-health-mapping.test.ts | 54 ++++++++++++++++++ src/lib/measurements/apple-health-mapping.ts | 35 ++++++++++++ 4 files changed, 182 insertions(+), 1 deletion(-) diff --git a/.planning/v15-ios-handoff/06-ios-responsibilities.md b/.planning/v15-ios-handoff/06-ios-responsibilities.md index e2e2b3511..51294decf 100644 --- a/.planning/v15-ios-handoff/06-ios-responsibilities.md +++ b/.planning/v15-ios-handoff/06-ios-responsibilities.md @@ -349,6 +349,43 @@ The iOS app could accidentally cross the MDR line by: When in doubt: **the iOS app is a thin client over server-rendered intelligence**. The server is the medical-information custodian; iOS is the presentation layer + the HK adapter. +## Cumulative metrics: daily aggregation on iOS (v1.4.30 — R-A Option A) + +The five cumulative HealthKit types — `HKQuantityTypeIdentifierStepCount`, `HKQuantityTypeIdentifierActiveEnergyBurned`, `HKQuantityTypeIdentifierFlightsClimbed`, `HKQuantityTypeIdentifierDistanceWalkingRunning`, `HKQuantityTypeIdentifierTimeInDaylight` — switch from per-sample ingest to **one row per day per type**, pre-aggregated on iOS via `HKStatisticsCollectionQuery` with `intervalComponents: DateComponents(day: 1)` and `options: .cumulativeSum`. + +Rationale + decision record: `.planning/research/v15-r-a-step-aggregation.md` §5. Server enforcement of the externalId shape: `.planning/v15-ios-handoff/08-locked-contracts.md` §13. + +### Daily-stats externalId shape + +``` +externalId = "stats::" +``` + +Example: `stats:HKQuantityTypeIdentifierStepCount:2026-05-16`. + +The date string is anchored to the user's IANA timezone (read via `GET /api/auth/me` → `User.timezone`). iOS generates it with `DateFormatter` using the `yyyy-MM-dd` pattern; the server trusts the format and does not re-validate. The existing `@@unique([userId, type, source, externalId])` index UPSERTs idempotently across re-syncs. + +### iOS service contract + +- New file `HealthLog/Services/HealthKitStatisticsService.swift` wraps `HKStatisticsCollectionQuery` per cumulative type. On every observer wake, ask "what's the new daily sum for the affected days?" and POST one row per affected day per type via the existing `/api/measurements/batch` endpoint. +- `value` = `HKStatistics.sumQuantity().doubleValue(for: )`. +- `measuredAt` = the canonical midday-UTC timestamp for the user's calendar day (matches the Withings activity-sync convention). +- `unit`, `startDate`, `endDate` follow the per-day envelope (canonical unit; `startDate` = day-start, `endDate` = next-day-start − 1 ms in user TZ). +- `deviceType` = the dominant contributing source's device (`watch` when the user wears one, else `phone`). +- `externalId` = `dailyStatsExternalId(hkIdentifier, dateYYYYMMDD)` per the helper in `src/lib/measurements/apple-health-mapping.ts`. + +### Late-watch-sync (PATCH-on-divergence) + +When the Watch syncs at 14:00 with samples from the same morning, the existing day's row should **update**, not insert a second. The deterministic externalId collapses the second POST to `status: "duplicate"` via the unique index; to make the new value visible iOS keeps a small SQLite cache of `(type, day, last-posted-value)` and issues `PATCH /api/measurements/[id]` when the new daily total diverges. Insert-only batch ingest stays narrow; the PATCH path already exists. + +### Server tolerance during cutover + +The server accepts BOTH shapes — pre-existing per-sample rows (`externalId = HKSample.uuid`) and the new daily-aggregated rows (`externalId = "stats:..."`) coexist for the cutover window. Operator runs `POST /api/admin/drain-per-sample-cumulative` (or `scripts/drain-per-sample-cumulative.ts` directly) once after the new TestFlight build adopts the daily path; the drain collapses pre-existing per-sample APPLE_HEALTH cumulative rows into daily rows keyed on the new externalId shape. Re-running the drain is a no-op (idempotent). + +### Spot-sample path unchanged + +Non-cumulative HK types (weight, BP, pulse, BG, body fat, HRV, RHR, SpO2, body temp, VO2 max, sleep) keep `externalId = HKSample.uuid.uuidString`. Only the five cumulative types switch shape; only their iOS pipeline (`HealthKitStatisticsService.swift`) is new. + ## "Since v1.4.24" diff markers - **NEW v1.4.25** — `MeasurementType` enum gained `AUDIO_EXPOSURE_ENV`, `AUDIO_EXPOSURE_HEADPHONE`, `TIME_IN_DAYLIGHT` for HK + Withings parity. @@ -356,6 +393,7 @@ When in doubt: **the iOS app is a thin client over server-rendered intelligence* - **NEW v1.4.25 W16c** — `PERSONAL_RECORD` event type for APNs; default OFF. - **NEW v1.4.25 W10** — batch ingest rate limit 60/min per user (was unbounded). - **NEW v1.4.25 W17b/c** — Withings now syncs Activity v2 + Sleep v2 — iOS should be aware that some HK metrics may arrive via the Withings path (no action required, but the source-priority picker now matters more). +- **NEW v1.4.30** — Cumulative HealthKit types switch to one-row-per-day daily-aggregated ingest. iOS owns `HealthKitStatisticsService.swift` with `HKStatisticsCollectionQuery`; per-day externalId is `"stats::"`. Per-sample path stays valid during the cutover (server tolerates both shapes); operator runs the drain script once post-cutover. See the "Cumulative metrics: daily aggregation on iOS" section above and `08-locked-contracts.md` §13. ## iOS implementation checklist diff --git a/.planning/v15-ios-handoff/08-locked-contracts.md b/.planning/v15-ios-handoff/08-locked-contracts.md index b2e1863b0..92fc3e6e8 100644 --- a/.planning/v15-ios-handoff/08-locked-contracts.md +++ b/.planning/v15-ios-handoff/08-locked-contracts.md @@ -466,7 +466,61 @@ BEFORE shipping. --- -## 12. What is NOT in this file +## 12. Daily-stats externalId shape (v1.4.30 — R-A Option A) + +The five cumulative HealthKit types switch from per-sample ingest +(`externalId = HKSample.uuid`) to one row per day per type, pre-aggregated +on iOS via `HKStatisticsCollectionQuery`. The per-day row's +`externalId` is locked at this shape: + +``` +externalId = "stats::" +``` + +Examples: + +| HK identifier | Day | externalId | +| --- | --- | --- | +| `HKQuantityTypeIdentifierStepCount` | 2026-05-16 | `stats:HKQuantityTypeIdentifierStepCount:2026-05-16` | +| `HKQuantityTypeIdentifierActiveEnergyBurned` | 2026-05-16 | `stats:HKQuantityTypeIdentifierActiveEnergyBurned:2026-05-16` | +| `HKQuantityTypeIdentifierFlightsClimbed` | 2026-05-16 | `stats:HKQuantityTypeIdentifierFlightsClimbed:2026-05-16` | +| `HKQuantityTypeIdentifierDistanceWalkingRunning` | 2026-05-16 | `stats:HKQuantityTypeIdentifierDistanceWalkingRunning:2026-05-16` | +| `HKQuantityTypeIdentifierTimeInDaylight` | 2026-05-16 | `stats:HKQuantityTypeIdentifierTimeInDaylight:2026-05-16` | + +**Scope**: cumulative types only. The set is the canonical +`CUMULATIVE_HK_TYPES` in `src/lib/measurements/apple-health-mapping.ts`. +Spot metrics (weight, BP, pulse, BG, body fat, HRV, RHR, SpO2, body +temp, VO2 max, sleep) keep `externalId = HKSample.uuid.uuidString`. + +**Server enforcement**: the helper +`dailyStatsExternalId(hkIdentifier, dateYYYYMMDD)` is the single source +of truth on the server side. The drain script +`scripts/drain-per-sample-cumulative.ts` mints the same shape when +collapsing legacy per-sample rows. + +**Date string**: anchored to the user's IANA timezone (read via +`GET /api/auth/me` → `User.timezone`). iOS generates it with +`DateFormatter` using the `yyyy-MM-dd` pattern. The server trusts the +inbound format and does not re-validate beyond the existing +`externalId` Zod cap (`min(1).max(120)`). + +**Cutover tolerance**: the server accepts BOTH shapes during the +cutover window. Operator runs the drain script +(`POST /api/admin/drain-per-sample-cumulative` or the CLI variant) once +after the new TestFlight build adopts the daily-stats path; re-running +the drain is a no-op (idempotent). + +**Late-watch-sync handling**: a second POST for the same day collapses +to `status: "duplicate"` via the existing +`@@unique([userId, type, source, externalId])` index. To make a +divergent daily total visible, iOS issues +`PATCH /api/measurements/[id]` with the new value; the per-day SQLite +cache (`type, day, last-posted-value`) drives the divergence check. + +**Test fence**: `dailyStatsExternalId` round-trip is covered by +`src/lib/measurements/__tests__/apple-health-mapping.test.ts`. + +## 13. What is NOT in this file - **API envelope details (`{ data, error, meta }`)** → `17-error-handling.md` - **Coach snapshot construction** → `14-coach-mental-model.md` diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index ac8475f94..2719769a5 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"; import { APPLE_HEALTH_SLEEP_STAGE_MAP, APPLE_HEALTH_TYPE_MAP, + CUMULATIVE_HK_TYPES, HK_QUANTITY_TYPE_DEFERRED, + dailyStatsExternalId, mapAppleHealthEntry, } from "../apple-health-mapping"; import { measurementTypeEnum } from "@/lib/validations/measurement"; @@ -343,3 +345,55 @@ describe("mapAppleHealthEntry", () => { expect(out).toBeNull(); }); }); + +describe("dailyStatsExternalId (v1.4.30 — R-A Option A handoff lock)", () => { + it("produces the canonical stats:: shape for stepCount", () => { + expect( + dailyStatsExternalId("HKQuantityTypeIdentifierStepCount", "2026-05-16"), + ).toBe("stats:HKQuantityTypeIdentifierStepCount:2026-05-16"); + }); + + it("produces the same shape for every cumulative-type identifier", () => { + const cases: Array<[string, string]> = [ + ["HKQuantityTypeIdentifierStepCount", "2026-01-01"], + ["HKQuantityTypeIdentifierActiveEnergyBurned", "2026-02-29"], + ["HKQuantityTypeIdentifierFlightsClimbed", "2025-12-31"], + ["HKQuantityTypeIdentifierDistanceWalkingRunning", "2024-02-29"], + ["HKQuantityTypeIdentifierTimeInDaylight", "2026-05-16"], + ]; + for (const [id, day] of cases) { + expect(dailyStatsExternalId(id, day)).toBe(`stats:${id}:${day}`); + } + }); + + it("accepts the date string as-is — iOS owns the format", () => { + // Per R-A §5, iOS generates the date string from the user's IANA + // timezone via DateFormatter with the `yyyy-MM-dd` pattern. The + // server trusts the inbound shape rather than re-validating; the + // receiving Zod schema already caps `externalId` at 120 chars. + expect( + dailyStatsExternalId("HKQuantityTypeIdentifierStepCount", " 2026-05-16 "), + ).toBe("stats:HKQuantityTypeIdentifierStepCount: 2026-05-16 "); + expect( + dailyStatsExternalId("HKQuantityTypeIdentifierStepCount", ""), + ).toBe("stats:HKQuantityTypeIdentifierStepCount:"); + }); + + it("covers every CUMULATIVE_HK_TYPES MeasurementType via a known HK identifier", () => { + // Sanity wall: every cumulative MeasurementType has a forward-map + // entry in APPLE_HEALTH_TYPE_MAP so the iOS-side daily-stats + // service can mint the externalId without a server round-trip. + const cumulativeIdentifiers = Object.values(APPLE_HEALTH_TYPE_MAP) + .filter((m) => CUMULATIVE_HK_TYPES.has(m.measurementType)) + .map((m) => m.hkIdentifier); + expect(cumulativeIdentifiers.sort()).toEqual( + [ + "HKQuantityTypeIdentifierStepCount", + "HKQuantityTypeIdentifierActiveEnergyBurned", + "HKQuantityTypeIdentifierFlightsClimbed", + "HKQuantityTypeIdentifierDistanceWalkingRunning", + "HKQuantityTypeIdentifierTimeInDaylight", + ].sort(), + ); + }); +}); diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index cbad48138..f546c5bea 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -310,6 +310,41 @@ export const CUMULATIVE_HK_TYPES: ReadonlySet = new Set:`. + * + * Cumulative HK types only — the spot-sample path keeps using + * `HKSample.uuid` as `externalId`. The shape is intentionally + * stable across the cutover: iOS clients still posting per-sample + * rows round-trip through the existing `(userId, type, source, + * externalId)` unique index; iOS clients on the daily-stats path + * collide on the deterministic `"stats:..."` key for idempotent + * UPSERTs. + * + * Locked contract — see + * `.planning/v15-ios-handoff/08-locked-contracts.md` §13 and + * `.planning/v15-ios-handoff/06-ios-responsibilities.md` Domain 1 + * "Cumulative metrics: daily aggregation on iOS". + * + * The helper accepts the date string as-is: iOS generates it from + * the user's IANA timezone via `DateFormatter` with the + * `yyyy-MM-dd` pattern; the server trusts that format rather than + * re-validating per ingest because the iOS handoff doc locks the + * shape and the receiving Zod schema already caps `externalId` at + * 120 characters. + */ +export function dailyStatsExternalId( + hkIdentifier: string, + dateYYYYMMDD: string, +): string { + return `stats:${hkIdentifier}:${dateYYYYMMDD}`; +} + /** * HK identifiers the iOS app may emit that HealthLog deliberately does * NOT map yet. Listing them here means the batch route can log a