mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
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];
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user