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
@@ -154,4 +154,59 @@ describe("loadIntradayPulse", () => {
);
expect(result.baseline).not.toBe(buggyBaseline);
});
it("v1.30.7 — classifies uploaded 10-min HR buckets to a tenMin series with the envelope", async () => {
const tz = "UTC";
const dateKey = "2026-06-15";
// A bucket-native day: the view-day PULSE read returns uploaded 10-min
// `stats:<HK>:<ISO-Z>` rows (avg in `value`, spread in valueMin/valueMax),
// no raw per-sample rows. Mature resting baseline so nothing falls back.
const restingRows = Array.from({ length: 20 }, (_, i) => ({
value: 55,
measuredAt: new Date(
`2026-05-${String((i % 28) + 1).padStart(2, "0")}T06:00:00.000Z`,
),
}));
const bucketRows = [
{
value: 72,
valueMin: 64,
valueMax: 88,
measuredAt: new Date("2026-06-15T08:09:00.000Z"),
externalId:
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-15T08:00:00.000Z",
},
{
value: 75,
valueMin: 70,
valueMax: 81,
measuredAt: new Date("2026-06-15T08:19:00.000Z"),
externalId:
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-15T08:10:00.000Z",
},
];
findManyMock.mockImplementation(
async (args: {
where: { type: string; measuredAt?: unknown };
take?: number;
}) => {
if (args.where.type === "RESTING_HEART_RATE") return restingRows;
if (args.where.type === "PULSE" && args.where.measuredAt) {
return bucketRows; // the view-day-windowed read
}
return [];
},
);
const result = await loadIntradayPulse("user-1", tz, dateKey);
expect(result.resolution).toBe("tenMin");
expect(result.bucketMinutes).toBe(10);
expect(result.series).toEqual([
{ startMinute: 480, mean: 72, count: 2, min: 64, max: 88 },
{ startMinute: 490, mean: 75, count: 2, min: 70, max: 81 },
]);
});
});
@@ -15,6 +15,7 @@ import {
MIN_STEP_COVERAGE_BUCKETS,
computeHourlyMeanSeries,
computeTenMinuteMeanSeries,
computeUploadedBucketSeries,
detectTensionWindow,
makeLocalResolver,
partOfDayForMinute,
@@ -78,8 +79,8 @@ describe("computeTenMinuteMeanSeries", () => {
utcOf,
);
expect(series).toEqual([
{ startMinute: 600, mean: 75, count: 2 },
{ startMinute: 610, mean: 90, count: 1 },
{ startMinute: 600, mean: 75, count: 2, min: 70, max: 80 },
{ startMinute: 610, mean: 90, count: 1, min: 90, max: 90 },
]);
});
@@ -89,7 +90,9 @@ describe("computeTenMinuteMeanSeries", () => {
"2026-05-01",
utcOf,
);
expect(series).toEqual([{ startMinute: 480, mean: 66, count: 1 }]);
expect(series).toEqual([
{ startMinute: 480, mean: 66, count: 1, min: 66, max: 66 },
]);
});
it("excludes samples from other local days (no next-day bleed)", () => {
@@ -101,7 +104,9 @@ describe("computeTenMinuteMeanSeries", () => {
"2026-05-01",
utcOf,
);
expect(series).toEqual([{ startMinute: 1430, mean: 70, count: 1 }]);
expect(series).toEqual([
{ startMinute: 1430, mean: 70, count: 1, min: 70, max: 70 },
]);
});
it("buckets on the USER's local day, not UTC (tz-correct windows)", () => {
@@ -110,7 +115,73 @@ describe("computeTenMinuteMeanSeries", () => {
const s = [sample("2026-05-01T02:30:00Z", 72)];
expect(computeTenMinuteMeanSeries(s, "2026-05-01", nyOf)).toEqual([]);
expect(computeTenMinuteMeanSeries(s, "2026-04-30", nyOf)).toEqual([
{ startMinute: 1350, mean: 72, count: 1 },
{ startMinute: 1350, mean: 72, count: 1, min: 72, max: 72 },
]);
});
});
describe("computeUploadedBucketSeries", () => {
it("maps each bucket to its local 10-min slot with mean, synthetic count, and envelope", () => {
const series = computeUploadedBucketSeries(
[
{
bucketStart: new Date("2026-05-01T10:00:00.000Z"),
mean: 72,
min: 64,
max: 88,
},
{
bucketStart: new Date("2026-05-01T10:10:00.000Z"),
mean: 75,
min: 70,
max: 81,
},
],
"2026-05-01",
utcOf,
);
// count = MIN_SAMPLES_PER_BUCKET so a device-computed bucket passes the
// tension density gate (a bucket already summarises many raw samples).
expect(series).toEqual([
{ startMinute: 600, mean: 72, count: 2, min: 64, max: 88 },
{ startMinute: 610, mean: 75, count: 2, min: 70, max: 81 },
]);
});
it("omits min/max when the bucket carries no spread (avg-only upload)", () => {
const series = computeUploadedBucketSeries(
[
{
bucketStart: new Date("2026-05-01T08:20:00.000Z"),
mean: 60,
min: null,
max: null,
},
],
"2026-05-01",
utcOf,
);
expect(series).toEqual([{ startMinute: 500, mean: 60, count: 2 }]);
expect(series[0]).not.toHaveProperty("min");
expect(series[0]).not.toHaveProperty("max");
});
it("slots buckets by the UTC bucket-start in the user's local day, not UTC day", () => {
const nyOf = makeLocalResolver("America/New_York");
// 02:30Z on 05-01 = 22:30 EDT on 04-30 → local minute 1350, prior day.
const buckets = [
{
bucketStart: new Date("2026-05-01T02:30:00.000Z"),
mean: 55,
min: 52,
max: 60,
},
];
expect(computeUploadedBucketSeries(buckets, "2026-05-01", nyOf)).toEqual(
[],
);
expect(computeUploadedBucketSeries(buckets, "2026-04-30", nyOf)).toEqual([
{ startMinute: 1350, mean: 55, count: 2, min: 52, max: 60 },
]);
});
});
+54 -10
View File
@@ -26,6 +26,7 @@ import {
MIN_BASELINE_DAYS,
computeHourlyMeanSeries,
computeTenMinuteMeanSeries,
computeUploadedBucketSeries,
detectTensionWindow,
makeLocalResolver,
type IntradayHrBucket,
@@ -34,6 +35,10 @@ import {
type TensionWindow,
type WorkoutInterval,
} from "@/lib/analytics/intraday-pulse";
import {
isAggregatedBucketExternalId,
parseAggregatedBucketStart,
} from "@/lib/measurements/apple-health-mapping";
/** UTC padding around the local calendar day so the query is a safe superset. */
const WINDOW_LEAD_MS = 15 * 60 * 60 * 1000; // covers UTC−14…+12 day starts
@@ -184,7 +189,13 @@ export async function loadIntradayPulse(
},
orderBy: { measuredAt: "asc" },
take: MAX_DAY_PULSE_ROWS,
select: { value: true, measuredAt: true, externalId: true },
select: {
value: true,
valueMin: true,
valueMax: true,
measuredAt: true,
externalId: true,
},
}),
prisma.measurement.findMany({
where: {
@@ -208,24 +219,57 @@ export async function loadIntradayPulse(
resolveBaseline(userId, timezone),
]);
// Split live PULSE rows into raw per-sample rows and already-folded
// `stats:` hourly-mean rows (the dense-intraday-retention fold's output —
// see `dense-intraday-retention.ts`). A day's raw rows are folded and
// tombstoned atomically as a whole, so in practice a day is either wholly
// raw or wholly folded; the split still guards against ever mixing the two
// grains into one mislabeled series.
// v1.30.7 (iOS #34) — classify the day's PULSE rows three ways by externalId
// shape (the shapes are disjoint, so this is unambiguous):
// · raw per-sample — no `stats:` prefix;
// · uploaded 10-min bucket — `stats:HK…:<10-min ISO instant with Z>`
// (the go-forward aggregated wire contract);
// · server-folded hourly — any other `stats:` row (the dense-intraday
// retention fold's `stats:<HK>:<day>T<HH>` local-hour output).
// Per the per-day-exclusive cutover invariant a day is normally single-grain;
// the one cutover-straddling local day is handled by the overlay below.
const rawPulseRows = pulseRows.filter(
(r) => !r.externalId?.startsWith("stats:"),
);
const foldedPulseRows = pulseRows.filter((r) =>
r.externalId?.startsWith("stats:"),
const uploadedBucketRows = pulseRows.filter((r) =>
isAggregatedBucketExternalId(r.externalId),
);
const foldedPulseRows = pulseRows.filter(
(r) =>
r.externalId?.startsWith("stats:") &&
!isAggregatedBucketExternalId(r.externalId),
);
const tenMinSeries = computeTenMinuteMeanSeries(
const rawSeries = computeTenMinuteMeanSeries(
rawPulseRows.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
dateKey,
localOf,
);
const bucketSeries = computeUploadedBucketSeries(
uploadedBucketRows.flatMap((r) => {
const bucketStart = parseAggregatedBucketStart(r.externalId);
return bucketStart
? [{ bucketStart, mean: r.value, min: r.valueMin, max: r.valueMax }]
: [];
}),
dateKey,
localOf,
);
// Merge the two 10-min grains. Normal days are single-grain, so one of the
// two is empty. The cutover-straddling local day is the only mix: overlay the
// uploaded buckets onto the raw slots, bucket authoritative on collision
// (the aggregate is the intended go-forward value for that slot).
let tenMinSeries: IntradayHrBucket[];
if (bucketSeries.length > 0 && rawSeries.length > 0) {
const bySlot = new Map(rawSeries.map((b) => [b.startMinute, b]));
for (const b of bucketSeries) bySlot.set(b.startMinute, b);
tenMinSeries = [...bySlot.values()].sort(
(a, b) => a.startMinute - b.startMinute,
);
} else {
tenMinSeries = bucketSeries.length > 0 ? bucketSeries : rawSeries;
}
// Day-navigator fallback (S11 / v1.29.x): a day outside
// `DENSE_INTRADAY_RETENTION_DAYS` has its raw rows tombstoned by the
+69 -3
View File
@@ -112,6 +112,14 @@ export interface IntradayHrBucket {
mean: number;
/** Raw sample count — buckets below `MIN_SAMPLES_PER_BUCKET` are gaps. */
count: number;
/**
* v1.30.7 — the bucket's low / high bpm, when known: the min/max of the raw
* samples (raw path) or the device-supplied `discreteMin`/`discreteMax` (an
* uploaded 10-min bucket). Absent when the source carries no spread. Drives
* the intraday envelope band around `mean`.
*/
min?: number;
max?: number;
}
/** A workout's local-day span, in minutes since local midnight. */
@@ -182,7 +190,10 @@ export function computeTenMinuteMeanSeries(
dayKey: string,
localOf: LocalResolver,
): IntradayHrBucket[] {
const acc = new Map<number, { sum: number; count: number }>();
const acc = new Map<
number,
{ sum: number; count: number; min: number; max: number }
>();
for (const s of samples) {
const local = localOf(s.measuredAt);
if (local.dayKey !== dayKey) continue;
@@ -192,19 +203,74 @@ export function computeTenMinuteMeanSeries(
if (bucket) {
bucket.sum += s.value;
bucket.count += 1;
bucket.min = Math.min(bucket.min, s.value);
bucket.max = Math.max(bucket.max, s.value);
} else {
acc.set(startMinute, { sum: s.value, count: 1 });
acc.set(startMinute, {
sum: s.value,
count: 1,
min: s.value,
max: s.value,
});
}
}
return [...acc.entries()]
.map(([startMinute, { sum, count }]) => ({
.map(([startMinute, { sum, count, min, max }]) => ({
startMinute,
mean: round1(sum / count),
count,
// v1.30.7 — carry the raw spread for the envelope band, for parity with
// an uploaded bucket's device-supplied min/max.
min: round1(min),
max: round1(max),
}))
.sort((a, b) => a.startMinute - b.startMinute);
}
/**
* v1.30.7 (iOS #34) — build the 10-minute series directly from UPLOADED
* aggregated HR buckets (`stats:HKQuantityTypeIdentifierHeartRate:<10-min-Z>`
* rows), one bucket per slot. Each row already IS a 10-minute mean, so it maps
* 1:1 to a plotted point; `min`/`max` carry the device's `discreteMin`/`Max`.
*
* A device-computed bucket statistic summarises many raw samples the client
* aggregated on-device, so it is treated as DENSE — assigned a synthetic
* `count` of `MIN_SAMPLES_PER_BUCKET` so tension detection stays enabled on a
* bucket-native day (the wire carries no sample count; see the contract doc's
* open-risks note on the density gate).
*
* `bucketStart` is the canonical UTC 10-min boundary from the externalId
* (resolved to the local slot), NOT the row's `measuredAt` (which carries the
* display offset). Last write wins on the rare slot collision (:45-offset
* zones can skew two UTC buckets toward one local slot).
*/
export function computeUploadedBucketSeries(
buckets: ReadonlyArray<{
bucketStart: Date;
mean: number;
min: number | null;
max: number | null;
}>,
dayKey: string,
localOf: LocalResolver,
): IntradayHrBucket[] {
const acc = new Map<number, IntradayHrBucket>();
for (const b of buckets) {
const local = localOf(b.bucketStart);
if (local.dayKey !== dayKey) continue;
const startMinute =
Math.floor(local.minuteOfDay / BUCKET_MINUTES) * BUCKET_MINUTES;
acc.set(startMinute, {
startMinute,
mean: round1(b.mean),
count: MIN_SAMPLES_PER_BUCKET,
...(b.min != null ? { min: round1(b.min) } : {}),
...(b.max != null ? { max: round1(b.max) } : {}),
});
}
return [...acc.values()].sort((a, b) => a.startMinute - b.startMinute);
}
/**
* Fold a day's already-folded `stats:` hourly-mean rows into ascending
* per-local-hour buckets — the day-navigator's fallback shape for a day
@@ -0,0 +1,138 @@
/**
* v1.30.7 (iOS #34) — aggregated 10-minute heart-rate wire-contract helpers.
*
* Covers the externalId minting, the well-formedness guard, and the bucket-start
* parser that the batch ingest route + the intraday reader use to route the
* go-forward 10-min-average PULSE bucket through the existing `stats:` overwrite
* path. Supersedes the retired v1.19.0 hourly shape.
*/
import { describe, expect, it } from "vitest";
import {
heartRateBucketExternalId,
isAggregatedBucketExternalId,
parseAggregatedBucketStart,
targetsAggregatedBucket,
} from "@/lib/measurements/apple-health-mapping";
describe("heartRateBucketExternalId", () => {
it("floors to the 10-minute UTC bucket and mints the canonical stats id", () => {
const id = heartRateBucketExternalId(new Date("2026-07-18T14:37:42.913Z"));
expect(id).toBe(
"stats:HKQuantityTypeIdentifierHeartRate:2026-07-18T14:30:00.000Z",
);
});
it("is stable: two instants in the same 10-min window map to one id", () => {
const a = heartRateBucketExternalId(new Date("2026-07-18T14:30:00Z"));
const b = heartRateBucketExternalId(new Date("2026-07-18T14:39:59Z"));
expect(a).toBe(b);
});
it("keeps adjacent 10-min windows distinct", () => {
const a = heartRateBucketExternalId(new Date("2026-07-18T14:29:59Z"));
const b = heartRateBucketExternalId(new Date("2026-07-18T14:30:00Z"));
expect(a).not.toBe(b);
});
it("round-trips through the well-formedness guard and the parser", () => {
const id = heartRateBucketExternalId(new Date("2026-01-02T03:04:05Z"));
expect(isAggregatedBucketExternalId(id)).toBe(true);
expect(parseAggregatedBucketStart(id)?.toISOString()).toBe(
"2026-01-02T03:00:00.000Z",
);
});
});
describe("isAggregatedBucketExternalId", () => {
it.each([
"2026-07-18T14:00:00.000Z",
"2026-07-18T14:10:00.000Z",
"2026-07-18T14:20:00.000Z",
"2026-07-18T14:30:00.000Z",
"2026-07-18T14:40:00.000Z",
"2026-07-18T14:50:00.000Z",
])("accepts a well-formed 10-min bucket ending %s", (suffix) => {
expect(
isAggregatedBucketExternalId(
`stats:HKQuantityTypeIdentifierHeartRate:${suffix}`,
),
).toBe(true);
});
it.each([
// off-grid minute (not a multiple of 10)
"stats:HKQuantityTypeIdentifierHeartRate:2026-07-18T14:05:00.000Z",
"stats:HKQuantityTypeIdentifierHeartRate:2026-07-18T14:35:00.000Z",
// missing millis
"stats:HKQuantityTypeIdentifierHeartRate:2026-07-18T14:30:00Z",
// date-only (the per-day cumulative shape)
"stats:HKQuantityTypeIdentifierHeartRate:2026-07-18",
// the retention fold's local-hour shape (no Z) — a folded row, not a bucket
"stats:HKQuantityTypeIdentifierHeartRate:2026-07-18T14",
// no trailing Z
"stats:HKQuantityTypeIdentifierHeartRate:2026-07-18T14:30:00.000",
// garbage suffix
"stats:HKQuantityTypeIdentifierHeartRate:not-a-date",
// matches the shape but fails Date parse (impossible month)
"stats:HKQuantityTypeIdentifierHeartRate:2026-13-01T14:30:00.000Z",
])("rejects malformed suffix %s", (id) => {
expect(isAggregatedBucketExternalId(id)).toBe(false);
});
it.each([
// a non-allowlisted HK identifier's per-day stats row
"stats:HKQuantityTypeIdentifierStepCount:2026-07-18",
// a per-sample uuid externalId
"B3A1C0DE-0000-4000-8000-000000000000",
null,
undefined,
])("returns false for a non-bucket externalId %s", (id) => {
expect(isAggregatedBucketExternalId(id as string | null)).toBe(false);
});
});
describe("targetsAggregatedBucket", () => {
it("is true for any allowlisted-HK bucket prefix, valid or malformed", () => {
expect(
targetsAggregatedBucket(
"stats:HKQuantityTypeIdentifierHeartRate:2026-07-18T14:30:00.000Z",
),
).toBe(true);
expect(
targetsAggregatedBucket(
"stats:HKQuantityTypeIdentifierHeartRate:not-a-date",
),
).toBe(true);
});
it("is false for a non-allowlisted stats row, per-sample uuids, and null", () => {
expect(
targetsAggregatedBucket(
"stats:HKQuantityTypeIdentifierStepCount:2026-07-18",
),
).toBe(false);
expect(targetsAggregatedBucket("some-uuid")).toBe(false);
expect(targetsAggregatedBucket(null)).toBe(false);
});
});
describe("parseAggregatedBucketStart", () => {
it("returns the canonical UTC bucket-start instant of a well-formed id", () => {
expect(
parseAggregatedBucketStart(
"stats:HKQuantityTypeIdentifierHeartRate:2026-07-18T14:30:00.000Z",
)?.toISOString(),
).toBe("2026-07-18T14:30:00.000Z");
});
it.each([
"stats:HKQuantityTypeIdentifierHeartRate:2026-07-18T14:05:00.000Z",
"stats:HKQuantityTypeIdentifierHeartRate:2026-07-18T14",
"stats:HKQuantityTypeIdentifierStepCount:2026-07-18",
"some-uuid",
null,
])("returns null for a non-bucket / malformed id %s", (id) => {
expect(parseAggregatedBucketStart(id as string | null)).toBeNull();
});
});
@@ -1,99 +0,0 @@
/**
* v1.19.0 (iOS #34) — hourly heart-rate wire-contract helpers.
*
* Covers the externalId minting + well-formedness guard that the batch
* ingest route uses to route the go-forward hourly-average PULSE bucket
* through the existing `stats:` overwrite path.
*/
import { describe, expect, it } from "vitest";
import {
hourlyHeartRateStatsExternalId,
isHourlyHeartRateStatsExternalId,
targetsHourlyHeartRateBucket,
} from "@/lib/measurements/apple-health-mapping";
describe("hourlyHeartRateStatsExternalId", () => {
it("floors to the UTC hour and mints the canonical stats externalId", () => {
const id = hourlyHeartRateStatsExternalId(
new Date("2026-06-21T14:37:42.913Z"),
);
expect(id).toBe(
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:00:00.000Z",
);
});
it("is stable: two instants in the same UTC hour map to one id", () => {
const a = hourlyHeartRateStatsExternalId(new Date("2026-06-21T14:00:00Z"));
const b = hourlyHeartRateStatsExternalId(new Date("2026-06-21T14:59:59Z"));
expect(a).toBe(b);
});
it("round-trips through the well-formedness guard", () => {
const id = hourlyHeartRateStatsExternalId(new Date("2026-01-02T03:04:05Z"));
expect(isHourlyHeartRateStatsExternalId(id)).toBe(true);
});
});
describe("isHourlyHeartRateStatsExternalId", () => {
it("accepts a zeroed ISO-hour HR bucket", () => {
expect(
isHourlyHeartRateStatsExternalId(
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:00:00.000Z",
),
).toBe(true);
});
it.each([
// non-zero minutes
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:30:00.000Z",
// missing millis
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:00:00Z",
// date-only (the per-day cumulative shape, not hourly)
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21",
// no trailing Z
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:00:00.000",
// garbage suffix
"stats:HKQuantityTypeIdentifierHeartRate:not-a-date",
// impossible month still matches the regex shape but fails Date parse
"stats:HKQuantityTypeIdentifierHeartRate:2026-13-01T14:00:00.000Z",
])("rejects malformed suffix %s", (id) => {
expect(isHourlyHeartRateStatsExternalId(id)).toBe(false);
});
it.each([
// a different HK identifier's per-day stats row
"stats:HKQuantityTypeIdentifierStepCount:2026-06-21",
// a per-sample uuid externalId
"B3A1C0DE-0000-4000-8000-000000000000",
null,
undefined,
])("returns false for non-HR-bucket externalId %s", (id) => {
expect(isHourlyHeartRateStatsExternalId(id as string | null)).toBe(false);
});
});
describe("targetsHourlyHeartRateBucket", () => {
it("is true for any HR-bucket prefix, valid or malformed", () => {
expect(
targetsHourlyHeartRateBucket(
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:00:00.000Z",
),
).toBe(true);
expect(
targetsHourlyHeartRateBucket(
"stats:HKQuantityTypeIdentifierHeartRate:not-a-date",
),
).toBe(true);
});
it("is false for per-day cumulative stats and per-sample uuids", () => {
expect(
targetsHourlyHeartRateBucket(
"stats:HKQuantityTypeIdentifierStepCount:2026-06-21",
),
).toBe(false);
expect(targetsHourlyHeartRateBucket("some-uuid")).toBe(false);
expect(targetsHourlyHeartRateBucket(null)).toBe(false);
});
});
+107 -56
View File
@@ -808,87 +808,138 @@ export function dailyStatsExternalId(
}
/**
* v1.19.0 (iOS #34) — go-forward aggregated heart-rate wire contract.
* v1.30.7 (iOS #34) — go-forward aggregated heart-rate wire contract.
* Supersedes the v1.19.0 HOURLY shape (which no client ever adopted) —
* see `.planning/research/2026-07-18-hr-aggregation-contract.md`.
*
* iOS uploads ONE PULSE row per hourly heart-rate bucket instead of one
* row per raw HealthKit sample (~16k rows/user/day → ~24). The bucket's
* `value` is the hourly AVERAGE bpm; the row reuses the existing
* `stats:` overwrite path so a re-post within the same hour (the running
* average shifts as more samples land) OVERWRITES rather than
* iOS uploads ONE PULSE row per 10-MINUTE heart-rate bucket instead of
* one row per raw HealthKit sample (~500–16k rows/user/day → ~144). The
* bucket's `value` is the 10-min AVERAGE bpm (`discreteAverage`), with
* `valueMin`/`valueMax` the bucket spread (`discreteMin`/`discreteMax`);
* the row reuses the existing `stats:` overwrite path so a re-post while
* the bucket fills (the running mean/min/max shift) OVERWRITES rather than
* duplicating — identical mechanics to the per-day `stats:<HK>:<day>`
* cumulative rows, but at hourly granularity for the spot HR metric.
* cumulative rows, but at 10-min granularity for the spot HR metric.
*
* 10 minutes is chosen because it maps 1:1 onto the intraday-pulse tier
* (`BUCKET_MINUTES = 10`): one uploaded bucket = one plotted point + its
* min/max envelope, and `discreteAverage` over the bucket's samples is
* exactly the 10-min mean the raw path would compute — so tension
* detection and the curve are preserved, while the hourly shape would
* have collapsed the curve to ~24 points/day.
*
* Format: `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start>` where
* `<bucket-start>` is the ISO-8601 instant of the hour boundary in UTC
* with a literal `Z` and ZEROED minutes/seconds/millis — e.g.
* `stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:00:00.000Z`.
* `<bucket-start>` is the ISO-8601 instant of the 10-min boundary in UTC
* with a literal `Z`, minutes ∈ {00,10,20,30,40,50}, seconds/millis zero
* — e.g. `stats:HKQuantityTypeIdentifierHeartRate:2026-07-18T14:30:00.000Z`.
*
* The hour boundary is in UTC (not the user's local hour) so the
* externalId is timezone-stable: a user travelling across zones never
* re-keys the same wall-clock hour to a new bucket. The `measuredAt`
* the client sends (`endDate`) still carries the offset for display.
* The boundary is in UTC (not the user's local slot) so the externalId is
* timezone-stable: a user travelling across zones never re-keys the same
* wall-clock window to a new bucket. The `measuredAt` the client sends
* (`endDate`) still carries the offset for display.
*
* AVG-ONLY in v1.19.0. The `Measurement` model carries a single `value`
* column; hourly min/max would need a schema migration (companion
* columns or companion `stats:` rows), which is a fast-follow — see the
* iOS-coord note. Until then iOS uploads the hourly mean as PULSE; the
* raw min/max is recoverable from the device's HealthKit store and the
* server-side nightly fold of any residual raw backlog.
* The guard is written identifier-GENERIC over `AGGREGATED_BUCKET_HK_IDENTIFIERS`
* (HR-only today). respiratoryRate / SpO2 stay per-sample for now but can
* join the same 10-min family later by widening the allowlist — same
* grammar, same overwrite semantics, no renegotiation.
*/
const HOURLY_HR_HK_IDENTIFIER = "HKQuantityTypeIdentifierHeartRate";
const AGGREGATED_BUCKET_HK_IDENTIFIERS = new Set<string>([
"HKQuantityTypeIdentifierHeartRate",
]);
export function hourlyHeartRateStatsExternalId(bucketStart: Date): string {
const hourFloor = new Date(bucketStart);
hourFloor.setUTCMinutes(0, 0, 0);
return `stats:${HOURLY_HR_HK_IDENTIFIER}:${hourFloor.toISOString()}`;
/** Floor a Date down to its 10-minute UTC bucket start. */
function floorToTenMinuteUtc(at: Date): Date {
const floored = new Date(at);
floored.setUTCMinutes(Math.floor(floored.getUTCMinutes() / 10) * 10, 0, 0);
return floored;
}
export function heartRateBucketExternalId(bucketStart: Date): string {
const floor = floorToTenMinuteUtc(bucketStart);
return `stats:HKQuantityTypeIdentifierHeartRate:${floor.toISOString()}`;
}
/**
* The exact ISO-hour suffix shape the hourly HR bucket externalId must
* carry: full ISO-8601 UTC instant, minutes/seconds/millis all zero,
* trailing `Z`. Anchored so a partial / malformed suffix is rejected.
* The exact ISO 10-minute suffix shape an aggregated bucket externalId must
* carry: full ISO-8601 UTC instant, minutes ∈ {00,10,20,30,40,50},
* seconds/millis zero, trailing `Z`. Anchored so a partial / malformed
* suffix is rejected. Distinct from the retention fold's OWN hourly shape
* (`stats:<HK>:<YYYY-MM-DD>T<HH>`, local hour, no `Z`) — the two suffix
* grammars are disjoint, which lets the intraday reader tell an uploaded
* 10-min bucket from a server-folded hourly mean by shape alone.
*/
const HOURLY_HR_SUFFIX_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:00:00\.000Z$/;
const AGGREGATED_BUCKET_SUFFIX_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:[0-5]0:00\.000Z$/;
/** The `stats:<HK>:` prefix for each allowlisted aggregated-bucket identifier. */
function aggregatedBucketPrefix(hkIdentifier: string): string {
return `stats:${hkIdentifier}:`;
}
/**
* v1.19.0 (iOS #34) — well-formedness guard for the hourly HR bucket
* externalId. Returns `true` ONLY for a `stats:` externalId that targets
* the heart-rate identifier AND carries a zeroed ISO-hour suffix that
* parses to a real instant. Used by the batch route to reject a
* malformed hourly-HR bucket (`skipped:"malformed_hr_bucket_id"`) rather
* than persisting an un-overwriteable, un-foldable garbage row.
* v1.30.7 (iOS #34) — well-formedness guard for an aggregated 10-min bucket
* externalId. Returns `true` ONLY for a `stats:` externalId that targets an
* allowlisted identifier AND carries a 10-min ISO-Z suffix that parses to a
* real instant. Used by the batch route to reject a malformed bucket
* (`skipped:"malformed_hr_bucket_id"`) rather than persisting an
* un-overwriteable, un-foldable garbage row.
*
* Non-HR `stats:` externalIds (the per-day cumulative rows) are NOT this
* function's concern — it returns `false` for them so the caller leaves
* them on the untouched path.
* Non-allowlisted `stats:` externalIds (the per-day cumulative rows) are NOT
* this function's concern — it returns `false` so the caller leaves them on
* the untouched path.
*/
export function isHourlyHeartRateStatsExternalId(
export function isAggregatedBucketExternalId(
externalId: string | null | undefined,
): boolean {
if (typeof externalId !== "string") return false;
const prefix = `stats:${HOURLY_HR_HK_IDENTIFIER}:`;
if (!externalId.startsWith(prefix)) return false;
const suffix = externalId.slice(prefix.length);
if (!HOURLY_HR_SUFFIX_RE.test(suffix)) return false;
const parsed = new Date(suffix);
return !Number.isNaN(parsed.getTime());
for (const hkIdentifier of AGGREGATED_BUCKET_HK_IDENTIFIERS) {
const prefix = aggregatedBucketPrefix(hkIdentifier);
if (!externalId.startsWith(prefix)) continue;
const suffix = externalId.slice(prefix.length);
if (!AGGREGATED_BUCKET_SUFFIX_RE.test(suffix)) return false;
const parsed = new Date(suffix);
return !Number.isNaN(parsed.getTime());
}
return false;
}
/**
* v1.19.0 (iOS #34) — does this externalId CLAIM to be an hourly HR
* bucket (right prefix + HR identifier) regardless of suffix validity?
* The route uses this to decide a malformed-vs-valid split: a row that
* targets the HR bucket prefix but fails `isHourlyHeartRateStatsExternalId`
* is a malformed bucket (reject), not an unrelated `stats:` row (pass
* through untouched).
* v1.30.7 (iOS #34) — does this externalId CLAIM to be an aggregated bucket
* (right `stats:<allowlisted-HK>:` prefix) regardless of suffix validity?
* The route uses this for a malformed-vs-valid split: a row that targets a
* bucket prefix but fails `isAggregatedBucketExternalId` is a malformed
* bucket (reject), not an unrelated `stats:` row (pass through untouched).
*/
export function targetsHourlyHeartRateBucket(
export function targetsAggregatedBucket(
externalId: string | null | undefined,
): boolean {
return (
typeof externalId === "string" &&
externalId.startsWith(`stats:${HOURLY_HR_HK_IDENTIFIER}:`)
);
if (typeof externalId !== "string") return false;
for (const hkIdentifier of AGGREGATED_BUCKET_HK_IDENTIFIERS) {
if (externalId.startsWith(aggregatedBucketPrefix(hkIdentifier)))
return true;
}
return false;
}
/**
* v1.30.7 (iOS #34) — the canonical UTC bucket-start instant of a well-formed
* aggregated 10-min bucket externalId, or `null` when the id is not one. The
* intraday reader uses this to place an uploaded bucket into its local 10-min
* slot from the id's authoritative UTC start (the row's `measuredAt` carries
* the display offset, not the canonical bucket boundary).
*/
export function parseAggregatedBucketStart(
externalId: string | null | undefined,
): Date | null {
if (typeof externalId !== "string") return null;
for (const hkIdentifier of AGGREGATED_BUCKET_HK_IDENTIFIERS) {
const prefix = aggregatedBucketPrefix(hkIdentifier);
if (!externalId.startsWith(prefix)) continue;
const suffix = externalId.slice(prefix.length);
if (!AGGREGATED_BUCKET_SUFFIX_RE.test(suffix)) return null;
const parsed = new Date(suffix);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
return null;
}
/**
+4 -4
View File
@@ -416,8 +416,8 @@ async function runRollupAggregate(input: {
${dateTrunc} AS bucket_start,
COUNT(*) AS count,
AVG(m."value")::double precision AS mean,
MIN(m."value")::double precision AS min_value,
MAX(m."value")::double precision AS max_value,
MIN(COALESCE(m."value_min", m."value"))::double precision AS min_value,
MAX(COALESCE(m."value_max", m."value"))::double precision AS max_value,
SUM(m."value")::double precision AS sum_value,
STDDEV_POP(m."value")::double precision AS sd,
REGR_SLOPE(
@@ -464,8 +464,8 @@ async function runRollupAggregate(input: {
${dateTrunc} AS bucket_start,
COUNT(*) AS count,
AVG(m."value")::double precision AS mean,
MIN(m."value")::double precision AS min_value,
MAX(m."value")::double precision AS max_value,
MIN(COALESCE(m."value_min", m."value"))::double precision AS min_value,
MAX(COALESCE(m."value_max", m."value"))::double precision AS max_value,
SUM(m."value")::double precision AS sum_value,
STDDEV_POP(m."value")::double precision AS sd,
REGR_SLOPE(