diff --git a/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts b/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts similarity index 94% rename from src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts rename to src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts index 866bffc84..336f9c64d 100644 --- a/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts +++ b/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts @@ -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, ), ], diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 0a49e997f..33cfe5c94 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -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 { continue; } - // v1.19.0 (iOS #34) — go-forward aggregated heart-rate wire - // contract. A `stats:HKQuantityTypeIdentifierHeartRate:` 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 { 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 diff --git a/src/lib/analytics/__tests__/intraday-pulse-io.test.ts b/src/lib/analytics/__tests__/intraday-pulse-io.test.ts index f2d97fc0e..867c0b48c 100644 --- a/src/lib/analytics/__tests__/intraday-pulse-io.test.ts +++ b/src/lib/analytics/__tests__/intraday-pulse-io.test.ts @@ -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::` 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 }, + ]); + }); }); diff --git a/src/lib/analytics/__tests__/intraday-pulse.test.ts b/src/lib/analytics/__tests__/intraday-pulse.test.ts index bed84682e..7e8775a5d 100644 --- a/src/lib/analytics/__tests__/intraday-pulse.test.ts +++ b/src/lib/analytics/__tests__/intraday-pulse.test.ts @@ -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 }, ]); }); }); diff --git a/src/lib/analytics/intraday-pulse-io.ts b/src/lib/analytics/intraday-pulse-io.ts index 0ec6ce966..8a063a21d 100644 --- a/src/lib/analytics/intraday-pulse-io.ts +++ b/src/lib/analytics/intraday-pulse-io.ts @@ -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::T` 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 diff --git a/src/lib/analytics/intraday-pulse.ts b/src/lib/analytics/intraday-pulse.ts index 3a517aa93..a47da1e7b 100644 --- a/src/lib/analytics/intraday-pulse.ts +++ b/src/lib/analytics/intraday-pulse.ts @@ -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(); + 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(); + 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 diff --git a/src/lib/measurements/__tests__/aggregated-hr-bucket.test.ts b/src/lib/measurements/__tests__/aggregated-hr-bucket.test.ts new file mode 100644 index 000000000..576e9b453 --- /dev/null +++ b/src/lib/measurements/__tests__/aggregated-hr-bucket.test.ts @@ -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(); + }); +}); diff --git a/src/lib/measurements/__tests__/hourly-hr-stats.test.ts b/src/lib/measurements/__tests__/hourly-hr-stats.test.ts deleted file mode 100644 index bf00e18d3..000000000 --- a/src/lib/measurements/__tests__/hourly-hr-stats.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index 704f46367..74d182ae6 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -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::` - * 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:` where - * `` 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`. + * `` 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([ + "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::T`, 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::` 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::` 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; } /** diff --git a/src/lib/rollups/measurement-rollups.ts b/src/lib/rollups/measurement-rollups.ts index f3211c928..d7043366f 100644 --- a/src/lib/rollups/measurement-rollups.ts +++ b/src/lib/rollups/measurement-rollups.ts @@ -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(