diff --git a/messages/de.json b/messages/de.json index 918099baa..9cac7e377 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5375,6 +5375,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.", + "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 2f6701fa2..a8536f6cb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5375,6 +5375,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.", + "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 404835a94..b4adff747 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5375,6 +5375,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.", + "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 578c24fbd..7298611d5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5375,6 +5375,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é.", + "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 71810d070..37e4e35d7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5375,6 +5375,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}.", + "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 8b08de039..0394f6811 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5375,6 +5375,9 @@ "rowsImported": "지금까지 {count}개 행을 불러왔어요", "doneSummary": "완료 — {read}개 중 {imported}개 기록을 불러왔고, 진료 기록 {skipped}개는 건너뛰었어요.", "doneNothing": "그 보관 파일에서 불러온 게 없어요: {read}개 중 0개만 들어왔어요.", + "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 1b06b419f..8dc68652a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5375,6 +5375,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.", + "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..12b1c29c2 --- /dev/null +++ b/src/components/settings/import-panel/__tests__/apple-health-own-records-note.test.tsx @@ -0,0 +1,67 @@ +/** + * 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( + + + , + ) + // Text between the tags; a test reading its own static markup, not a + // sanitiser for untrusted input. + .split(/<[^>]*>/) + .join("") + .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 344299c81..fa486dc3c 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,12 @@ 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; + 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, @@ -247,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); @@ -342,6 +364,10 @@ 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?.byExternalId ?? 0) + + (status?.result?.writtenByHealthLog?.matchedManual ?? 0); const isFailed = status?.status === "failed"; const busy = uploading || isRunning; const percent = status?.progress?.percent ?? null; @@ -492,6 +518,9 @@ export function AppleHealthImportCard() { })}

)} + {isDone && ownRecordsSkipped > 0 && ( + + )} {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/__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 () => { diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts index 37b5b0c35..dc280d285 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,19 @@ 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, 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; + 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 @@ -163,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; @@ -405,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. @@ -412,6 +455,31 @@ 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: 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: PendingRecord[]; + } | null = null; // Spot-row batch awaiting flush. const spotBatch: PreparedMeasurement[] = []; // Workout-row batch awaiting flush. @@ -468,9 +536,134 @@ export async function streamParseExportXml( }; // ── Flush helpers ────────────────────────────────────────── + // 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: [...windows].map(([type, window]) => ({ + type, + measuredAt: { + 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 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, + }, + list[i], + ); + if (mirrored) { + countOwn("matchedManual", row.externalId); + return false; + } + } + return true; + }); + }; + 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( + await withoutHealthLogIds(incoming), + ); + for (const row of incoming) healthLogIdOf.delete(row); for (const row of chunk) widenSpan(row.measuredAt); const insertedArrivals: Array<{ id: string; @@ -847,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; } @@ -907,46 +1111,61 @@ 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, + 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( + 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, + ); + const row: PreparedMeasurement = { + userId, + type: mapped.type, + value: mapped.value, + unit: mapped.unit, + measuredAt: mapped.takenAt, + externalId, + externalSourceVersion: attrs.sourceVersion ?? null, + sleepStage: mapped.sleepStage ?? null, + deviceType: null, + }; + spotBatch.push(row); + if (healthLogId) healthLogIdOf.set(row, healthLogId); + } + }, + }; return; } @@ -1024,7 +1243,21 @@ export async function streamParseExportXml( return; } + if (name === "Correlation") { + currentCorrelation = { ownOrigin: false, held: [] }; + return; + } + if (name === "MetadataEntry") { + 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. @@ -1051,16 +1284,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; } @@ -1070,6 +1302,24 @@ export async function streamParseExportXml( }; parser.onclosetag = async (tagName) => { + if (tagName === "Record" && pendingRecord) { + const rec = pendingRecord; + pendingRecord = null; + if (currentCorrelation) currentCorrelation.held.push(rec); + 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) { + countOwn("byMarker", rec.sampleKey()); + } else { + rec.commit(rec.healthLogId); + } + } + } if (tagName === "Workout" && currentWorkout) { workoutBatch.push(currentWorkout); currentWorkout = null; @@ -1077,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, @@ -1212,6 +1467,7 @@ export async function streamParseExportXml( perType, workouts, clinical, + writtenByHealthLog, cycle, deferred, unknown, 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 new file mode 100644 index 000000000..a24b663de --- /dev/null +++ b/tests/integration/apple-health-import-own-samples.test.ts @@ -0,0 +1,572 @@ +/** + * 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 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 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"; +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, + byExternalId: 0, + 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); + }); +}); + +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 + // 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, + byExternalId: 0, + matchedManual: 0, + }); + }); +}); + +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, + byExternalId: 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); + }); +}); + +/** + * 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); + }); +});