From 8bf3c53d7d2643eb3438bb674f38ca4f4f96d6d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 23 Aug 2026 18:54:41 +0200 Subject: [PATCH] fix(apple-health): read an ECG export whose metadata keys are translated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CSV is written in the device's language, and the keys are translated along with the values: a German watch writes `Aufzeichnungsdatum` and `Klassifizierung`, not `Recorded Date` and `Classification`. Indexing on the English keys found none of them, so the parser never located the waveform and rejected the recording outright — a lost import, not a missing field. Two alias maps close it, both taken from real exports rather than from a dictionary: nine metadata keys and two verdicts, consistent across every German file observed. Adding a language is a data addition to those maps and nothing else. The verdict map is an indirection onto the English wording rather than a second verdict-to-enum table, so there stays exactly one place that decides what a verdict means. The other four shipped languages are absent on purpose. A wrong key silently mis-files a value and a wrong clinical verdict is worse than none, so an export this parser cannot place is refused — which an operator can see — rather than read into the wrong column, which they cannot. Two gaps are written down at the map: no observed file carries an average-heart-rate row in any language, and the paired layout's column header has only ever been seen in English. --- src/lib/apple-health/ecg-csv.ts | 68 ++++++++++++++++- .../__tests__/apple-health-ecg-import.test.ts | 76 +++++++++++++++++++ 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/src/lib/apple-health/ecg-csv.ts b/src/lib/apple-health/ecg-csv.ts index 264e3c253..8fbedefcf 100644 --- a/src/lib/apple-health/ecg-csv.ts +++ b/src/lib/apple-health/ecg-csv.ts @@ -9,6 +9,7 @@ import { createInterface } from "node:readline"; import type { Readable } from "node:stream"; import type { RhythmClassification } from "@/generated/prisma/client"; +import { foldForMatch } from "@/lib/i18n/fold-for-match"; export interface NormalizedAppleHealthEcg { recordedAt: Date; @@ -87,9 +88,67 @@ function parseHeartRate(value: string | undefined): number | null { return Math.round(magnitude); } +/* ────────────────────────────────────────────────────────────────────────── + * Localised exports. + * + * The CSV is written in the device's language, and not only in its values — + * the metadata KEYS are translated too. A German watch writes + * `Klassifizierung,Sinusrhythmus` and `Aufzeichnungsdatum,…`, so a parser + * indexing on the English keys found none of them, never crossed into the + * waveform, and rejected the whole recording. + * + * Both maps below are OBSERVED, from real exports: nine keys and two verdicts, + * consistent across every German file seen. The other four shipped languages + * are deliberately ABSENT rather than guessed. A wrong key silently mis-files a + * value and a wrong clinical verdict is worse than no verdict, so a localised + * file this parser cannot place is refused — which is visible — instead of + * being read into the wrong column, which is not. + * + * Adding a language is a data addition to these two maps and nothing else. + * + * Two gaps worth naming. No observed file carries an average-heart-rate row in + * any language, so there is no localised key for it here. And every observed + * localised file uses the single-column layout, so the paired layout's + * `Lead,Voltage` column header has only ever been seen in English; its + * translated form is unknown and is not invented here. + * ────────────────────────────────────────────────────────────────────────── */ + +/** Folded localised metadata key → the canonical English key we index on. */ +const METADATA_KEY_ALIASES: Readonly> = { + // de — observed across real exports + geburtstag: "Date of Birth", + aufzeichnungsdatum: "Recorded Date", + klassifizierung: "Classification", + symptome: "Symptoms", + softwareversion: "Software Version", + gerat: "Device", + messrate: "Sample Rate", + ableitung: "Lead", + einheit: "Unit", +}; + +/** + * Folded localised verdict → its English wording. + * + * Deliberately an indirection onto the English vocabulary rather than a second + * verdict-to-enum table: one place decides what a verdict MEANS, and + * translations only feed it. + */ +const CLASSIFICATION_ALIASES: Readonly> = { + // de — observed across real exports + sinusrhythmus: "Sinus Rhythm", + uneindeutig: "Inconclusive", +}; + +/** Resolve a metadata key to the English one this parser indexes on. */ +function canonicalMetadataKey(raw: string): string { + return METADATA_KEY_ALIASES[foldForMatch(raw)] ?? raw; +} + function mapClassification( - value: string | undefined, + raw: string | undefined, ): RhythmClassification | null { + const value = CLASSIFICATION_ALIASES[foldForMatch(raw ?? "")] ?? raw; switch (value?.trim().toLowerCase()) { case "sinus rhythm": return "NOT_DETECTED"; @@ -261,11 +320,14 @@ export async function parseAppleHealthEcgCsv(input: { // THE CROSSING into the paired waveform: the `Lead,Voltage` column // header. Unlike the single-column boundary this one is explicit in // the file, so there is nothing to infer. - if (key.toLowerCase() === "lead" && value.toLowerCase() === "voltage") { + if ( + canonicalMetadataKey(key) === "Lead" && + value.toLowerCase() === "voltage" + ) { section = "paired"; continue; } - if (key !== "Name") metadata.set(key, value); + if (key !== "Name") metadata.set(canonicalMetadataKey(key), value); continue; } 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 ae014daa7..55f71150a 100644 --- a/src/lib/measurements/__tests__/apple-health-ecg-import.test.ts +++ b/src/lib/measurements/__tests__/apple-health-ecg-import.test.ts @@ -556,4 +556,80 @@ describe("Apple Health ECG CSV — real-export shapes", () => { ); }); }); + + describe("a localised export", () => { + /** + * The header of a real German export, key for key. Every row here was + * observed; nothing is translated by hand. The blank rows mid-header and + * the no-break space before "Hertz" are the file's, not a typo. + */ + function germanCsv( + overrides: Partial> = {}, + ): string { + const metadata: Record = { + Geburtstag: '"27.03.1987"', + Aufzeichnungsdatum: "2020-07-31 22:48:19 +0200", + Klassifizierung: "Sinusrhythmus", + Symptome: "", + Softwareversion: "1.13", + Gerät: '"Watch4,2"', + Messrate: "511,562\u00a0Hertz", + ...overrides, + }; + return [ + "Name", + ...Object.entries(metadata).map(([k, v]) => `${k},${v}`), + "", + "", + "Ableitung,Ableitung I", + "Einheit,µV", + "", + "-180,596", + "-199,83", + "-217,333", + ].join("\n"); + } + + it("reads a German export end to end", async () => { + await expect(parse(germanCsv())).resolves.toMatchObject({ + recordedAt: new Date("2020-07-31T20:48:19.000Z"), + samplingFrequency: 512, + samples: [-181, -200, -217], + lead: "Ableitung I", + rhythmClassification: "NOT_DETECTED", + }); + }); + + it("reads the other observed German verdict", async () => { + await expect( + parse(germanCsv({ Klassifizierung: "Uneindeutig" })), + ).resolves.toMatchObject({ rhythmClassification: "INCONCLUSIVE" }); + }); + + it("returns no verdict for a language that is not mapped yet", async () => { + // French is not in the alias map and is not guessed at. The recording + // still imports; only the verdict is withheld. + await expect( + parse(germanCsv({ Klassifizierung: "Rythme sinusal" })), + ).resolves.toMatchObject({ rhythmClassification: null }); + }); + + it("refuses a file whose keys it cannot place, rather than mis-filing it", async () => { + // An unmapped language's KEYS mean the parser never finds the waveform. + // Refusing is visible; reading values into the wrong column is not. + const french = germanCsv() + .replace("Aufzeichnungsdatum,", "Date d'enregistrement,") + .replace("Messrate,", "Fréquence d'échantillonnage,") + .replace("Ableitung,Ableitung I", "Dérivation,Dérivation I") + .replace("Einheit,µV", "Unité,µV"); + await expect(parse(french)).rejects.toThrow(/samples are missing/i); + }); + + it("leaves the English header working unchanged", async () => { + await expect(parse(validCsv())).resolves.toMatchObject({ + samplingFrequency: 512, + rhythmClassification: "NOT_DETECTED", + }); + }); + }); });