From a5e98c9e71864279205de50ac30d5442506f656e Mon Sep 17 00:00:00 2001
From: BernhardBuckel <33808384+BernhardBuckel@users.noreply.github.com>
Date: Sun, 4 Oct 2026 10:53:23 +0200
Subject: [PATCH 1/8] fix(import): leave out Apple Health samples HealthLog
wrote itself
The iOS app writes manual entries into Apple Health, and mirrors Withings and
import rows into it, each stamped with dev.healthlog.app.origin = healthlog.
The server already holds those readings under their own source, so the same
sample arriving through an Apple Health export is a second copy: the series
shows it twice and the rollups count it twice.
The export import now leaves out a record carrying that marker. The marker is a
child , read after the record's open tag, so a record is
committed at its close tag. As a fallback for manual entries mirrored before the
app set the marker, a record is also left out when a MANUAL row of the same
type and value sits within 2 s (same rule and tolerance as the sync path).
Withings and import rows are not matched by value and time: they reach Apple
Health only through the mirror, which always sets the marker.
The import summary says how many records were left out for this reason.
Fixes #1109
---
messages/de.json | 1 +
messages/en.json | 1 +
messages/es.json | 1 +
messages/fr.json | 1 +
messages/it.json | 1 +
messages/ko.json | 1 +
messages/pl.json | 1 +
.../import-panel/apple-health-import-card.tsx | 18 ++
src/lib/apple-health/own-origin.ts | 24 +++
.../import-apple-health-export.ts | 159 +++++++++++----
.../apple-health-import-own-samples.test.ts | 186 ++++++++++++++++++
11 files changed, 353 insertions(+), 41 deletions(-)
create mode 100644 src/lib/apple-health/own-origin.ts
create mode 100644 tests/integration/apple-health-import-own-samples.test.ts
diff --git a/messages/de.json b/messages/de.json
index 0c7173452..251f58dd9 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5373,6 +5373,7 @@
"rowsImported": "Bisher {count} Einträge importiert",
"doneSummary": "Fertig — {imported} von {read} Datensätzen importiert, {skipped} klinische Datensätze übersprungen.",
"doneNothing": "Aus diesem Archiv wurde nichts importiert: 0 von {read} Datensätzen sind angekommen.",
+ "writtenByHealthLog": "{count} Einträge, die HealthLog selbst in Apple Health geschrieben hat, wurden ausgelassen, weil sie schon in deinen Daten sind.",
"ecgResult": "EKGs: {discovered} gefunden, {imported} importiert, {updated} aktualisiert, {skipped} übersprungen, {failed} fehlgeschlagen. Fehler bei EKGs verwerfen keine unterstützten Datensätze, die aus export.xml importiert wurden.",
"estimateWarning": "Kumulative Summen für {count} Tag(e) wurden anhand der größten Exportquelle geschätzt. Native Apple-Health-Statistiken bleiben maßgeblich.",
"phase": {
diff --git a/messages/en.json b/messages/en.json
index 3cd9aa9a4..3196dad45 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5373,6 +5373,7 @@
"rowsImported": "{count} rows imported so far",
"doneSummary": "Done — {imported} of {read} records imported, {skipped} clinical records skipped.",
"doneNothing": "Nothing was imported from that archive: 0 of {read} records landed.",
+ "writtenByHealthLog": "{count} records that HealthLog wrote into Apple Health itself were left out, because they are already in your data.",
"ecgResult": "ECGs: {discovered} discovered, {imported} imported, {updated} updated, {skipped} skipped, {failed} failed. ECG failures do not discard supported records imported from export.xml.",
"estimateWarning": "Cumulative totals for {count} day(s) were estimated from the largest export source. Native Apple Health statistics remain authoritative.",
"phase": {
diff --git a/messages/es.json b/messages/es.json
index d40131208..2d6d6414c 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5373,6 +5373,7 @@
"rowsImported": "{count} filas importadas hasta ahora",
"doneSummary": "Listo: {imported} de {read} registros importados, {skipped} registros clínicos omitidos.",
"doneNothing": "No se importó nada de ese archivo: llegaron 0 de {read} registros.",
+ "writtenByHealthLog": "Se omitieron {count} registros que HealthLog escribió en Apple Health, porque ya están en tus datos.",
"ecgResult": "ECG: {discovered} detectados, {imported} importados, {updated} actualizados, {skipped} omitidos y {failed} fallidos. Los errores de ECG no descartan los registros compatibles importados desde export.xml.",
"estimateWarning": "Los totales acumulados de {count} día(s) se estimaron a partir de la mayor fuente de la exportación. Las estadísticas nativas de Apple Health siguen siendo la referencia.",
"phase": {
diff --git a/messages/fr.json b/messages/fr.json
index a5d0afa08..8e1bb35f1 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5373,6 +5373,7 @@
"rowsImported": "{count} lignes importées jusqu'ici",
"doneSummary": "Terminé — {imported} enregistrements sur {read} importés, {skipped} enregistrements cliniques ignorés.",
"doneNothing": "Rien n'a été importé de cette archive : 0 enregistrement sur {read} est arrivé.",
+ "writtenByHealthLog": "{count} enregistrements que HealthLog a écrits lui-même dans Santé ont été ignorés, car ils figurent déjà dans vos données.",
"ecgResult": "ECG : {discovered} détectés, {imported} importés, {updated} mis à jour, {skipped} ignorés et {failed} échoués. Les erreurs d’ECG n’annulent pas les enregistrements compatibles importés depuis export.xml.",
"estimateWarning": "Les totaux cumulés de {count} jour(s) ont été estimés à partir de la source d’export la plus élevée. Les statistiques Apple Santé natives restent la référence.",
"phase": {
diff --git a/messages/it.json b/messages/it.json
index e0e489863..bc6b6ab1a 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5373,6 +5373,7 @@
"rowsImported": "{count} righe importate finora",
"doneSummary": "Fatto: {imported} di {read} record importati, {skipped} record clinici saltati.",
"doneNothing": "Da questo archivio non è stato importato nulla: sono arrivati 0 record su {read}.",
+ "writtenByHealthLog": "{count} registrazioni che HealthLog ha scritto in Salute sono state escluse, perché sono già nei tuoi dati.",
"ecgResult": "ECG: {discovered} rilevati, {imported} importati, {updated} aggiornati, {skipped} ignorati e {failed} non riusciti. Gli errori ECG non eliminano i record supportati importati da export.xml.",
"estimateWarning": "I totali cumulativi di {count} giorno/i sono stati stimati dalla fonte di esportazione più alta. Le statistiche native di Apple Salute restano autorevoli.",
"phase": {
diff --git a/messages/ko.json b/messages/ko.json
index e51af7017..b416ab808 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -5373,6 +5373,7 @@
"rowsImported": "지금까지 {count}개 행을 불러왔어요",
"doneSummary": "완료 — {read}개 중 {imported}개 기록을 불러왔고, 진료 기록 {skipped}개는 건너뛰었어요.",
"doneNothing": "그 보관 파일에서 불러온 게 없어요: {read}개 중 0개만 들어왔어요.",
+ "writtenByHealthLog": "HealthLog가 직접 건강 앱에 기록한 {count}개 항목은 이미 데이터에 있으므로 제외했습니다.",
"ecgResult": "심전도: 발견 {discovered}개, 불러옴 {imported}개, 갱신 {updated}개, 건너뜀 {skipped}개, 실패 {failed}개. 심전도가 실패해도 export.xml에서 불러온 지원 기록은 그대로 남아요.",
"estimateWarning": "{count}일치 누적 합계는 가장 큰 내보내기 소스에서 추정했어요. Apple Health 자체 통계가 여전히 기준이에요.",
"phase": {
diff --git a/messages/pl.json b/messages/pl.json
index caa7f9245..b907285fe 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5373,6 +5373,7 @@
"rowsImported": "Zaimportowano dotąd {count} wierszy",
"doneSummary": "Gotowe — zaimportowano {imported} z {read} rekordów, pominięto {skipped} rekordów klinicznych.",
"doneNothing": "Z tego archiwum nic nie zaimportowano: dotarło 0 z {read} rekordów.",
+ "writtenByHealthLog": "Pominięto {count} wpisów, które HealthLog sam zapisał w Apple Health, ponieważ są już w Twoich danych.",
"ecgResult": "EKG: wykryto {discovered}, zaimportowano {imported}, zaktualizowano {updated}, pominięto {skipped}, nie powiodło się {failed}. Błędy EKG nie usuwają obsługiwanych rekordów zaimportowanych z pliku export.xml.",
"estimateWarning": "Łączne wartości dla {count} dni oszacowano na podstawie największego źródła w eksporcie. Natywne statystyki Apple Health pozostają nadrzędne.",
"phase": {
diff --git a/src/components/settings/import-panel/apple-health-import-card.tsx b/src/components/settings/import-panel/apple-health-import-card.tsx
index 344299c81..dbe40953c 100644
--- a/src/components/settings/import-panel/apple-health-import-card.tsx
+++ b/src/components/settings/import-panel/apple-health-import-card.tsx
@@ -31,6 +31,8 @@ interface JobStatus {
totals?: { recordsRead?: number; rowsUpserted?: number };
clinical?: { skipped?: number };
cumulativeEstimates?: { days?: number; rows?: number };
+ /** Records HealthLog wrote into Apple Health itself, left out here. */
+ writtenByHealthLog?: { byMarker?: number; matchedManual?: number };
/** HK types deferred by design — read but not (yet) imported. */
deferred?: Record;
/** Per-key drop counters — plain HK types are unsupported-by-design,
@@ -342,6 +344,9 @@ export function AppleHealthImportCard() {
const status = statusQuery.data ?? null;
const isRunning = status !== null && !TERMINAL_STATES.includes(status.status);
const isDone = status?.status === "done";
+ const ownRecordsSkipped =
+ (status?.result?.writtenByHealthLog?.byMarker ?? 0) +
+ (status?.result?.writtenByHealthLog?.matchedManual ?? 0);
const isFailed = status?.status === "failed";
const busy = uploading || isRunning;
const percent = status?.progress?.percent ?? null;
@@ -492,6 +497,19 @@ export function AppleHealthImportCard() {
})}
)}
+ {isDone && ownRecordsSkipped > 0 && (
+
+ {t(
+ "settings.sections.export.import.appleHealth.writtenByHealthLog",
+ {
+ count: ownRecordsSkipped,
+ },
+ )}
+
+ )}
{isDone && (status?.result?.cumulativeEstimates?.days ?? 0) > 0 && (
` the mark of a sample HealthLog wrote itself? */
+export function isHealthLogOriginEntry(
+ key: string | undefined,
+ value: string | undefined,
+): boolean {
+ return (
+ key === HEALTHLOG_ORIGIN_METADATA_KEY &&
+ value === HEALTHLOG_ORIGIN_METADATA_VALUE
+ );
+}
diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts
index 37b5b0c35..8801e4d49 100644
--- a/src/lib/measurements/import-apple-health-export.ts
+++ b/src/lib/measurements/import-apple-health-export.ts
@@ -40,6 +40,11 @@ import {
dayKeyForUserTz,
canonicalDailyTimestamp,
} from "@/lib/measurements/drain-per-sample-cumulative";
+import { isHealthLogOriginEntry } from "@/lib/apple-health/own-origin";
+import {
+ isSameReadingAcrossSource,
+ MEASURED_AT_TOLERANCE_MS,
+} from "@/lib/measurements/cross-source-merge";
import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement";
import {
insertNewMeasurementRows,
@@ -115,6 +120,13 @@ export interface ImportJobResult {
durationMs: number;
};
clinical: { skipped: number };
+ /**
+ * Records left out because HealthLog wrote them into Apple Health itself and
+ * already holds them under their own source: `byMarker` carry the app's
+ * origin marker, `matchedManual` match a manual entry (same type and value,
+ * within 2 s) from before the marker existed.
+ */
+ writtenByHealthLog: { byMarker: number; matchedManual: number };
/**
* v1.15.0 — reproductive HealthKit samples routed into CYCLE day-logs
* (NOT Measurement). Absent / zeroed when the account has no cycle
@@ -412,6 +424,11 @@ export async function streamParseExportXml(
MeasurementType,
Map
>();
+ // A record's contribution is committed at its close tag, once its child
+ // `` rows are read: one of them can mark the sample as written
+ // by HealthLog itself, which has to keep it out of the import.
+ let pendingRecord: { commit: () => void; ownOrigin: boolean } | null = null;
+ const writtenByHealthLog = { byMarker: 0, matchedManual: 0 };
// Spot-row batch awaiting flush.
const spotBatch: PreparedMeasurement[] = [];
// Workout-row batch awaiting flush.
@@ -468,9 +485,54 @@ export async function streamParseExportXml(
};
// ── Flush helpers ──────────────────────────────────────────
+ // A manual entry the app mirrored into Apple Health before it stamped its
+ // origin marker comes back in the export as a second copy of the reading.
+ // Leave it out when a MANUAL row of the same type and value sits within 2 s.
+ // Only MANUAL: Withings and import rows reach Apple Health through the app's
+ // mirror, which always sets the marker, so a value-and-time match against
+ // them would only add false positives. Same rule and tolerance as the sync
+ // path (`cross-source-merge.ts`).
+ const withoutManualMirrors = async (
+ rows: PreparedMeasurement[],
+ ): Promise => {
+ if (rows.length === 0) return rows;
+ const candidates = await prisma.measurement.findMany({
+ where: {
+ userId,
+ source: "MANUAL",
+ deletedAt: null,
+ OR: rows.map((row) => ({
+ type: row.type,
+ measuredAt: {
+ gte: new Date(row.measuredAt.getTime() - MEASURED_AT_TOLERANCE_MS),
+ lte: new Date(row.measuredAt.getTime() + MEASURED_AT_TOLERANCE_MS),
+ },
+ })),
+ },
+ select: { type: true, source: true, value: true, measuredAt: true },
+ });
+ if (candidates.length === 0) return rows;
+ return rows.filter((row) => {
+ const mirrored = candidates.some((candidate) =>
+ isSameReadingAcrossSource(
+ {
+ type: row.type,
+ source: "APPLE_HEALTH",
+ value: row.value,
+ measuredAt: row.measuredAt,
+ },
+ candidate,
+ ),
+ );
+ if (mirrored) writtenByHealthLog.matchedManual += 1;
+ return !mirrored;
+ });
+ };
+
const flushSpotBatch = async (): Promise => {
if (spotBatch.length === 0) return;
- const chunk = spotBatch.splice(0, spotBatch.length);
+ const incoming = spotBatch.splice(0, spotBatch.length);
+ const chunk = await withoutManualMirrors(incoming);
for (const row of chunk) widenSpan(row.measuredAt);
const insertedArrivals: Array<{
id: string;
@@ -907,46 +969,51 @@ export async function streamParseExportXml(
const stat = bumpStat(mapped.type);
stat.read += 1;
- if (CUMULATIVE_HK_TYPES.has(mapped.type)) {
- const dayKey = dayKeyForUserTz(mapped.takenAt, userTimezone);
- const sourceHash = hashCumulativeSourceIdentity(
- attrs.sourceName,
- attrs.device,
- );
- let byDay = cumulativeBucket.get(mapped.type);
- if (!byDay) {
- byDay = new Map();
- cumulativeBucket.set(mapped.type, byDay);
- }
- let bySource = byDay.get(dayKey);
- if (!bySource) {
- bySource = new Map();
- byDay.set(dayKey, bySource);
- }
- bySource.set(
- sourceHash,
- (bySource.get(sourceHash) ?? 0) + mapped.value,
- );
- } else {
- // Spot row: derive a stable externalId, queue for flush.
- const externalId = hashSampleKey(
- hkType,
- attrs.value ?? "",
- attrs.startDate,
- attrs.endDate,
- );
- spotBatch.push({
- userId,
- type: mapped.type,
- value: mapped.value,
- unit: mapped.unit,
- measuredAt: mapped.takenAt,
- externalId,
- externalSourceVersion: attrs.sourceVersion ?? null,
- sleepStage: mapped.sleepStage ?? null,
- deviceType: null,
- });
- }
+ pendingRecord = {
+ ownOrigin: false,
+ commit: () => {
+ if (CUMULATIVE_HK_TYPES.has(mapped.type)) {
+ const dayKey = dayKeyForUserTz(mapped.takenAt, userTimezone);
+ const sourceHash = hashCumulativeSourceIdentity(
+ attrs.sourceName,
+ attrs.device,
+ );
+ let byDay = cumulativeBucket.get(mapped.type);
+ if (!byDay) {
+ byDay = new Map();
+ cumulativeBucket.set(mapped.type, byDay);
+ }
+ let bySource = byDay.get(dayKey);
+ if (!bySource) {
+ bySource = new Map();
+ byDay.set(dayKey, bySource);
+ }
+ bySource.set(
+ sourceHash,
+ (bySource.get(sourceHash) ?? 0) + mapped.value,
+ );
+ } else {
+ // Spot row: derive a stable externalId, queue for flush.
+ const externalId = hashSampleKey(
+ hkType,
+ attrs.value ?? "",
+ attrs.startDate,
+ attrs.endDate,
+ );
+ spotBatch.push({
+ userId,
+ type: mapped.type,
+ value: mapped.value,
+ unit: mapped.unit,
+ measuredAt: mapped.takenAt,
+ externalId,
+ externalSourceVersion: attrs.sourceVersion ?? null,
+ sleepStage: mapped.sleepStage ?? null,
+ deviceType: null,
+ });
+ }
+ },
+ };
return;
}
@@ -1025,6 +1092,9 @@ export async function streamParseExportXml(
}
if (name === "MetadataEntry") {
+ if (pendingRecord && isHealthLogOriginEntry(attrs.key, attrs.value)) {
+ pendingRecord.ownOrigin = true;
+ }
// Attach the SexualActivity protection flag to the open cycle record.
// Apple writes `HKMetadataKeySexualActivityProtectionUsed` with a
// `"0"`/`"1"` (or `"true"`/`"false"`) value.
@@ -1070,6 +1140,12 @@ export async function streamParseExportXml(
};
parser.onclosetag = async (tagName) => {
+ if (tagName === "Record" && pendingRecord) {
+ const rec = pendingRecord;
+ pendingRecord = null;
+ if (rec.ownOrigin) writtenByHealthLog.byMarker += 1;
+ else rec.commit();
+ }
if (tagName === "Workout" && currentWorkout) {
workoutBatch.push(currentWorkout);
currentWorkout = null;
@@ -1212,6 +1288,7 @@ export async function streamParseExportXml(
perType,
workouts,
clinical,
+ writtenByHealthLog,
cycle,
deferred,
unknown,
diff --git a/tests/integration/apple-health-import-own-samples.test.ts b/tests/integration/apple-health-import-own-samples.test.ts
new file mode 100644
index 000000000..d97d0dda8
--- /dev/null
+++ b/tests/integration/apple-health-import-own-samples.test.ts
@@ -0,0 +1,186 @@
+/**
+ * The Apple Health export must not bring back what HealthLog wrote into Apple
+ * Health itself.
+ *
+ * The iOS app writes manual entries into Apple Health, and mirrors Withings and
+ * import rows into it, each stamped with `dev.healthlog.app.origin = healthlog`.
+ * The server already holds those readings under their own source, so the same
+ * sample arriving through an export is a second copy: the series shows it
+ * twice and the rollups count it twice.
+ *
+ * Asserted against a real Postgres:
+ * - a record carrying the marker is left out, and counted;
+ * - a record without it lands as before;
+ * - the marker is read from a child `` that follows the
+ * record's open tag, so the record is committed at its close tag;
+ * - a manual entry mirrored before the marker existed (a MANUAL row with the
+ * same type and value within 2 s) is left out and counted separately;
+ * - a MANUAL row with another value, or 3 s away, is not a match;
+ * - a Withings row of the same value and time is not a match either (those
+ * are mirrored with the marker, so a match would only add false positives).
+ */
+import { mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { beforeEach, describe, expect, it } from "vitest";
+
+import { getPrismaClient, truncateAllTables } from "./setup";
+import { streamParseExportXml } from "@/lib/measurements/import-apple-health-export";
+
+const prisma = getPrismaClient();
+
+beforeEach(async () => {
+ await truncateAllTables(prisma);
+});
+
+function writeXml(records: string): string {
+ const dir = mkdtempSync(join(tmpdir(), "healthlog-import-own-"));
+ const xmlPath = join(dir, "export.xml");
+ writeFileSync(
+ xmlPath,
+ `
+]>
+
+${records}
+`,
+ );
+ return xmlPath;
+}
+
+const weight = (value: number, end: string, children = "") =>
+ ` ${children}`;
+const OWN = ``;
+const OTHER = ``;
+
+async function run(records: string) {
+ const user = await prisma.user.create({
+ data: { username: "own-samples", email: "own@example.test", role: "USER" },
+ });
+ const result = await streamParseExportXml({
+ xmlPath: writeXml(records),
+ userId: user.id,
+ userTimezone: "Europe/Berlin",
+ prisma,
+ });
+ const rows = await prisma.measurement.findMany({
+ where: { userId: user.id, type: "WEIGHT", source: "APPLE_HEALTH" },
+ orderBy: { measuredAt: "asc" },
+ });
+ return { user, result, rows };
+}
+
+describe("a record HealthLog wrote itself (the origin marker)", () => {
+ it("is left out and counted; an ordinary record still lands", async () => {
+ const { result, rows } = await run(
+ [
+ weight(80.1, "2026-05-14 08:00:00 +0200", `\n ${OWN}\n `),
+ weight(80.2, "2026-05-14 09:00:00 +0200", `\n ${OTHER}\n `),
+ weight(80.3, "2026-05-14 10:00:00 +0200"),
+ ].join("\n"),
+ );
+ expect(rows.map((r) => r.value)).toEqual([80.2, 80.3]);
+ expect(result.writtenByHealthLog).toEqual({
+ byMarker: 1,
+ matchedManual: 0,
+ });
+ });
+
+ it("finds the marker among other metadata entries", async () => {
+ const { result, rows } = await run(
+ weight(80.1, "2026-05-14 08:00:00 +0200", `\n ${OTHER}\n ${OWN}\n`),
+ );
+ expect(rows).toHaveLength(0);
+ expect(result.writtenByHealthLog.byMarker).toBe(1);
+ });
+
+ it("does not let one record's marker spill onto the next", async () => {
+ const { rows } = await run(
+ [
+ weight(80.1, "2026-05-14 08:00:00 +0200", `\n ${OWN}\n`),
+ weight(80.2, "2026-05-14 09:00:00 +0200"),
+ ].join("\n"),
+ );
+ expect(rows.map((r) => r.value)).toEqual([80.2]);
+ });
+
+ it("needs the exact value: another origin does not count", async () => {
+ const { rows } = await run(
+ weight(
+ 80.1,
+ "2026-05-14 08:00:00 +0200",
+ ``,
+ ),
+ );
+ expect(rows).toHaveLength(1);
+ });
+});
+
+describe("a manual entry mirrored before the marker existed", () => {
+ async function runAgainst(
+ manual: { value: number; at: string; source?: "MANUAL" | "WITHINGS" },
+ record: { value: number; at: string },
+ ) {
+ const user = await prisma.user.create({
+ data: { username: "own-fallback", email: "f@example.test", role: "USER" },
+ });
+ await prisma.measurement.create({
+ data: {
+ userId: user.id,
+ type: "WEIGHT",
+ unit: "kg",
+ source: manual.source ?? "MANUAL",
+ value: manual.value,
+ measuredAt: new Date(manual.at),
+ },
+ });
+ const result = await streamParseExportXml({
+ xmlPath: writeXml(weight(record.value, "2026-05-14 08:00:00 +0200")),
+ userId: user.id,
+ userTimezone: "Europe/Berlin",
+ prisma,
+ });
+ const rows = await prisma.measurement.findMany({
+ where: { userId: user.id, type: "WEIGHT", source: "APPLE_HEALTH" },
+ });
+ return { result, rows };
+ }
+
+ it("is left out when a MANUAL row has the same value within 2 s", async () => {
+ const { result, rows } = await runAgainst(
+ { value: 80.4, at: "2026-05-14T06:00:01.500Z" },
+ { value: 80.4, at: "" },
+ );
+ expect(rows).toHaveLength(0);
+ expect(result.writtenByHealthLog).toEqual({
+ byMarker: 0,
+ matchedManual: 1,
+ });
+ });
+
+ it("is kept when the value differs", async () => {
+ const { result, rows } = await runAgainst(
+ { value: 80.9, at: "2026-05-14T06:00:00.000Z" },
+ { value: 80.4, at: "" },
+ );
+ expect(rows).toHaveLength(1);
+ expect(result.writtenByHealthLog.matchedManual).toBe(0);
+ });
+
+ it("is kept when the MANUAL row is 3 s away", async () => {
+ const { rows } = await runAgainst(
+ { value: 80.4, at: "2026-05-14T06:00:03.000Z" },
+ { value: 80.4, at: "" },
+ );
+ expect(rows).toHaveLength(1);
+ });
+
+ it("is kept against a Withings row of the same value and time", async () => {
+ const { result, rows } = await runAgainst(
+ { value: 80.4, at: "2026-05-14T06:00:00.000Z", source: "WITHINGS" },
+ { value: 80.4, at: "" },
+ );
+ expect(rows).toHaveLength(1);
+ expect(result.writtenByHealthLog.matchedManual).toBe(0);
+ });
+});
From 841d4e559cebbb0d3c4ffc920d86e929777451fd Mon Sep 17 00:00:00 2001
From: BernhardBuckel <33808384+BernhardBuckel@users.noreply.github.com>
Date: Sun, 4 Oct 2026 11:10:49 +0200
Subject: [PATCH 2/8] test(import): add a synthetic export.xml fixture for the
origin marker
The fixture is invented, in the shape Apple's DTD documents, and says so in its
header and its file name. It shows the parser reads a file laid out like an
export; it cannot show that Apple writes a custom metadata key verbatim.
---
.../export-own-origin.synthetic.xml | 56 +++++++++++++++++++
.../apple-health-import-own-samples.test.ts | 29 ++++++++++
2 files changed, 85 insertions(+)
create mode 100644 tests/fixtures/apple-health/export-own-origin.synthetic.xml
diff --git a/tests/fixtures/apple-health/export-own-origin.synthetic.xml b/tests/fixtures/apple-health/export-own-origin.synthetic.xml
new file mode 100644
index 000000000..aff5f87ac
--- /dev/null
+++ b/tests/fixtures/apple-health/export-own-origin.synthetic.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/integration/apple-health-import-own-samples.test.ts b/tests/integration/apple-health-import-own-samples.test.ts
index d97d0dda8..240cf7195 100644
--- a/tests/integration/apple-health-import-own-samples.test.ts
+++ b/tests/integration/apple-health-import-own-samples.test.ts
@@ -116,6 +116,35 @@ describe("a record HealthLog wrote itself (the origin marker)", () => {
});
});
+describe("the synthetic export fixture", () => {
+ // `export-own-origin.synthetic.xml` is invented, in the shape Apple's DTD
+ // documents. It shows the parser reads a file laid out like an export; it
+ // cannot show that Apple writes a custom metadata key verbatim.
+ it("leaves out the two marked records and imports the other three", async () => {
+ const user = await prisma.user.create({
+ data: { username: "own-fixture", email: "fx@example.test", role: "USER" },
+ });
+ const result = await streamParseExportXml({
+ xmlPath: join(
+ process.cwd(),
+ "tests/fixtures/apple-health/export-own-origin.synthetic.xml",
+ ),
+ userId: user.id,
+ userTimezone: "Europe/Berlin",
+ prisma,
+ });
+ const rows = await prisma.measurement.findMany({
+ where: { userId: user.id, type: "WEIGHT", source: "APPLE_HEALTH" },
+ orderBy: { measuredAt: "asc" },
+ });
+ expect(rows.map((r) => r.value)).toEqual([80.2, 80.4, 80.5]);
+ expect(result.writtenByHealthLog).toEqual({
+ byMarker: 2,
+ matchedManual: 0,
+ });
+ });
+});
+
describe("a manual entry mirrored before the marker existed", () => {
async function runAgainst(
manual: { value: number; at: string; source?: "MANUAL" | "WITHINGS" },
From 1763ac85d9268c1e35137d5e82c09e1308155700 Mon Sep 17 00:00:00 2001
From: BernhardBuckel <33808384+BernhardBuckel@users.noreply.github.com>
Date: Sun, 4 Oct 2026 11:28:08 +0200
Subject: [PATCH 3/8] fix(import): honour the origin marker on a blood pressure
correlation
A blood pressure reading is a wrapping its systolic and diastolic
records, and the marker can sit on the correlation instead of on each record.
The import only read it from a record's own metadata, so a marked correlation
would have come back in full.
A correlation's records are now held until it closes, then all kept or all left
out together: the marker on the correlation, before or after its records, or on
a record, applies. Tests cover each, the marker not spilling onto the next
record, and a self-closing record.
---
.../import-apple-health-export.ts | 39 +++++--
.../apple-health-import-own-samples.test.ts | 103 ++++++++++++++++++
2 files changed, 135 insertions(+), 7 deletions(-)
diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts
index 8801e4d49..43608a994 100644
--- a/src/lib/measurements/import-apple-health-export.ts
+++ b/src/lib/measurements/import-apple-health-export.ts
@@ -429,6 +429,13 @@ export async function streamParseExportXml(
// by HealthLog itself, which has to keep it out of the import.
let pendingRecord: { commit: () => void; ownOrigin: boolean } | null = null;
const writtenByHealthLog = { byMarker: 0, matchedManual: 0 };
+ // A `` (a blood pressure reading) wraps its records, and the
+ // marker may sit on the correlation itself, before or after them. Its records
+ // are held until it closes, then all kept or all left out together.
+ let currentCorrelation: {
+ ownOrigin: boolean;
+ held: Array<{ commit: () => void; ownOrigin: boolean }>;
+ } | null = null;
// Spot-row batch awaiting flush.
const spotBatch: PreparedMeasurement[] = [];
// Workout-row batch awaiting flush.
@@ -1091,9 +1098,16 @@ export async function streamParseExportXml(
return;
}
+ if (name === "Correlation") {
+ currentCorrelation = { ownOrigin: false, held: [] };
+ return;
+ }
+
if (name === "MetadataEntry") {
- if (pendingRecord && isHealthLogOriginEntry(attrs.key, attrs.value)) {
- pendingRecord.ownOrigin = true;
+ if (isHealthLogOriginEntry(attrs.key, attrs.value)) {
+ // On a record, or on the correlation that wraps it.
+ if (pendingRecord) pendingRecord.ownOrigin = true;
+ else if (currentCorrelation) currentCorrelation.ownOrigin = true;
}
// Attach the SexualActivity protection flag to the open cycle record.
// Apple writes `HKMetadataKeySexualActivityProtectionUsed` with a
@@ -1121,16 +1135,15 @@ export async function streamParseExportXml(
if (
name === "Me" ||
- name === "Correlation" ||
name === "ActivitySummary" ||
name === "WorkoutEvent" ||
name === "WorkoutRoute" ||
name === "FileReference" ||
name === "HealthData"
) {
- // Known elements we intentionally ignore at the open-tag stage.
- // Correlation envelopes flatten naturally because their child
- // `` elements still fire their own `onopentag`.
+ // Known elements we intentionally ignore at the open-tag stage. A
+ // `` is handled above: its child `` elements fire
+ // their own `onopentag`, and are held until it closes.
return;
}
@@ -1143,9 +1156,21 @@ export async function streamParseExportXml(
if (tagName === "Record" && pendingRecord) {
const rec = pendingRecord;
pendingRecord = null;
- if (rec.ownOrigin) writtenByHealthLog.byMarker += 1;
+ if (currentCorrelation) currentCorrelation.held.push(rec);
+ else if (rec.ownOrigin) writtenByHealthLog.byMarker += 1;
else rec.commit();
}
+ if (tagName === "Correlation" && currentCorrelation) {
+ const correlation = currentCorrelation;
+ currentCorrelation = null;
+ for (const rec of correlation.held) {
+ if (correlation.ownOrigin || rec.ownOrigin) {
+ writtenByHealthLog.byMarker += 1;
+ } else {
+ rec.commit();
+ }
+ }
+ }
if (tagName === "Workout" && currentWorkout) {
workoutBatch.push(currentWorkout);
currentWorkout = null;
diff --git a/tests/integration/apple-health-import-own-samples.test.ts b/tests/integration/apple-health-import-own-samples.test.ts
index 240cf7195..8d48dc0b6 100644
--- a/tests/integration/apple-health-import-own-samples.test.ts
+++ b/tests/integration/apple-health-import-own-samples.test.ts
@@ -116,6 +116,109 @@ describe("a record HealthLog wrote itself (the origin marker)", () => {
});
});
+const bpRecord = (
+ type: "Systolic" | "Diastolic",
+ value: number,
+ at: string,
+ children = "",
+) =>
+ ` ${children}`;
+const correlation = (inner: string, children = "") =>
+ ` ${children}\n${inner}\n `;
+
+describe("blood pressure correlations and self-closing records", () => {
+ const AT = "2026-05-14 08:00:00 +0200";
+ async function runBp(records: string) {
+ const user = await prisma.user.create({
+ data: { username: "own-bp", email: "bp@example.test", role: "USER" },
+ });
+ const result = await streamParseExportXml({
+ xmlPath: writeXml(records),
+ userId: user.id,
+ userTimezone: "Europe/Berlin",
+ prisma,
+ });
+ const rows = await prisma.measurement.findMany({
+ where: {
+ userId: user.id,
+ type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] },
+ },
+ orderBy: [{ type: "asc" }],
+ });
+ return { result, values: rows.map((r) => r.value) };
+ }
+
+ it("imports an unmarked correlation's two records", async () => {
+ const { values, result } = await runBp(
+ correlation(
+ [bpRecord("Systolic", 121, AT), bpRecord("Diastolic", 79, AT)].join(
+ "\n",
+ ),
+ ),
+ );
+ expect(values).toEqual([121, 79]);
+ expect(result.writtenByHealthLog.byMarker).toBe(0);
+ });
+
+ it("leaves out both records when the marker is on the correlation, before them", async () => {
+ const { values, result } = await runBp(
+ correlation(
+ [bpRecord("Systolic", 121, AT), bpRecord("Diastolic", 79, AT)].join(
+ "\n",
+ ),
+ `\n ${OWN}`,
+ ),
+ );
+ expect(values).toEqual([]);
+ expect(result.writtenByHealthLog.byMarker).toBe(2);
+ });
+
+ it("leaves out both records when the marker is on the correlation, after them", async () => {
+ const records = [
+ bpRecord("Systolic", 121, AT),
+ bpRecord("Diastolic", 79, AT),
+ ].join("\n");
+ const { values } = await runBp(
+ ` \n${records}\n ${OWN}\n `,
+ );
+ expect(values).toEqual([]);
+ });
+
+ it("leaves out only the record that carries the marker", async () => {
+ const { values } = await runBp(
+ correlation(
+ [
+ bpRecord("Systolic", 121, AT, `\n ${OWN}\n `),
+ bpRecord("Diastolic", 79, AT),
+ ].join("\n"),
+ ),
+ );
+ expect(values).toEqual([79]);
+ });
+
+ it("does not let a correlation's marker spill onto the next record", async () => {
+ const { values } = await runBp(
+ [
+ correlation(
+ [bpRecord("Systolic", 121, AT), bpRecord("Diastolic", 79, AT)].join(
+ "\n",
+ ),
+ `\n ${OWN}`,
+ ),
+ bpRecord("Systolic", 130, "2026-05-14 09:00:00 +0200"),
+ ].join("\n"),
+ );
+ expect(values).toEqual([130]);
+ });
+
+ it("imports a self-closing record, with no children to wait for", async () => {
+ const { rows } = await run(
+ ` `,
+ );
+ expect(rows.map((r) => r.value)).toEqual([80.9]);
+ });
+});
+
describe("the synthetic export fixture", () => {
// `export-own-origin.synthetic.xml` is invented, in the shape Apple's DTD
// documents. It shows the parser reads a file laid out like an export; it
From 8add851fb7ab10ff739789499133345b79735fbb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 4 Oct 2026 14:34:37 +0200
Subject: [PATCH 4/8] docs(import): say what the origin marker is for and who
reads it
The comment claimed the app's own sync filters by the marker. It does not:
the app's echo check is HKExternalUUID plus the sample's authoring source
being the app itself, and the marker exists for the deletion path, where
the source is not visible. Say so, and say why the export import reads the
marker and the row id instead.
---
src/lib/apple-health/own-origin.ts | 18 +++++++++++++-----
1 file changed, 13 insertions(+), 5 deletions(-)
diff --git a/src/lib/apple-health/own-origin.ts b/src/lib/apple-health/own-origin.ts
index ae8476ea4..34f6f2f8c 100644
--- a/src/lib/apple-health/own-origin.ts
+++ b/src/lib/apple-health/own-origin.ts
@@ -1,13 +1,21 @@
/**
* The marker HealthLog's iOS app stamps on every HealthKit sample it writes
- * itself (a manual entry, or a Withings or import row it mirrors into Apple
- * Health). The sample also carries the HealthLog measurement id as
- * `HKExternalUUID`.
+ * itself, next to `HKExternalUUID` (the HealthLog row id): a manual entry saved
+ * from the app, and any Withings, import or manual row the server-to-Health
+ * mirror writes. Builds from before the marker existed wrote the id alone.
*
* Such a sample is already in the database under its own source. When the same
* sample comes back through an Apple Health export it is a second copy of that
- * reading, so the export import leaves it out. This is the same predicate the
- * app's own sync filter uses, and a stable contract with the app.
+ * reading, so the export import leaves it out.
+ *
+ * This is not the predicate the app's own sync uses. The app decides "our own
+ * echo" from `HKExternalUUID` plus the sample's authoring source being the app
+ * itself (`HealthKitSampleOwnership.isOwnEcho`); it keeps this marker for the
+ * deletion path, where a deleted object exposes its metadata but no source. An
+ * export names its source only by a display name, so the import cannot repeat
+ * the source check and reads the marker instead, and for samples from before
+ * the marker, `HKExternalUUID` against the account's own row ids. The key and
+ * value are a stable contract with the app.
*/
export const HEALTHLOG_ORIGIN_METADATA_KEY = "dev.healthlog.app.origin";
export const HEALTHLOG_ORIGIN_METADATA_VALUE = "healthlog";
From 4dabc83513f670db857d5a2a1ef2980ba80b890d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 4 Oct 2026 14:34:38 +0200
Subject: [PATCH 5/8] fix(import): match HealthLog's own samples by row id and
ask for mirrors per type
A sample the app wrote before the origin marker existed still carries the
HealthLog row id as HKExternalUUID. A hit on one of the account's rows of
the same kind now leaves it out exactly, for Withings and import mirrors as
well as manual ones; either id of a blood pressure pair covers both halves,
since the app stamps both samples of a reading with one id.
The manual-mirror lookup read one OR clause per row on every batch, even
for an account without a single MANUAL row. The types that hold MANUAL rows
are now read once per import; a batch asks nothing for any other type, and
one time window per type for those.
Cycle samples carry the marker too and are now left out like the rest, and
a sample the export repeats (a correlation's records also stand on their
own) is counted once.
---
.../import-apple-health-export.ts | 222 ++++++++++++---
.../apple-health-import-own-samples.test.ts | 260 +++++++++++++++++-
2 files changed, 445 insertions(+), 37 deletions(-)
diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts
index 43608a994..dc280d285 100644
--- a/src/lib/measurements/import-apple-health-export.ts
+++ b/src/lib/measurements/import-apple-health-export.ts
@@ -122,11 +122,17 @@ export interface ImportJobResult {
clinical: { skipped: number };
/**
* Records left out because HealthLog wrote them into Apple Health itself and
- * already holds them under their own source: `byMarker` carry the app's
- * origin marker, `matchedManual` match a manual entry (same type and value,
- * within 2 s) from before the marker existed.
+ * already holds them under their own source, each counted once however often
+ * the export repeats it: `byMarker` carry the app's origin marker,
+ * `byExternalId` carry a HealthLog row id as `HKExternalUUID` (samples from
+ * before the marker existed), `matchedManual` match a manual entry (same type
+ * and value, within 2 s) without either.
*/
- writtenByHealthLog: { byMarker: number; matchedManual: number };
+ writtenByHealthLog: {
+ byMarker: number;
+ byExternalId: number;
+ matchedManual: number;
+ };
/**
* v1.15.0 — reproductive HealthKit samples routed into CYCLE day-logs
* (NOT Measurement). Absent / zeroed when the account has no cycle
@@ -175,6 +181,29 @@ interface PreparedMeasurement {
deviceType: string | null;
}
+/**
+ * A quantity `` read up to its open tag. Its contribution is committed
+ * at the close tag, once its `` children have been read.
+ */
+interface PendingRecord {
+ /** Queue the record; `healthLogId` is the `HKExternalUUID` it carried. */
+ commit: (healthLogId: string | null) => void;
+ ownOrigin: boolean;
+ healthLogId: string | null;
+ /** The record's sample identity, computed only when it is left out. */
+ sampleKey: () => string;
+}
+
+/** Apple's export name for `HKMetadataKeyExternalUUID`. */
+const HK_EXTERNAL_UUID_METADATA_KEY = "HKExternalUUID";
+
+/** The two halves of a blood pressure reading are one kind for an id match. */
+function kindOf(type: MeasurementType): string {
+ return type === "BLOOD_PRESSURE_SYS" || type === "BLOOD_PRESSURE_DIA"
+ ? "BLOOD_PRESSURE"
+ : type;
+}
+
/** Flush row written to the `Workout` table. */
interface PreparedWorkout {
userId: string;
@@ -417,6 +446,8 @@ export async function streamParseExportXml(
dayKey: string;
rawValue: string | undefined;
protectionUsed?: boolean;
+ sampleKey: string;
+ ownOrigin: boolean;
} | null = null;
// Cumulative fold: type -> local day -> hashed source identity -> subtotal.
@@ -427,14 +458,27 @@ export async function streamParseExportXml(
// A record's contribution is committed at its close tag, once its child
// `` rows are read: one of them can mark the sample as written
// by HealthLog itself, which has to keep it out of the import.
- let pendingRecord: { commit: () => void; ownOrigin: boolean } | null = null;
- const writtenByHealthLog = { byMarker: 0, matchedManual: 0 };
+ let pendingRecord: PendingRecord | null = null;
+ const writtenByHealthLog = { byMarker: 0, byExternalId: 0, matchedManual: 0 };
+ // An export can carry the same sample twice (a correlation's records also
+ // stand on their own), so a left-out sample is counted by its sample key.
+ const countedOwn = new Set();
+ const countOwn = (
+ reason: keyof typeof writtenByHealthLog,
+ sampleKey: string,
+ ): void => {
+ if (countedOwn.has(sampleKey)) return;
+ countedOwn.add(sampleKey);
+ writtenByHealthLog[reason] += 1;
+ };
+ // The `HKExternalUUID` a queued spot row carried, checked at flush.
+ const healthLogIdOf = new Map();
// A `` (a blood pressure reading) wraps its records, and the
// marker may sit on the correlation itself, before or after them. Its records
// are held until it closes, then all kept or all left out together.
let currentCorrelation: {
ownOrigin: boolean;
- held: Array<{ commit: () => void; ownOrigin: boolean }>;
+ held: PendingRecord[];
} | null = null;
// Spot-row batch awaiting flush.
const spotBatch: PreparedMeasurement[] = [];
@@ -492,54 +536,134 @@ export async function streamParseExportXml(
};
// ── Flush helpers ──────────────────────────────────────────
- // A manual entry the app mirrored into Apple Health before it stamped its
- // origin marker comes back in the export as a second copy of the reading.
- // Leave it out when a MANUAL row of the same type and value sits within 2 s.
- // Only MANUAL: Withings and import rows reach Apple Health through the app's
- // mirror, which always sets the marker, so a value-and-time match against
- // them would only add false positives. Same rule and tolerance as the sync
- // path (`cross-source-merge.ts`).
+ // A sample the app wrote before it stamped its origin marker still carries
+ // the HealthLog row id as `HKExternalUUID`. A hit on one of this account's
+ // rows (deleted ones included: the sample is ours either way) of the same
+ // kind is an exact match. Blood pressure is one row per half, while the app
+ // stamps both samples of a reading with one id (the systolic row's, the
+ // diastolic row's in older builds), so either half matches either id.
+ const withoutHealthLogIds = async (
+ rows: PreparedMeasurement[],
+ ): Promise => {
+ const ids = new Set();
+ for (const row of rows) {
+ const id = healthLogIdOf.get(row);
+ if (id) ids.add(id);
+ }
+ if (ids.size === 0) return rows;
+ const known = await prisma.measurement.findMany({
+ where: { userId, id: { in: [...ids] } },
+ select: { id: true, type: true },
+ });
+ if (known.length === 0) return rows;
+ const typeOf = new Map(known.map((row) => [row.id, row.type]));
+ return rows.filter((row) => {
+ const id = healthLogIdOf.get(row);
+ const rowType = id ? typeOf.get(id) : undefined;
+ if (!rowType || kindOf(rowType) !== kindOf(row.type)) return true;
+ countOwn("byExternalId", row.externalId);
+ return false;
+ });
+ };
+
+ // A manual entry mirrored with neither the marker nor an id comes back as a
+ // second copy of the reading. Leave it out when a MANUAL row of the same
+ // type and value sits within 2 s. Only MANUAL: Withings and import rows are
+ // written into Apple Health by the server-to-Health mirror, which has always
+ // stamped the row id, so a value-and-time match against them would only add
+ // false positives. Same rule and tolerance as the sync path
+ // (`cross-source-merge.ts`).
+ //
+ // The types that hold MANUAL rows are read once per import, so a batch of a
+ // type the account never typed in asks nothing, and a batch that does asks
+ // one time window per type rather than one clause per row.
+ let manualTypes: Promise> | null = null;
const withoutManualMirrors = async (
rows: PreparedMeasurement[],
): Promise => {
if (rows.length === 0) return rows;
+ manualTypes ??= prisma.measurement
+ .groupBy({
+ by: ["type"],
+ where: { userId, source: "MANUAL", deletedAt: null },
+ })
+ .then((groups) => new Set(groups.map((group) => group.type)));
+ const typed = await manualTypes;
+ const windows = new Map();
+ for (const row of rows) {
+ if (!typed.has(row.type)) continue;
+ const at = row.measuredAt.getTime();
+ const window = windows.get(row.type);
+ if (!window) windows.set(row.type, { from: at, to: at });
+ else {
+ if (at < window.from) window.from = at;
+ if (at > window.to) window.to = at;
+ }
+ }
+ if (windows.size === 0) return rows;
const candidates = await prisma.measurement.findMany({
where: {
userId,
source: "MANUAL",
deletedAt: null,
- OR: rows.map((row) => ({
- type: row.type,
+ OR: [...windows].map(([type, window]) => ({
+ type,
measuredAt: {
- gte: new Date(row.measuredAt.getTime() - MEASURED_AT_TOLERANCE_MS),
- lte: new Date(row.measuredAt.getTime() + MEASURED_AT_TOLERANCE_MS),
+ gte: new Date(window.from - MEASURED_AT_TOLERANCE_MS),
+ lte: new Date(window.to + MEASURED_AT_TOLERANCE_MS),
},
})),
},
select: { type: true, source: true, value: true, measuredAt: true },
+ orderBy: { measuredAt: "asc" },
});
if (candidates.length === 0) return rows;
+ const byType = new Map();
+ for (const candidate of candidates) {
+ const list = byType.get(candidate.type);
+ if (list) list.push(candidate);
+ else byType.set(candidate.type, [candidate]);
+ }
return rows.filter((row) => {
- const mirrored = candidates.some((candidate) =>
- isSameReadingAcrossSource(
+ const list = byType.get(row.type);
+ if (!list) return true;
+ const at = row.measuredAt.getTime();
+ // First candidate not earlier than the tolerance window.
+ let lo = 0;
+ let hi = list.length;
+ while (lo < hi) {
+ const mid = (lo + hi) >> 1;
+ if (list[mid].measuredAt.getTime() < at - MEASURED_AT_TOLERANCE_MS)
+ lo = mid + 1;
+ else hi = mid;
+ }
+ for (let i = lo; i < list.length; i++) {
+ if (list[i].measuredAt.getTime() > at + MEASURED_AT_TOLERANCE_MS) break;
+ const mirrored = isSameReadingAcrossSource(
{
type: row.type,
source: "APPLE_HEALTH",
value: row.value,
measuredAt: row.measuredAt,
},
- candidate,
- ),
- );
- if (mirrored) writtenByHealthLog.matchedManual += 1;
- return !mirrored;
+ list[i],
+ );
+ if (mirrored) {
+ countOwn("matchedManual", row.externalId);
+ return false;
+ }
+ }
+ return true;
});
};
const flushSpotBatch = async (): Promise => {
if (spotBatch.length === 0) return;
const incoming = spotBatch.splice(0, spotBatch.length);
- const chunk = await withoutManualMirrors(incoming);
+ const chunk = await withoutManualMirrors(
+ await withoutHealthLogIds(incoming),
+ );
+ for (const row of incoming) healthLogIdOf.delete(row);
for (const row of chunk) widenSpan(row.measuredAt);
const insertedArrivals: Array<{
id: string;
@@ -916,7 +1040,18 @@ export async function streamParseExportXml(
Date.parse(attrs.endDate ?? attrs.startDate ?? ""),
)
? null
- : { hkType, dayKey, rawValue: attrs.value };
+ : {
+ hkType,
+ dayKey,
+ rawValue: attrs.value,
+ sampleKey: hashSampleKey(
+ hkType,
+ attrs.value ?? "",
+ attrs.startDate,
+ attrs.endDate,
+ ),
+ ownOrigin: false,
+ };
return;
}
@@ -978,7 +1113,15 @@ export async function streamParseExportXml(
pendingRecord = {
ownOrigin: false,
- commit: () => {
+ healthLogId: null,
+ sampleKey: () =>
+ hashSampleKey(
+ hkType,
+ attrs.value ?? "",
+ attrs.startDate,
+ attrs.endDate,
+ ),
+ commit: (healthLogId) => {
if (CUMULATIVE_HK_TYPES.has(mapped.type)) {
const dayKey = dayKeyForUserTz(mapped.takenAt, userTimezone);
const sourceHash = hashCumulativeSourceIdentity(
@@ -1007,7 +1150,7 @@ export async function streamParseExportXml(
attrs.startDate,
attrs.endDate,
);
- spotBatch.push({
+ const row: PreparedMeasurement = {
userId,
type: mapped.type,
value: mapped.value,
@@ -1017,7 +1160,9 @@ export async function streamParseExportXml(
externalSourceVersion: attrs.sourceVersion ?? null,
sleepStage: mapped.sleepStage ?? null,
deviceType: null,
- });
+ };
+ spotBatch.push(row);
+ if (healthLogId) healthLogIdOf.set(row, healthLogId);
}
},
};
@@ -1107,8 +1252,12 @@ export async function streamParseExportXml(
if (isHealthLogOriginEntry(attrs.key, attrs.value)) {
// On a record, or on the correlation that wraps it.
if (pendingRecord) pendingRecord.ownOrigin = true;
+ else if (currentCycleRecord) currentCycleRecord.ownOrigin = true;
else if (currentCorrelation) currentCorrelation.ownOrigin = true;
}
+ if (pendingRecord && attrs.key === HK_EXTERNAL_UUID_METADATA_KEY) {
+ pendingRecord.healthLogId = attrs.value || null;
+ }
// Attach the SexualActivity protection flag to the open cycle record.
// Apple writes `HKMetadataKeySexualActivityProtectionUsed` with a
// `"0"`/`"1"` (or `"true"`/`"false"`) value.
@@ -1157,17 +1306,17 @@ export async function streamParseExportXml(
const rec = pendingRecord;
pendingRecord = null;
if (currentCorrelation) currentCorrelation.held.push(rec);
- else if (rec.ownOrigin) writtenByHealthLog.byMarker += 1;
- else rec.commit();
+ else if (rec.ownOrigin) countOwn("byMarker", rec.sampleKey());
+ else rec.commit(rec.healthLogId);
}
if (tagName === "Correlation" && currentCorrelation) {
const correlation = currentCorrelation;
currentCorrelation = null;
for (const rec of correlation.held) {
if (correlation.ownOrigin || rec.ownOrigin) {
- writtenByHealthLog.byMarker += 1;
+ countOwn("byMarker", rec.sampleKey());
} else {
- rec.commit();
+ rec.commit(rec.healthLogId);
}
}
}
@@ -1178,6 +1327,11 @@ export async function streamParseExportXml(
if (tagName === "Record" && currentCycleRecord) {
const rec = currentCycleRecord;
currentCycleRecord = null;
+ if (rec.ownOrigin) {
+ // A day HealthLog mirrored into Apple Health from its own day-log.
+ countOwn("byMarker", rec.sampleKey);
+ return;
+ }
const consumed = cycleAccumulator.consume(
rec.hkType,
rec.dayKey,
diff --git a/tests/integration/apple-health-import-own-samples.test.ts b/tests/integration/apple-health-import-own-samples.test.ts
index 8d48dc0b6..a24b663de 100644
--- a/tests/integration/apple-health-import-own-samples.test.ts
+++ b/tests/integration/apple-health-import-own-samples.test.ts
@@ -13,11 +13,17 @@
* - a record without it lands as before;
* - the marker is read from a child `` that follows the
* record's open tag, so the record is committed at its close tag;
- * - a manual entry mirrored before the marker existed (a MANUAL row with the
- * same type and value within 2 s) is left out and counted separately;
+ * - a sample from before the marker carries the HealthLog row id as
+ * `HKExternalUUID`; a hit on one of the account's rows of the same kind
+ * leaves it out, either id of a blood pressure pair covering both halves;
+ * - a manual entry mirrored with neither (a MANUAL row with the same type and
+ * value within 2 s) is left out and counted separately;
* - a MANUAL row with another value, or 3 s away, is not a match;
* - a Withings row of the same value and time is not a match either (those
- * are mirrored with the marker, so a match would only add false positives).
+ * are mirrored with their row id, so a match would only add false
+ * positives);
+ * - the value match asks nothing for a type the account has no MANUAL rows
+ * of, and each sample left out is counted once.
*/
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -82,6 +88,7 @@ describe("a record HealthLog wrote itself (the origin marker)", () => {
expect(rows.map((r) => r.value)).toEqual([80.2, 80.3]);
expect(result.writtenByHealthLog).toEqual({
byMarker: 1,
+ byExternalId: 0,
matchedManual: 0,
});
});
@@ -243,6 +250,7 @@ describe("the synthetic export fixture", () => {
expect(rows.map((r) => r.value)).toEqual([80.2, 80.4, 80.5]);
expect(result.writtenByHealthLog).toEqual({
byMarker: 2,
+ byExternalId: 0,
matchedManual: 0,
});
});
@@ -286,6 +294,7 @@ describe("a manual entry mirrored before the marker existed", () => {
expect(rows).toHaveLength(0);
expect(result.writtenByHealthLog).toEqual({
byMarker: 0,
+ byExternalId: 0,
matchedManual: 1,
});
});
@@ -316,3 +325,248 @@ describe("a manual entry mirrored before the marker existed", () => {
expect(result.writtenByHealthLog.matchedManual).toBe(0);
});
});
+
+/**
+ * Count every `measurement.findMany` the import issues against MANUAL rows,
+ * by wrapping the client the import is handed.
+ */
+function countingManualQueries() {
+ const manualQueries: Array<{ where?: Record }> = [];
+ const measurement = new Proxy(prisma.measurement, {
+ get(target, prop, receiver) {
+ const value = Reflect.get(target, prop, receiver);
+ if (prop !== "findMany" || typeof value !== "function") return value;
+ return (args: { where?: Record }) => {
+ if (args?.where?.source === "MANUAL") manualQueries.push(args);
+ return value.call(target, args);
+ };
+ },
+ });
+ const client = new Proxy(prisma, {
+ get(target, prop, receiver) {
+ if (prop === "measurement") return measurement;
+ const value = Reflect.get(target, prop, receiver);
+ return typeof value === "function" ? value.bind(target) : value;
+ },
+ }) as typeof prisma;
+ return { client, manualQueries };
+}
+
+describe("the manual-mirror lookup stays off the hot path", () => {
+ const AT = (i: number) =>
+ `2026-05-14 ${String(8 + Math.floor(i / 60)).padStart(2, "0")}:${String(i % 60).padStart(2, "0")}:00 +0200`;
+ const heartRate = (value: number, at: string) =>
+ ` `;
+
+ async function runCounting(records: string, manualType?: "WEIGHT") {
+ const user = await prisma.user.create({
+ data: { username: "own-hot", email: "hot@example.test", role: "USER" },
+ });
+ if (manualType) {
+ await prisma.measurement.create({
+ data: {
+ userId: user.id,
+ type: manualType,
+ unit: "kg",
+ source: "MANUAL",
+ value: 80.4,
+ measuredAt: new Date("2026-05-14T06:00:00.000Z"),
+ },
+ });
+ }
+ const { client, manualQueries } = countingManualQueries();
+ const result = await streamParseExportXml({
+ xmlPath: writeXml(records),
+ userId: user.id,
+ userTimezone: "Europe/Berlin",
+ prisma: client,
+ spotBatchSize: 10,
+ });
+ return { result, manualQueries, user };
+ }
+
+ it("asks nothing per batch when the account has no MANUAL rows", async () => {
+ const records = Array.from({ length: 40 }, (_, i) =>
+ heartRate(60 + (i % 20), AT(i)),
+ ).join("\n");
+ const { manualQueries, result } = await runCounting(records);
+ expect(manualQueries).toHaveLength(0);
+ expect(result.writtenByHealthLog.matchedManual).toBe(0);
+ });
+
+ it("asks nothing for a type without MANUAL rows, and one bounded window per batch for one with them", async () => {
+ const records = [
+ ...Array.from({ length: 40 }, (_, i) => heartRate(60 + (i % 20), AT(i))),
+ weight(80.4, "2026-05-14 08:00:00 +0200"),
+ weight(80.6, "2026-05-14 09:00:00 +0200"),
+ ].join("\n");
+ const { manualQueries, result, user } = await runCounting(
+ records,
+ "WEIGHT",
+ );
+ // The heart rates ask nothing; the weights ask once, with one window for
+ // their type, not one clause per row.
+ expect(manualQueries).toHaveLength(1);
+ const or = manualQueries[0].where?.OR as Array<{ type: string }>;
+ expect(or).toHaveLength(1);
+ expect(or[0].type).toBe("WEIGHT");
+ // The mirror is still caught; the other weight still lands.
+ expect(result.writtenByHealthLog.matchedManual).toBe(1);
+ const weights = await prisma.measurement.findMany({
+ where: { userId: user.id, type: "WEIGHT", source: "APPLE_HEALTH" },
+ });
+ expect(weights.map((w) => w.value)).toEqual([80.6]);
+ });
+});
+
+describe("a sample whose HKExternalUUID is a HealthLog row id", () => {
+ const AT = "2026-05-14 08:00:00 +0200";
+ const ext = (id: string) =>
+ ``;
+
+ async function runWith(
+ seed: (userId: string) => Promise>,
+ records: (ids: Record) => string,
+ ) {
+ const user = await prisma.user.create({
+ data: { username: "own-ext", email: "ext@example.test", role: "USER" },
+ });
+ const ids = await seed(user.id);
+ const result = await streamParseExportXml({
+ xmlPath: writeXml(records(ids)),
+ userId: user.id,
+ userTimezone: "Europe/Berlin",
+ prisma,
+ });
+ const rows = await prisma.measurement.findMany({
+ where: { userId: user.id, source: "APPLE_HEALTH" },
+ orderBy: [{ type: "asc" }],
+ });
+ return { result, rows };
+ }
+
+ const seedRow = async (
+ userId: string,
+ type: "WEIGHT" | "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA",
+ value: number,
+ source: "MANUAL" | "WITHINGS" = "MANUAL",
+ ) =>
+ (
+ await prisma.measurement.create({
+ data: {
+ userId,
+ type,
+ value,
+ unit: type === "WEIGHT" ? "kg" : "mmHg",
+ source,
+ // Ten minutes off the sample: only the id can tie them together.
+ measuredAt: new Date("2026-05-14T06:10:00.000Z"),
+ },
+ })
+ ).id;
+
+ it("is left out without the marker, for a Withings row the value match never covers", async () => {
+ const { result, rows } = await runWith(
+ async (userId) => ({
+ w: await seedRow(userId, "WEIGHT", 81, "WITHINGS"),
+ }),
+ (ids) => weight(80.4, AT, ext(ids.w)),
+ );
+ expect(rows).toHaveLength(0);
+ expect(result.writtenByHealthLog).toEqual({
+ byMarker: 0,
+ byExternalId: 1,
+ matchedManual: 0,
+ });
+ });
+
+ it("leaves out both halves of a pressure pair stamped with either half's id", async () => {
+ const { result, rows } = await runWith(
+ async (userId) => ({
+ sys: await seedRow(userId, "BLOOD_PRESSURE_SYS", 121),
+ dia: await seedRow(userId, "BLOOD_PRESSURE_DIA", 79),
+ }),
+ (ids) =>
+ [
+ // Current builds stamp both samples with the systolic id ...
+ bpRecord("Systolic", 121, AT, ext(ids.sys)),
+ bpRecord("Diastolic", 79, AT, ext(ids.sys)),
+ // ... older builds stamped both with the diastolic id.
+ bpRecord("Systolic", 122, "2026-05-14 09:00:00 +0200", ext(ids.dia)),
+ bpRecord("Diastolic", 80, "2026-05-14 09:00:00 +0200", ext(ids.dia)),
+ ].join("\n"),
+ );
+ expect(rows).toHaveLength(0);
+ expect(result.writtenByHealthLog.byExternalId).toBe(4);
+ });
+
+ it("is imported when the id is unknown, belongs to another account, or names another kind", async () => {
+ const other = await prisma.user.create({
+ data: { username: "own-ext-2", email: "ext2@example.test", role: "USER" },
+ });
+ const foreignId = await seedRow(other.id, "WEIGHT", 80.4);
+ const { result, rows } = await runWith(
+ async (userId) => ({
+ sys: await seedRow(userId, "BLOOD_PRESSURE_SYS", 121),
+ }),
+ (ids) =>
+ [
+ weight(80.1, AT, ext("00000000-0000-4000-8000-000000000001")),
+ weight(80.2, "2026-05-14 09:00:00 +0200", ext(foreignId)),
+ weight(80.3, "2026-05-14 10:00:00 +0200", ext(ids.sys)),
+ ].join("\n"),
+ );
+ expect(rows.map((r) => r.value)).toEqual([80.1, 80.2, 80.3]);
+ expect(result.writtenByHealthLog.byExternalId).toBe(0);
+ });
+});
+
+describe("each sample left out is counted once", () => {
+ it("does not count a marked record twice when it appears top level and inside its correlation", async () => {
+ const AT = "2026-05-14 08:00:00 +0200";
+ const sys = bpRecord("Systolic", 121, AT, `\n ${OWN}\n `);
+ const dia = bpRecord("Diastolic", 79, AT, `\n ${OWN}\n `);
+ const user = await prisma.user.create({
+ data: { username: "own-once", email: "once@example.test", role: "USER" },
+ });
+ const result = await streamParseExportXml({
+ xmlPath: writeXml(
+ [sys, dia, correlation([sys, dia].join("\n"))].join("\n"),
+ ),
+ userId: user.id,
+ userTimezone: "Europe/Berlin",
+ prisma,
+ });
+ expect(result.writtenByHealthLog.byMarker).toBe(2);
+ });
+});
+
+describe("a cycle sample HealthLog wrote itself", () => {
+ it("is left out of the day-log import", async () => {
+ const user = await prisma.user.create({
+ data: {
+ username: "own-cycle",
+ email: "cycle@example.test",
+ role: "USER",
+ gender: "FEMALE",
+ timezone: "Europe/Berlin",
+ },
+ });
+ const flow = (day: string, children = "") =>
+ ` ${children}`;
+ const result = await streamParseExportXml({
+ xmlPath: writeXml(
+ [flow("2026-03-02", OWN), flow("2026-03-03")].join("\n"),
+ ),
+ userId: user.id,
+ userTimezone: "Europe/Berlin",
+ prisma,
+ });
+ const days = await prisma.cycleDayLog.findMany({
+ where: { userId: user.id },
+ orderBy: { date: "asc" },
+ });
+ expect(days.map((d) => d.date)).toEqual(["2026-03-03"]);
+ expect(result.writtenByHealthLog.byMarker).toBe(1);
+ });
+});
From 67e8e7c4f2d933f2b9797bcdfb5b9e53df12e0d3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 4 Oct 2026 14:34:42 +0200
Subject: [PATCH 6/8] fix(import): pluralise the note on records HealthLog
wrote itself
The note had one form per locale, so a single left-out record read as a
plural. It now resolves One, Few and Other through tCount like the other
counted strings, with the singular in every locale and the Polish few form.
---
messages/de.json | 4 +-
messages/en.json | 4 +-
messages/es.json | 4 +-
messages/fr.json | 4 +-
messages/it.json | 4 +-
messages/ko.json | 4 +-
messages/pl.json | 4 +-
.../apple-health-own-records-note.test.tsx | 62 +++++++++++++++++++
.../import-panel/apple-health-import-card.tsx | 35 +++++++----
9 files changed, 106 insertions(+), 19 deletions(-)
create mode 100644 src/components/settings/import-panel/__tests__/apple-health-own-records-note.test.tsx
diff --git a/messages/de.json b/messages/de.json
index bdc16bfd4..070101ba0 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5373,7 +5373,9 @@
"rowsImported": "Bisher {count} Einträge importiert",
"doneSummary": "Fertig — {imported} von {read} Datensätzen importiert, {skipped} klinische Datensätze übersprungen.",
"doneNothing": "Aus diesem Archiv wurde nichts importiert: 0 von {read} Datensätzen sind angekommen.",
- "writtenByHealthLog": "{count} Einträge, die HealthLog selbst in Apple Health geschrieben hat, wurden ausgelassen, weil sie schon in deinen Daten sind.",
+ "writtenByHealthLogOne": "{count} Eintrag, den HealthLog selbst in Apple Health geschrieben hat, wurde ausgelassen, weil er schon in deinen Daten ist.",
+ "writtenByHealthLogOther": "{count} Einträge, die HealthLog selbst in Apple Health geschrieben hat, wurden ausgelassen, weil sie schon in deinen Daten sind.",
+ "writtenByHealthLogFew": "{count} Einträge, die HealthLog selbst in Apple Health geschrieben hat, wurden ausgelassen, weil sie schon in deinen Daten sind.",
"ecgResult": "EKGs: {discovered} gefunden, {imported} importiert, {updated} aktualisiert, {skipped} übersprungen, {failed} fehlgeschlagen. Fehler bei EKGs verwerfen keine unterstützten Datensätze, die aus export.xml importiert wurden.",
"estimateWarning": "Kumulative Summen für {count} Tag(e) wurden anhand der größten Exportquelle geschätzt. Native Apple-Health-Statistiken bleiben maßgeblich.",
"phase": {
diff --git a/messages/en.json b/messages/en.json
index 979ec1a04..41f1dbcb3 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5373,7 +5373,9 @@
"rowsImported": "{count} rows imported so far",
"doneSummary": "Done — {imported} of {read} records imported, {skipped} clinical records skipped.",
"doneNothing": "Nothing was imported from that archive: 0 of {read} records landed.",
- "writtenByHealthLog": "{count} records that HealthLog wrote into Apple Health itself were left out, because they are already in your data.",
+ "writtenByHealthLogOne": "{count} record that HealthLog wrote into Apple Health itself was left out, because it is already in your data.",
+ "writtenByHealthLogOther": "{count} records that HealthLog wrote into Apple Health itself were left out, because they are already in your data.",
+ "writtenByHealthLogFew": "{count} records that HealthLog wrote into Apple Health itself were left out, because they are already in your data.",
"ecgResult": "ECGs: {discovered} discovered, {imported} imported, {updated} updated, {skipped} skipped, {failed} failed. ECG failures do not discard supported records imported from export.xml.",
"estimateWarning": "Cumulative totals for {count} day(s) were estimated from the largest export source. Native Apple Health statistics remain authoritative.",
"phase": {
diff --git a/messages/es.json b/messages/es.json
index 50198919b..1a1167cf8 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5373,7 +5373,9 @@
"rowsImported": "{count} filas importadas hasta ahora",
"doneSummary": "Listo: {imported} de {read} registros importados, {skipped} registros clínicos omitidos.",
"doneNothing": "No se importó nada de ese archivo: llegaron 0 de {read} registros.",
- "writtenByHealthLog": "Se omitieron {count} registros que HealthLog escribió en Apple Health, porque ya están en tus datos.",
+ "writtenByHealthLogOne": "Se omitió {count} registro que HealthLog escribió en Apple Health, porque ya está en tus datos.",
+ "writtenByHealthLogOther": "Se omitieron {count} registros que HealthLog escribió en Apple Health, porque ya están en tus datos.",
+ "writtenByHealthLogFew": "Se omitieron {count} registros que HealthLog escribió en Apple Health, porque ya están en tus datos.",
"ecgResult": "ECG: {discovered} detectados, {imported} importados, {updated} actualizados, {skipped} omitidos y {failed} fallidos. Los errores de ECG no descartan los registros compatibles importados desde export.xml.",
"estimateWarning": "Los totales acumulados de {count} día(s) se estimaron a partir de la mayor fuente de la exportación. Las estadísticas nativas de Apple Health siguen siendo la referencia.",
"phase": {
diff --git a/messages/fr.json b/messages/fr.json
index 19cd12f96..721c5befe 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5373,7 +5373,9 @@
"rowsImported": "{count} lignes importées jusqu'ici",
"doneSummary": "Terminé — {imported} enregistrements sur {read} importés, {skipped} enregistrements cliniques ignorés.",
"doneNothing": "Rien n'a été importé de cette archive : 0 enregistrement sur {read} est arrivé.",
- "writtenByHealthLog": "{count} enregistrements que HealthLog a écrits lui-même dans Santé ont été ignorés, car ils figurent déjà dans vos données.",
+ "writtenByHealthLogOne": "{count} enregistrement que HealthLog a écrit lui-même dans Santé a été ignoré, car il figure déjà dans vos données.",
+ "writtenByHealthLogOther": "{count} enregistrements que HealthLog a écrits lui-même dans Santé ont été ignorés, car ils figurent déjà dans vos données.",
+ "writtenByHealthLogFew": "{count} enregistrements que HealthLog a écrits lui-même dans Santé ont été ignorés, car ils figurent déjà dans vos données.",
"ecgResult": "ECG : {discovered} détectés, {imported} importés, {updated} mis à jour, {skipped} ignorés et {failed} échoués. Les erreurs d’ECG n’annulent pas les enregistrements compatibles importés depuis export.xml.",
"estimateWarning": "Les totaux cumulés de {count} jour(s) ont été estimés à partir de la source d’export la plus élevée. Les statistiques Apple Santé natives restent la référence.",
"phase": {
diff --git a/messages/it.json b/messages/it.json
index 9c77b4ab9..d6b34faa5 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5373,7 +5373,9 @@
"rowsImported": "{count} righe importate finora",
"doneSummary": "Fatto: {imported} di {read} record importati, {skipped} record clinici saltati.",
"doneNothing": "Da questo archivio non è stato importato nulla: sono arrivati 0 record su {read}.",
- "writtenByHealthLog": "{count} registrazioni che HealthLog ha scritto in Salute sono state escluse, perché sono già nei tuoi dati.",
+ "writtenByHealthLogOne": "{count} registrazione che HealthLog ha scritto in Salute è stata esclusa, perché è già nei tuoi dati.",
+ "writtenByHealthLogOther": "{count} registrazioni che HealthLog ha scritto in Salute sono state escluse, perché sono già nei tuoi dati.",
+ "writtenByHealthLogFew": "{count} registrazioni che HealthLog ha scritto in Salute sono state escluse, perché sono già nei tuoi dati.",
"ecgResult": "ECG: {discovered} rilevati, {imported} importati, {updated} aggiornati, {skipped} ignorati e {failed} non riusciti. Gli errori ECG non eliminano i record supportati importati da export.xml.",
"estimateWarning": "I totali cumulativi di {count} giorno/i sono stati stimati dalla fonte di esportazione più alta. Le statistiche native di Apple Salute restano autorevoli.",
"phase": {
diff --git a/messages/ko.json b/messages/ko.json
index 2c0721ccb..a22a21b1b 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -5373,7 +5373,9 @@
"rowsImported": "지금까지 {count}개 행을 불러왔어요",
"doneSummary": "완료 — {read}개 중 {imported}개 기록을 불러왔고, 진료 기록 {skipped}개는 건너뛰었어요.",
"doneNothing": "그 보관 파일에서 불러온 게 없어요: {read}개 중 0개만 들어왔어요.",
- "writtenByHealthLog": "HealthLog가 직접 건강 앱에 기록한 {count}개 항목은 이미 데이터에 있으므로 제외했습니다.",
+ "writtenByHealthLogOne": "HealthLog가 직접 건강 앱에 기록한 {count}개 항목은 이미 데이터에 있으므로 제외했습니다.",
+ "writtenByHealthLogOther": "HealthLog가 직접 건강 앱에 기록한 {count}개 항목은 이미 데이터에 있으므로 제외했습니다.",
+ "writtenByHealthLogFew": "HealthLog가 직접 건강 앱에 기록한 {count}개 항목은 이미 데이터에 있으므로 제외했습니다.",
"ecgResult": "심전도: 발견 {discovered}개, 불러옴 {imported}개, 갱신 {updated}개, 건너뜀 {skipped}개, 실패 {failed}개. 심전도가 실패해도 export.xml에서 불러온 지원 기록은 그대로 남아요.",
"estimateWarning": "{count}일치 누적 합계는 가장 큰 내보내기 소스에서 추정했어요. Apple Health 자체 통계가 여전히 기준이에요.",
"phase": {
diff --git a/messages/pl.json b/messages/pl.json
index c8091d1c0..229ade97a 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5373,7 +5373,9 @@
"rowsImported": "Zaimportowano dotąd {count} wierszy",
"doneSummary": "Gotowe — zaimportowano {imported} z {read} rekordów, pominięto {skipped} rekordów klinicznych.",
"doneNothing": "Z tego archiwum nic nie zaimportowano: dotarło 0 z {read} rekordów.",
- "writtenByHealthLog": "Pominięto {count} wpisów, które HealthLog sam zapisał w Apple Health, ponieważ są już w Twoich danych.",
+ "writtenByHealthLogOne": "Pominięto {count} wpis, który HealthLog sam zapisał w Apple Health, ponieważ jest już w Twoich danych.",
+ "writtenByHealthLogOther": "Pominięto {count} wpisów, które HealthLog sam zapisał w Apple Health, ponieważ są już w Twoich danych.",
+ "writtenByHealthLogFew": "Pominięto {count} wpisy, które HealthLog sam zapisał w Apple Health, ponieważ są już w Twoich danych.",
"ecgResult": "EKG: wykryto {discovered}, zaimportowano {imported}, zaktualizowano {updated}, pominięto {skipped}, nie powiodło się {failed}. Błędy EKG nie usuwają obsługiwanych rekordów zaimportowanych z pliku export.xml.",
"estimateWarning": "Łączne wartości dla {count} dni oszacowano na podstawie największego źródła w eksporcie. Natywne statystyki Apple Health pozostają nadrzędne.",
"phase": {
diff --git a/src/components/settings/import-panel/__tests__/apple-health-own-records-note.test.tsx b/src/components/settings/import-panel/__tests__/apple-health-own-records-note.test.tsx
new file mode 100644
index 000000000..9d18bd756
--- /dev/null
+++ b/src/components/settings/import-panel/__tests__/apple-health-own-records-note.test.tsx
@@ -0,0 +1,62 @@
+/**
+ * The note under a finished Apple Health import that says how many records
+ * were left out because HealthLog wrote them into Apple Health itself.
+ *
+ * The count is often 1, and a single string rendered "1 Einträge". The note
+ * reads the plural tier of the active locale, so each locale's own singular
+ * (and the Polish few form) reaches the screen.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import type { MessageBundle } from "@/lib/i18n/load-locale";
+import { locales, type Locale } from "@/lib/i18n/config";
+
+import { AppleHealthOwnRecordsNote } from "../apple-health-import-card";
+
+function bundle(locale: Locale): MessageBundle {
+ return JSON.parse(
+ readFileSync(join(process.cwd(), "messages", `${locale}.json`), "utf8"),
+ ) as MessageBundle;
+}
+
+function render(locale: Locale, count: number): string {
+ return renderToStaticMarkup(
+
+
+ ,
+ )
+ .replace(/<[^>]+>/g, "")
+ .trim();
+}
+
+describe("", () => {
+ it("uses the singular for one record", () => {
+ expect(render("de", 1)).toMatch(/^1 Eintrag,/);
+ expect(render("en", 1)).toMatch(/^1 record that/);
+ expect(render("es", 1)).toContain("1 registro que");
+ expect(render("fr", 1)).toMatch(/^1 enregistrement que/);
+ expect(render("it", 1)).toMatch(/^1 registrazione che/);
+ expect(render("pl", 1)).toContain("1 wpis,");
+ });
+
+ it("uses the plural for several, and the Polish few form for 2 to 4", () => {
+ expect(render("de", 5)).toMatch(/^5 Einträge,/);
+ expect(render("en", 5)).toMatch(/^5 records that/);
+ expect(render("pl", 3)).toContain("3 wpisy,");
+ expect(render("pl", 5)).toContain("5 wpisów,");
+ });
+
+ it("renders a sentence with the count in every locale", () => {
+ for (const locale of locales) {
+ for (const count of [1, 2, 5]) {
+ const text = render(locale, count);
+ expect(text, `${locale} ${count}`).toContain(String(count));
+ expect(text, `${locale} ${count}`).not.toContain("writtenByHealthLog");
+ }
+ }
+ });
+});
diff --git a/src/components/settings/import-panel/apple-health-import-card.tsx b/src/components/settings/import-panel/apple-health-import-card.tsx
index dbe40953c..fa486dc3c 100644
--- a/src/components/settings/import-panel/apple-health-import-card.tsx
+++ b/src/components/settings/import-panel/apple-health-import-card.tsx
@@ -32,7 +32,11 @@ interface JobStatus {
clinical?: { skipped?: number };
cumulativeEstimates?: { days?: number; rows?: number };
/** Records HealthLog wrote into Apple Health itself, left out here. */
- writtenByHealthLog?: { byMarker?: number; matchedManual?: number };
+ writtenByHealthLog?: {
+ byMarker?: number;
+ byExternalId?: number;
+ matchedManual?: number;
+ };
/** HK types deferred by design — read but not (yet) imported. */
deferred?: Record;
/** Per-key drop counters — plain HK types are unsupported-by-design,
@@ -249,6 +253,22 @@ export function AppleHealthSkipLines({
);
}
+/** How many records the import left out because HealthLog wrote them. */
+export function AppleHealthOwnRecordsNote({ count }: { count: number }) {
+ const { tCount } = useTranslations();
+ return (
+
+ {tCount(
+ "settings.sections.export.import.appleHealth.writtenByHealthLog",
+ count,
+ )}
+
+ );
+}
+
export function AppleHealthImportCard() {
const { t } = useTranslations();
const fileInputRef = useRef(null);
@@ -346,6 +366,7 @@ export function AppleHealthImportCard() {
const isDone = status?.status === "done";
const ownRecordsSkipped =
(status?.result?.writtenByHealthLog?.byMarker ?? 0) +
+ (status?.result?.writtenByHealthLog?.byExternalId ?? 0) +
(status?.result?.writtenByHealthLog?.matchedManual ?? 0);
const isFailed = status?.status === "failed";
const busy = uploading || isRunning;
@@ -498,17 +519,7 @@ export function AppleHealthImportCard() {
)}
{isDone && ownRecordsSkipped > 0 && (
-
- {t(
- "settings.sections.export.import.appleHealth.writtenByHealthLog",
- {
- count: ownRecordsSkipped,
- },
- )}
-
+
)}
{isDone && (status?.result?.cumulativeEstimates?.days ?? 0) > 0 && (
Date: Sun, 4 Oct 2026 14:49:07 +0200
Subject: [PATCH 7/8] test(import): answer the MANUAL-type lookup in the import
mocks
---
.../__tests__/import-apple-health-export.test.ts | 15 +++++++++++++++
.../__tests__/import-large-export-memory.test.ts | 1 +
2 files changed, 16 insertions(+)
diff --git a/src/lib/measurements/__tests__/import-apple-health-export.test.ts b/src/lib/measurements/__tests__/import-apple-health-export.test.ts
index e5f346f5b..1fc88cdc7 100644
--- a/src/lib/measurements/__tests__/import-apple-health-export.test.ts
+++ b/src/lib/measurements/__tests__/import-apple-health-export.test.ts
@@ -271,6 +271,21 @@ function makeFakePrisma(
}
return returned;
},
+ // The import reads once which types hold MANUAL rows.
+ groupBy: async ({
+ where,
+ }: {
+ where: { userId: string; source: string };
+ }) =>
+ [
+ ...new Set(
+ measurements
+ .filter(
+ (m) => m.userId === where.userId && m.source === where.source,
+ )
+ .map((m) => m.type),
+ ),
+ ].map((type) => ({ type })),
findMany: async ({
where,
}: {
diff --git a/src/lib/measurements/__tests__/import-large-export-memory.test.ts b/src/lib/measurements/__tests__/import-large-export-memory.test.ts
index e3c784041..04871867f 100644
--- a/src/lib/measurements/__tests__/import-large-export-memory.test.ts
+++ b/src/lib/measurements/__tests__/import-large-export-memory.test.ts
@@ -297,6 +297,7 @@ function makeDiscardingPrisma(sample: () => void) {
externalId: row.externalId,
}));
},
+ groupBy: async () => [],
findMany: async () => [],
findFirst: async () => null,
update: async () => {
From dc9b0e39365d563dd90372b6a2fde4a54a256017 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 4 Oct 2026 16:14:47 +0200
Subject: [PATCH 8/8] test(import): read the note's text without a
tag-stripping replace
---
.../apple-health-own-records-note.test.tsx | 19 ++++++++++++-------
1 file changed, 12 insertions(+), 7 deletions(-)
diff --git a/src/components/settings/import-panel/__tests__/apple-health-own-records-note.test.tsx b/src/components/settings/import-panel/__tests__/apple-health-own-records-note.test.tsx
index 9d18bd756..12b1c29c2 100644
--- a/src/components/settings/import-panel/__tests__/apple-health-own-records-note.test.tsx
+++ b/src/components/settings/import-panel/__tests__/apple-health-own-records-note.test.tsx
@@ -24,13 +24,18 @@ function bundle(locale: Locale): MessageBundle {
}
function render(locale: Locale, count: number): string {
- return renderToStaticMarkup(
-
-
- ,
- )
- .replace(/<[^>]+>/g, "")
- .trim();
+ return (
+ renderToStaticMarkup(
+
+
+ ,
+ )
+ // Text between the tags; a test reading its own static markup, not a
+ // sanitiser for untrusted input.
+ .split(/<[^>]*>/)
+ .join("")
+ .trim()
+ );
}
describe("", () => {