feat(api): lock the daily-stats externalId shape for cumulative HealthKit types

R-A Option A handoff prep. Adds dailyStatsExternalId() helper alongside
the v1.4.29 CUMULATIVE_HK_TYPES set; the helper mints
"stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>" 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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 11:51:25 +02:00
1 parent 1a2447295d
commit 6e6bc61800
4 files changed
+182 -1

No files matched your search

@@ -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:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"
```
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: <canonical unit>)`.
- `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:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"`. 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
@@ -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:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"
```
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`
@@ -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:<type>:<date> 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(),
);
});
});
@@ -310,6 +310,41 @@ export const CUMULATIVE_HK_TYPES: ReadonlySet<MeasurementType> = new Set<Measure
"TIME_IN_DAYLIGHT",
]);
/**
* v1.4.30 — externalId shape for daily-aggregated cumulative
* HealthKit rows. iOS emits one row per day per cumulative type via
* `HKStatisticsCollectionQuery` per R-A Option A; the externalId
* UPSERTs the matching server row idempotently across re-syncs.
*
* Format: `stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>`.
*
* 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