mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +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
@@ -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;
|
||||
@@ -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")
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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:<hour>` 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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
},
|
||||
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
|
||||
|
||||
@@ -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<string, boolean> }).select.valueMin,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(selectArg as { select: Record<string, boolean> }).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
|
||||
|
||||
@@ -124,6 +124,15 @@ interface SeriesPoint {
|
||||
* the night's TIME-ASLEEP total.
|
||||
*/
|
||||
sleepStages?: Partial<Record<SleepStage, number>> | 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,
|
||||
|
||||
@@ -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