fix(apple-health): read an ECG export whose metadata keys are translated

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-23 19:17:02 +02:00
1 parent 29bad0a2b8
commit 8bf3c53d7d
2 files changed
+141 -3

No files matched your search

+65 -3
View File
@@ -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<Record<string, string>> = {
// 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<Record<string, string>> = {
// 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;
}
@@ -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<Record<string, string>> = {},
): string {
const metadata: Record<string, string> = {
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",
});
});
});
});