feat(measurements): 10-minute aggregated heart-rate upload contract

Refine the go-forward aggregated HR wire contract (iOS #34) from the never-
adopted hourly bucket to a 10-minute UTC bucket, so one contract serves both
the measurements-list de-explosion and the intraday-pulse 10-min curve.

- Batch route accepts stats:HKQuantityTypeIdentifierHeartRate:<10-min ISO-Z>
  with avg + min/max, overwrite semantics, malformed-suffix rejection; the
  validator is identifier-generic (allowlist) so respiratoryRate/SpO2 can join
  later without a grammar change.
- The intraday reader classifies a day's PULSE rows three ways by externalId
  shape (raw / uploaded 10-min bucket / server-folded hourly), builds the
  10-min curve directly from uploaded buckets with the min/max envelope and
  tension detection enabled, and overlays buckets over raw on the one
  cutover-straddling day.
- DAY rollup min/max read COALESCE(value_min, value) so a bucket-native day's
  band reflects the true intra-bucket extremes.

No schema change (value_min/value_max exist since v1.19.2). Reconciliation is
go-forward cutover; the nightly retention fold handles the raw backlog.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 11:20:26 +02:00
1 parent 389bdb5d5d
commit 5fa18a3754
10 files changed
+531 -206

No files matched your search

@@ -3,10 +3,10 @@
* `POST /api/measurements/batch`.
*
* Asserts:
* - a fresh hourly HR bucket inserts;
* - a re-post of the same hour OVERWRITES (status `updated`, not a
* - a fresh 10-min HR bucket inserts;
* - a re-post of the same bucket OVERWRITES (status `updated`, not a
* duplicate row);
* - a malformed hourly-HR bucket externalId is `skipped`;
* - a malformed aggregated HR bucket externalId is `skipped`;
* - the per-sample uuid HR path is unaffected (immutable duplicate);
* - the existing per-day step `stats:` overwrite path is unaffected.
*/
@@ -131,8 +131,8 @@ beforeEach(() => {
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 1 });
});
describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", () => {
it("inserts a fresh hourly HR bucket as a PULSE row", async () => {
describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)", () => {
it("inserts a fresh 10-min HR bucket as a PULSE row", async () => {
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
const res = await POST(
makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 72)] }),
@@ -143,7 +143,7 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
expect(data.updated).toBe(0);
expect(data.entries[0].status).toBe("inserted");
// Stored as a PULSE row carrying the hourly average value.
// Stored as a PULSE row carrying the 10-min average value.
const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0];
const rows = (createArg as { data: { type: string; value: number }[] })
.data;
@@ -151,7 +151,7 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
expect(rows[0].value).toBe(72);
});
it("persists per-bucket min/max on a fresh hourly HR bucket (iOS #34 ext)", async () => {
it("persists per-bucket min/max on a fresh 10-min HR bucket (iOS #34 ext)", async () => {
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
const res = await POST(
makeRequest({
@@ -214,8 +214,8 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
expect(rows[0].valueMax).toBeNull();
});
it("overwrites the same hour bucket on a re-post (updated, not duplicate)", async () => {
// The bucket already exists from an earlier within-hour post.
it("overwrites the same bucket on a re-post (updated, not duplicate)", async () => {
// The bucket already exists from an earlier within-bucket post.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
type: "PULSE",
@@ -234,19 +234,19 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
expect(data.duplicates).toBe(0);
expect(data.entries[0].status).toBe("updated");
// The overwrite went through updateMany with the new hourly average.
// The overwrite went through updateMany with the new 10-min average.
expect(prisma.measurement.updateMany).toHaveBeenCalledTimes(1);
const updateArg = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
expect((updateArg as { data: { value: number } }).data.value).toBe(78);
expect(prisma.measurement.createMany).not.toHaveBeenCalled();
});
it("skips a malformed hourly HR bucket externalId", async () => {
it("skips a malformed aggregated HR bucket externalId", async () => {
const res = await POST(
makeRequest({
entries: [
hrBucketEntry(
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:30:00.000Z",
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:35:00.000Z",
70,
),
],
+16 -17
View File
@@ -50,8 +50,8 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import {
mapAppleHealthEntry,
isHourlyHeartRateStatsExternalId,
targetsHourlyHeartRateBucket,
isAggregatedBucketExternalId,
targetsAggregatedBucket,
} from "@/lib/measurements/apple-health-mapping";
import {
MEASURED_AT_TOLERANCE_MS,
@@ -292,19 +292,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
continue;
}
// v1.19.0 (iOS #34) — go-forward aggregated heart-rate wire
// contract. A `stats:HKQuantityTypeIdentifierHeartRate:<hour>` row
// is the hourly-average PULSE bucket; it rides the generic `stats:`
// overwrite path below so a within-hour re-post (the running mean
// shifts) replaces the value instead of duplicating. Reject a row
// that targets the HR-bucket prefix but carries a malformed hour
// suffix — a garbage suffix would mint an un-overwriteable row that
// the next hour's re-post can't collapse onto. A well-formed bucket
// (and every non-HR `stats:` / per-sample `uuid` externalId) passes
// through unchanged.
// v1.30.7 (iOS #34) — go-forward aggregated heart-rate wire
// contract. A `stats:HKQuantityTypeIdentifierHeartRate:<10-min-ISO-Z>`
// row is the 10-minute-average PULSE bucket; it rides the generic
// `stats:` overwrite path below so a within-bucket re-post (the running
// mean shifts) replaces the value instead of duplicating. Reject a row
// that targets a bucket prefix but carries a malformed suffix — a
// garbage suffix would mint an un-overwriteable row the next re-post
// can't collapse onto. A well-formed bucket (and every non-bucket
// `stats:` / per-sample `uuid` externalId) passes through unchanged.
if (
targetsHourlyHeartRateBucket(entry.externalId) &&
!isHourlyHeartRateStatsExternalId(entry.externalId)
targetsAggregatedBucket(entry.externalId) &&
!isAggregatedBucketExternalId(entry.externalId)
) {
results[index] = {
index,
@@ -314,12 +313,12 @@ async function postBatch(request: NextRequest): Promise<Response> {
continue;
}
// v1.19.2 (iOS #34 extension) — the per-bucket MIN / MAX are persisted
// ONLY on a well-formed hourly HR bucket row. A per-sample reading, a
// v1.30.7 (iOS #34) — the per-bucket MIN / MAX are persisted ONLY on a
// well-formed 10-min aggregated bucket row. A per-sample reading, a
// per-day cumulative `stats:` total, or a manual entry never carries a
// spread, so we pin them to null there even if a client mis-sends the
// fields — `value` stays the single source of truth for those rows.
const isHrBucket = isHourlyHeartRateStatsExternalId(entry.externalId);
const isHrBucket = isAggregatedBucketExternalId(entry.externalId);
const valueMin =
isHrBucket && typeof mapped.valueMin === "number"
? mapped.valueMin