diff --git a/prisma/migrations/0188_v1192_hr_bucket_min_max/migration.sql b/prisma/migrations/0188_v1192_hr_bucket_min_max/migration.sql new file mode 100644 index 000000000..e143e9af6 --- /dev/null +++ b/prisma/migrations/0188_v1192_hr_bucket_min_max/migration.sql @@ -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:`) 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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e1ffce14c..54d7485f1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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:`), 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") diff --git a/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts b/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts index 09f6ceaad..839e85d2f 100644 --- a/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts +++ b/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts @@ -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([ diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index d56a1d236..bb9e9b1d6 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -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:` 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 { 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 { 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 { 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 { }, 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 diff --git a/src/app/api/measurements/series/__tests__/route.test.ts b/src/app/api/measurements/series/__tests__/route.test.ts index 7e8c4d0d4..e4070e049 100644 --- a/src/app/api/measurements/series/__tests__/route.test.ts +++ b/src/app/api/measurements/series/__tests__/route.test.ts @@ -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 }).select.valueMin, + ).toBe(true); + expect( + (selectArg as { select: Record }).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 diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 505bbc6a6..23f16bc75 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -124,6 +124,15 @@ interface SeriesPoint { * the night's TIME-ASLEEP total. */ sleepStages?: Partial> | 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, diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index 866ecc5c6..d6be7265a 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -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]; diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 9af53ec26..0c2a70172 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -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:` 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:` 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