feat(measurements): add per-bucket heart-rate min/max to the hourly contract

The v1.19.0 hourly heart-rate bucket
(`stats:HKQuantityTypeIdentifierHeartRate:<UTC-hour>`) shipped avg-only:
one PULSE row carrying the hour's mean bpm. Add the hour's min/max so a
client can render the intra-hour range without re-uploading raw samples.

- Two nullable columns on `measurements` (`value_min` / `value_max`),
  migration 0188. Set only on a well-formed hourly HR bucket row; null for
  per-sample readings, per-day cumulative totals, and manual entries.
- Batch ingest threads the spread through the same unit conversion as the
  average and persists / overwrites it alongside `value` on the bucket
  row, pinning it null everywhere else.
- The series reader returns the spread for `kind=pulse` (null on a
  per-sample PULSE row); the OpenAPI Zod contract documents both fields on
  the batch entry and the series point. The avg path is unchanged.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 21:06:09 +02:00
1 parent 02ce177ac4
commit 868dfb72bd
8 files changed
+286 -3

No files matched your search

@@ -85,7 +85,11 @@ function makeRequest(body: unknown): NextRequest {
});
}
function hrBucketEntry(externalId: string, value: number) {
function hrBucketEntry(
externalId: string,
value: number,
spread?: { valueMin: number; valueMax: number },
) {
return {
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
value,
@@ -93,6 +97,7 @@ function hrBucketEntry(externalId: string, value: number) {
startDate: "2026-06-21T14:00:00.000Z",
endDate: "2026-06-21T14:59:59.000Z",
externalId,
...(spread ?? {}),
};
}
@@ -146,6 +151,69 @@ 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 () => {
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
const res = await POST(
makeRequest({
entries: [
hrBucketEntry(HR_BUCKET_ID, 72, { valueMin: 58, valueMax: 96 }),
],
}),
);
expect(res.status).toBe(200);
const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0];
const rows = (
createArg as {
data: { value: number; valueMin: number; valueMax: number }[];
}
).data;
expect(rows[0].value).toBe(72);
expect(rows[0].valueMin).toBe(58);
expect(rows[0].valueMax).toBe(96);
});
it("overwrites min/max alongside the average on a re-post", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "PULSE", source: "APPLE_HEALTH", externalId: HR_BUCKET_ID },
] as never);
const res = await POST(
makeRequest({
entries: [
hrBucketEntry(HR_BUCKET_ID, 78, { valueMin: 55, valueMax: 110 }),
],
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
const data = (
updateArg as {
data: { value: number; valueMin: number; valueMax: number };
}
).data;
expect(data.value).toBe(78);
expect(data.valueMin).toBe(55);
expect(data.valueMax).toBe(110);
});
it("leaves min/max null on a per-sample PULSE row even if sent", async () => {
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
const SAMPLE_ID = "C0FFEE00-0000-4000-8000-000000000000";
const res = await POST(
makeRequest({
entries: [hrBucketEntry(SAMPLE_ID, 64, { valueMin: 50, valueMax: 80 })],
}),
);
expect(res.status).toBe(200);
const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0];
const rows = (
createArg as {
data: { valueMin: number | null; valueMax: number | null }[];
}
).data;
expect(rows[0].valueMin).toBeNull();
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.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
+34
View File
@@ -124,6 +124,15 @@ const batchEntrySchema = z.object({
// device's own verdict / severity; resolved to a `rhythmClassification`
// by `mapAppleHealthEntry`. Ignored for every non-event identifier.
categoryValue: z.number().int().min(0).max(20).optional(),
// v1.19.2 (iOS #34 extension) — per-bucket spread for the hourly
// heart-rate bucket. `value` carries the hour's AVERAGE bpm; these carry
// the hour's MIN / MAX so a client renders the intra-hour band without
// re-uploading raw samples. Persisted ONLY on a well-formed
// `stats:HKQuantityTypeIdentifierHeartRate:<hour>` row; ignored (stored
// NULL) for every other entry. Optional + backward-compatible: a pre-W192
// iOS build omits them and the bucket keeps the avg-only contract.
valueMin: z.number().finite().optional(),
valueMax: z.number().finite().optional(),
externalId: z.string().min(1).max(120),
externalSourceVersion: z.string().min(1).max(120).optional(),
// v1.8.6 W6 — optional per-entry source tag. Defaults to
@@ -255,6 +264,8 @@ async function postBatch(request: NextRequest): Promise<Response> {
endDate: entry.endDate,
sleepStage: entry.sleepStage,
categoryValue: entry.categoryValue,
valueMin: entry.valueMin,
valueMax: entry.valueMax,
});
if (!mapped) {
@@ -301,6 +312,21 @@ 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
// 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 valueMin =
isHrBucket && typeof mapped.valueMin === "number"
? mapped.valueMin
: null;
const valueMax =
isHrBucket && typeof mapped.valueMax === "number"
? mapped.valueMax
: null;
prepared.push({
index,
entry,
@@ -308,6 +334,8 @@ async function postBatch(request: NextRequest): Promise<Response> {
userId: user.id,
type: mapped.type,
value: mapped.value,
valueMin,
valueMax,
unit: mapped.unit,
// v1.8.6 W6 — honour the per-entry source tag, defaulting to
// `APPLE_HEALTH` when absent so legacy callers are unchanged.
@@ -505,6 +533,12 @@ async function postBatch(request: NextRequest): Promise<Response> {
},
data: {
value: p.row.value as number,
// v1.19.2 (iOS #34 extension) — overwrite the per-bucket
// spread alongside `value` so a within-hour re-post (the
// running mean + range shift as more samples land) replaces
// both. Null for every non-HR-bucket `stats:` overwrite.
valueMin: p.row.valueMin as number | null,
valueMax: p.row.valueMax as number | null,
unit: p.row.unit as string,
measuredAt: p.row.measuredAt as Date,
externalSourceVersion: p.row.externalSourceVersion as
@@ -134,6 +134,51 @@ describe("GET /api/measurements/series", () => {
expect(body.data.points[0].secondary).toBeNull();
});
it("surfaces per-point min/max for kind=pulse (iOS #34 ext)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
// hourly HR bucket — average + spread
{
id: "p1",
value: 72,
measuredAt: new Date("2026-06-21T14:00:00.000Z"),
valueMin: 58,
valueMax: 96,
},
// per-sample PULSE row — no spread
{
id: "p2",
value: 64,
measuredAt: new Date("2026-06-21T15:00:00.000Z"),
valueMin: null,
valueMax: null,
},
] as never);
const res = await GET(req("kind=pulse&days=7"));
const body = (await res.json()) as {
data: {
points: Array<{
value: number;
valueMin: number | null;
valueMax: number | null;
}>;
};
};
expect(body.data.points[0].valueMin).toBe(58);
expect(body.data.points[0].valueMax).toBe(96);
expect(body.data.points[1].valueMin).toBeNull();
expect(body.data.points[1].valueMax).toBeNull();
// The pulse read selects the spread columns.
const selectArg = vi.mocked(prisma.measurement.findMany).mock.calls[0][0];
expect(
(selectArg as { select: Record<string, boolean> }).select.valueMin,
).toBe(true);
expect(
(selectArg as { select: Record<string, boolean> }).select.valueMax,
).toBe(true);
});
it("collapses sleep stage rows into one night point in hours (v1.11.4)", async () => {
// SLEEP_DURATION is stored one row per STAGE per night (minutes). The
// series must return ONE point per night carrying the TIME-ASLEEP
+38 -1
View File
@@ -124,6 +124,15 @@ interface SeriesPoint {
* the night's TIME-ASLEEP total.
*/
sleepStages?: Partial<Record<SleepStage, number>> | null;
/**
* v1.19.2 (iOS #34 extension) — per-point spread for `kind=pulse`. On an
* aggregated hourly heart-rate bucket `value` is the hour's AVERAGE bpm
* and these carry the hour's MIN / MAX. `null` for a per-sample PULSE row
* (no bucket spread) and absent for every non-pulse kind, so a client can
* render a low/high band only where the data supports it.
*/
valueMin?: number | null;
valueMax?: number | null;
}
function stdDev(values: number[]): number {
@@ -317,6 +326,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
} else {
const type = KIND_TO_TYPE[kind];
// v1.19.2 (iOS #34 extension) — pull the per-bucket spread for the
// heart-rate kind so the chart can render a low/high band around the
// hourly average. Only PULSE rows ever carry a non-null spread (the
// hourly HR bucket); every other kind selects the bare value shape.
const includeSpread = kind === "pulse";
const rows = await prisma.measurement.findMany({
where: {
userId: user.id,
@@ -325,7 +339,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { id: true, value: true, measuredAt: true },
select: {
id: true,
value: true,
measuredAt: true,
...(includeSpread ? { valueMin: true, valueMax: true } : {}),
},
});
if (kind === "glucose") {
// v1.16.16 — glucose is stored canonical mg/dL; convert each point to
@@ -379,6 +398,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
count: rawSummary.count,
};
}
} else if (includeSpread) {
// v1.19.2 (iOS #34 extension) — pulse points carry the per-bucket
// spread. A per-sample PULSE row has null min/max; an hourly HR bucket
// carries the hour's range around the average.
points = rows.map((r) => {
const row = r as typeof r & {
valueMin: number | null;
valueMax: number | null;
};
return {
id: row.id,
at: row.measuredAt.toISOString(),
value: row.value,
secondary: null,
valueMin: row.valueMin ?? null,
valueMax: row.valueMax ?? null,
};
});
} else {
points = rows.map((r) => ({
id: r.id,