mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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
|
||||
|
||||
Reference in new issue
Block a user