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

@@ -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