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")