From 29bad0a2b8ad72d520683a73ed61b312974c0418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 23 Aug 2026 18:51:50 +0200 Subject: [PATCH] fix(apple-health): read the sampling rate as real exports spell it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rate matcher demanded "Hz". Observed exports spell the unit out and lower-case it — "511,422 hertz" — and the German ones separate it from the number with a no-break space. The fixtures had only ever used "512 Hz", so the mismatch never showed. Rate and heart rate now split into a magnitude and a unit word, with the magnitude read through the same decimal helper the waveform uses. "512 Hz", "512 hertz" and "511,562 Hertz" all resolve; a missing unit, the wrong unit, a grouped number and a rate past the bound are all still refused. The unit word is matched loosely and the magnitude strictly. That is the right way round: the unit is presentation and varies by region, while the number is the measurement. --- src/lib/apple-health/ecg-csv.ts | 47 +++++++++++++++---- .../__tests__/apple-health-ecg-import.test.ts | 37 +++++++++++++++ 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/lib/apple-health/ecg-csv.ts b/src/lib/apple-health/ecg-csv.ts index c113ef973..264e3c253 100644 --- a/src/lib/apple-health/ecg-csv.ts +++ b/src/lib/apple-health/ecg-csv.ts @@ -37,23 +37,54 @@ function parseRecordedAt(value: string | undefined): Date { return parsed; } +/** + * Split a measured quantity into its magnitude and its unit word. + * + * The unit is matched loosely on purpose. A real export writes the sampling + * rate as "511,422 hertz" — the word spelled out, lower case, and separated by + * a NO-BREAK space in the German files — where the fixtures had always assumed + * "512 Hz". The magnitude is read through the shared decimal helper, so a + * comma-region watch's "511,562" is the same rate as "511.422". + * + * `\s` covers U+00A0, so the no-break space needs no special case. + */ +function splitQuantity( + raw: string, + unit: RegExp, +): { magnitude: number; ok: boolean } { + const match = /^([+-]?[\d.,]+)\s*([^\s\d]+)$/.exec(raw.trim()); + if (!match) return { magnitude: Number.NaN, ok: false }; + const magnitude = parseDecimal(match[1]); + if (magnitude === null) return { magnitude: Number.NaN, ok: false }; + return { magnitude, ok: unit.test(match[2]) }; +} + +/** "Hz", "hz", "hertz", "Hertz" — the same unit, spelled as the region does. */ +const HERTZ = /^(?:hz|hertz)$/i; + +/** "bpm" and the spelled-out forms a localised export may carry. */ +const BEATS_PER_MINUTE = /^(?:bpm|spm)$/i; + function parseRate(value: string | undefined): number { - const match = /^(\d+(?:\.\d+)?)\s*Hz$/i.exec(value?.trim() ?? ""); - const rate = match ? Number(match[1]) : Number.NaN; - if (!Number.isFinite(rate) || rate <= 0 || rate > 10_000) { + const { magnitude, ok } = splitQuantity(value ?? "", HERTZ); + if ( + !ok || + !Number.isFinite(magnitude) || + magnitude <= 0 || + magnitude > 10_000 + ) { throw parserError("sample rate is invalid"); } - return Math.round(rate); + return Math.round(magnitude); } function parseHeartRate(value: string | undefined): number | null { if (value === undefined || value.trim() === "") return null; - const match = /^(\d+(?:\.\d+)?)\s*bpm$/i.exec(value.trim()); - const rate = match ? Number(match[1]) : Number.NaN; - if (!Number.isFinite(rate) || rate <= 0 || rate > 300) { + const { magnitude, ok } = splitQuantity(value, BEATS_PER_MINUTE); + if (!ok || !Number.isFinite(magnitude) || magnitude <= 0 || magnitude > 300) { throw parserError("average heart rate is invalid"); } - return Math.round(rate); + return Math.round(magnitude); } function mapClassification( diff --git a/src/lib/measurements/__tests__/apple-health-ecg-import.test.ts b/src/lib/measurements/__tests__/apple-health-ecg-import.test.ts index e5768a0ce..ae014daa7 100644 --- a/src/lib/measurements/__tests__/apple-health-ecg-import.test.ts +++ b/src/lib/measurements/__tests__/apple-health-ecg-import.test.ts @@ -513,6 +513,43 @@ describe("Apple Health ECG CSV — real-export shapes", () => { ).rejects.toThrow(/sample value is invalid/i); }); + it.each([ + ["512 Hz", 512], + ["512 hz", 512], + ["512 hertz", 512], + ["511,422 hertz", 511], + ["511.422 hertz", 511], + ["511,562\u00a0Hertz", 512], + ["512Hz", 512], + ])("reads the sample rate written as %s", async (raw, expected) => { + // Observed exports spell the unit out, lower-case it, and (in the German + // files) separate it with a no-break space. The fixtures had only ever + // used "512 Hz". + await expect( + parse(validCsv({ "Sample Rate": raw })), + ).resolves.toMatchObject({ samplingFrequency: expected }); + }); + + it.each([ + ["a missing unit", "512"], + ["the wrong unit", "512 bpm"], + ["a rate above the bound", "20000 hertz"], + ["a grouped rate", "1.234,5 hertz"], + ])("still rejects a sample rate with %s", async (_label, raw) => { + await expect(parse(validCsv({ "Sample Rate": raw }))).rejects.toThrow( + /sample rate is invalid/i, + ); + }); + + it("reads the average heart rate in both dialects", async () => { + await expect( + parse(validCsv({ "Average Heart Rate": "64 bpm" })), + ).resolves.toMatchObject({ averageHeartRate: 64 }); + await expect( + parse(validCsv({ "Average Heart Rate": "63,7 bpm" })), + ).resolves.toMatchObject({ averageHeartRate: 64 }); + }); + it("still rejects a paired row that follows the single-column waveform", async () => { await expect(parse(`${singleColumnCsv()}\nI,0.001`)).rejects.toThrow( /sample value is invalid/i,