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

@@ -0,0 +1,33 @@
-- v1.19.2 (iOS #34 extension) — per-bucket heart-rate spread.
--
-- v1.19.0 shipped the hourly heart-rate bucket
-- (`stats:HKQuantityTypeIdentifierHeartRate:<UTC-hour>`) carrying the
-- hour's AVERAGE bpm as one PULSE row. This adds the hour's MIN / MAX bpm
-- as two companion columns so a client can render the intra-hour range
-- (low/high band around the mean) without iOS re-uploading raw samples.
--
-- Two additive, nullable columns on `measurements`:
--
-- * `value_min` — the bucket's minimum bpm.
-- * `value_max` — the bucket's maximum bpm.
--
-- Set ONLY on the hourly heart-rate bucket rows; NULL for every other row
-- (per-sample readings, per-day cumulative `stats:` totals, manual
-- entries). The series reader hands them through only for the heart-rate
-- kind, and the batch ingest persists / overwrites them alongside `value`.
--
-- Additive; no existing row touched. The columns default to NULL so every
-- legacy row and every non-HR-bucket write stays unchanged. Idempotent
-- guards (`ADD COLUMN IF NOT EXISTS`) so a rerun is safe on prod.
--
-- Reversibility (down):
-- ALTER TABLE "measurements" DROP COLUMN IF EXISTS "value_min";
-- ALTER TABLE "measurements" DROP COLUMN IF EXISTS "value_max";
-- The columns are read-additive — dropping them reverts to the avg-only
-- v1.19.0 contract.
ALTER TABLE "measurements"
ADD COLUMN IF NOT EXISTS "value_min" DOUBLE PRECISION;
ALTER TABLE "measurements"
ADD COLUMN IF NOT EXISTS "value_max" DOUBLE PRECISION;
+10
View File
@@ -1026,6 +1026,16 @@ model Measurement {
userId String @map("user_id")
type MeasurementType
value Float
// v1.19.2 (iOS #34 extension) — per-bucket spread for an aggregated row.
// Set ONLY on the hourly heart-rate bucket
// (`stats:HKQuantityTypeIdentifierHeartRate:<UTC-hour>`), where `value`
// is the hour's AVERAGE bpm and these carry the hour's MIN / MAX bpm so a
// client can render the intra-hour range without re-uploading raw
// samples. NULL for every other row (per-sample readings, per-day
// cumulative `stats:` totals, manual entries) — the series reader hands
// them through only for the heart-rate kind.
valueMin Float? @map("value_min")
valueMax Float? @map("value_max")
unit String // kg, mmHg, bpm, %, minutes (sleep), steps, mg/dL, kcal, m, ms, mL/(kg·min), celsius
source MeasurementSource @default(MANUAL)
measuredAt DateTime @map("measured_at")
@@ -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,
@@ -1082,6 +1082,16 @@ export interface AppleHealthEntryInput {
* existing category-value slot still round-trips.
*/
categoryValue?: number;
/**
* v1.19.2 (iOS #34 extension) — per-bucket spread for an aggregated
* heart-rate row. The hour's MIN / MAX bpm in Apple's native unit
* (pre-conversion), companions to `value` (the hour's AVERAGE). The
* mapper applies the SAME `convertToDbUnit` it applies to `value`.
* Ignored for every non-quantity / non-HR-bucket entry; the caller
* persists them only on the hourly HR bucket row.
*/
valueMin?: number;
valueMax?: number;
}
/** Output of `mapAppleHealthEntry()`. */
@@ -1090,6 +1100,13 @@ export interface AppleHealthEntryOutput {
value: number;
unit: string;
takenAt: Date;
/**
* v1.19.2 (iOS #34 extension) — converted per-bucket MIN / MAX, set
* only when the input carried them (the hourly HR bucket). NULL/absent
* for every other row.
*/
valueMin?: number;
valueMax?: number;
/** Set only for sleep entries. */
sleepStage?: SleepStage;
/**
@@ -1132,6 +1149,17 @@ export function mapAppleHealthEntry(
takenAt,
};
// v1.19.2 (iOS #34 extension) — carry the per-bucket MIN / MAX through
// the SAME unit conversion as `value` when the caller supplied them (the
// hourly HR bucket). A finite check guards a malformed payload; the
// caller only persists these on the HR-bucket row.
if (typeof input.valueMin === "number" && Number.isFinite(input.valueMin)) {
out.valueMin = mapping.convertToDbUnit(input.valueMin);
}
if (typeof input.valueMax === "number" && Number.isFinite(input.valueMax)) {
out.valueMax = mapping.convertToDbUnit(input.valueMax);
}
if (mapping.sleepStageMap) {
if (input.sleepStage === undefined) return null;
const stage = mapping.sleepStageMap[input.sleepStage];
+29 -1
View File
@@ -30,7 +30,21 @@ const batchEntrySchema = z
.number()
.finite()
.describe(
"Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For an hourly heart-rate bucket (see `externalId`) this is the hour's AVERAGE bpm — v1.19.0 ships avg-only; min/max are a documented fast-follow.",
"Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For an hourly heart-rate bucket (see `externalId`) this is the hour's AVERAGE bpm; the hour's spread rides `valueMin` / `valueMax`.",
),
valueMin: z
.number()
.finite()
.optional()
.describe(
"v1.19.2 (iOS #34 extension) — the hour's MINIMUM bpm for an hourly heart-rate bucket (see `externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:<hour>` row; ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract.",
),
valueMax: z
.number()
.finite()
.optional()
.describe(
"v1.19.2 (iOS #34 extension) — the hour's MAXIMUM bpm for an hourly heart-rate bucket (see `externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:<hour>` row; ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract.",
),
unit: z.string().min(1).max(60),
startDate: z.iso.datetime({ offset: true }),
@@ -407,6 +421,20 @@ const seriesPointSchema = z.object({
.describe(
"Per-stage hours for a `kind=sleep` night (CORE/DEEP/REM/…); null/absent for non-sleep kinds.",
),
valueMin: z
.number()
.nullable()
.optional()
.describe(
"v1.19.2 (iOS #34 extension) — per-point MINIMUM for `kind=pulse`. On an aggregated hourly heart-rate bucket `value` is the hour's average and this is the hour's low; null on a per-sample PULSE row and absent for every other kind.",
),
valueMax: z
.number()
.nullable()
.optional()
.describe(
"v1.19.2 (iOS #34 extension) — per-point MAXIMUM for `kind=pulse`. On an aggregated hourly heart-rate bucket `value` is the hour's average and this is the hour's high; null on a per-sample PULSE row and absent for every other kind.",
),
});
const seriesResponse = z