Merge pull request #1075 from MBombeck/fix/v1397-ecg-ingest

Accept the fractional heart rate and frequency HealthKit reports for ECGs
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-01 13:21:31 +02:00
commit 9cab8e74da
4 files changed
+55 -12

No files matched your search

+7 -5
View File
@@ -26425,11 +26425,12 @@ components:
description: "When the strip was recorded on-device, with offset. Part of the recording's identity — see `status:
duplicate`."
samplingFrequency:
type: integer
type: number
minimum: 1
maximum: 10000
description: Signal sampling rate in Hz (an Apple Watch ECG is 512). Part of the recording's identity, and the divisor
for the server-derived `durationSeconds`.
description: Signal sampling rate in Hz (an Apple Watch ECG is 512). A fractional value is accepted and rounded to the
nearest whole hertz before it is stored. Part of the recording's identity, and the divisor for the
server-derived `durationSeconds`.
samples:
minItems: 1
maxItems: 32768
@@ -26449,9 +26450,10 @@ components:
maxLength: 40
- type: "null"
averageHeartRate:
description: The device's average heart rate (BPM) for the strip.
description: The device's average heart rate (BPM) for the strip. HealthKit reports it with a fraction; it is accepted
as sent and rounded to whole beats per minute before it is stored.
anyOf:
- type: integer
- type: number
minimum: 1
maximum: 300
- type: "null"
+27 -3
View File
@@ -92,13 +92,29 @@ const ecgIngestSchema = z
.object({
externalRecordingId: z.string().min(1).max(120),
recordedAt: z.iso.datetime({ offset: true }),
samplingFrequency: z.number().int().min(1).max(10_000),
// HealthKit hands both of these over as doubles. A watch reports its
// average heart rate with a fraction (64.7) often enough that requiring
// an integer refused most new recordings while the old ones, synced when
// the value happened to be whole, went through (#1060). The columns are
// integers and the person reads whole beats per minute, so the server
// rounds once here rather than every client doing it.
samplingFrequency: z
.number()
.min(1)
.max(10_000)
.transform((v) => Math.round(v)),
samples: z
.array(z.number().int().min(-1_000_000).max(1_000_000))
.min(1)
.max(MAX_SAMPLES),
lead: z.string().min(1).max(40).nullable().optional(),
averageHeartRate: z.number().int().min(1).max(300).nullable().optional(),
averageHeartRate: z
.number()
.min(1)
.max(300)
.transform((v) => Math.round(v))
.nullable()
.optional(),
classification: ecgClassificationEnum.nullable().optional(),
source: ingestSourceEnum,
})
@@ -198,9 +214,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = ecgIngestSchema.safeParse(rawBody);
if (!parsed.success) {
// Which field was refused and why, never the value: without it a refused
// recording is a bare "invalid" in the log and the cause has to be guessed.
annotate({
action: { name: "insights.ecg.ingest" },
meta: { outcome: "invalid" },
meta: {
outcome: "invalid",
invalidFields: parsed.error.issues
.map((issue) => `${issue.path.join(".") || "(body)"}:${issue.code}`)
.slice(0, 8)
.join(","),
},
});
return returnAllZodIssues(parsed.error);
}
+4 -4
View File
@@ -1678,11 +1678,10 @@ export const ecgIngestRequest = z
),
samplingFrequency: z
.number()
.int()
.min(1)
.max(10_000)
.describe(
"Signal sampling rate in Hz (an Apple Watch ECG is 512). Part of the recording's identity, and the divisor for the server-derived `durationSeconds`.",
"Signal sampling rate in Hz (an Apple Watch ECG is 512). A fractional value is accepted and rounded to the nearest whole hertz before it is stored. Part of the recording's identity, and the divisor for the server-derived `durationSeconds`.",
),
samples: z
.array(z.number().int().min(-1_000_000).max(1_000_000))
@@ -1700,12 +1699,13 @@ export const ecgIngestRequest = z
.describe("Recording lead label when the device reports one (e.g. `I`)."),
averageHeartRate: z
.number()
.int()
.min(1)
.max(300)
.nullable()
.optional()
.describe("The device's average heart rate (BPM) for the strip."),
.describe(
"The device's average heart rate (BPM) for the strip. HealthKit reports it with a fraction; it is accepted as sent and rounded to whole beats per minute before it is stored.",
),
classification: ecgClassification
.optional()
.describe(
@@ -188,6 +188,23 @@ describe("POST /api/insights/ecg — round trip through the real routes", () =>
expect(detail.body.classification).toBe("NOT_DETECTED");
});
it("accepts the fractional heart rate and frequency HealthKit reports and rounds them (#1060)", async () => {
// HealthKit hands both over as doubles; a watch's average heart rate is
// often fractional. Requiring integers refused those recordings outright.
const posted = await ingest(
ingestPayload({ averageHeartRate: 64.7, samplingFrequency: 511.96 }),
);
expect(posted.status).toBe(201);
expect(posted.data.status).toBe("inserted");
const list = await readList();
expect(list.recordings[0]).toMatchObject({
averageHeartRate: 65,
samplingFrequency: 512,
});
expect(posted.data.durationSeconds).toBeCloseTo(SAMPLES.length / 512, 10);
});
it("never writes the waveform as plaintext", async () => {
const posted = await ingest(ingestPayload());
const row = await getPrismaClient().ecgRecording.findUniqueOrThrow({