From b2bb81cac3c8a1f60b93112d0f3a1f8562c5de33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 15:59:07 +0200 Subject: [PATCH] fix(apple-health): keep one source per device across an OS update in the export import The export importer keeps the largest source subtotal per day for step-like totals, and it told sources apart by name, version and device. On the day a phone or watch updates, its records carry two versions, so the one device became two sources and the day kept only the larger half. The identity is now the source and its device without any version. --- .../import-apple-health-export.test.ts | 26 +++++++++++++++++++ .../import-apple-health-export.ts | 19 +++++++++++--- 2 files changed, 41 insertions(+), 4 deletions(-) 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 17aa4b3c4..e5f346f5b 100644 --- a/src/lib/measurements/__tests__/import-apple-health-export.test.ts +++ b/src/lib/measurements/__tests__/import-apple-health-export.test.ts @@ -722,6 +722,32 @@ describe("streamParseExportXml — cumulative source-day estimates", () => { }); }, ); + it("counts a device's whole day across an OS update", async () => { + // The phone updated mid-morning: its records carry two sourceVersion + // values and two software fields. They are still one source, so the day + // is 6,000 + 3,000, not the larger half. + const { prisma } = await importCumulativeFixture(` + + + + +`); + + expect(prisma._measurements).toHaveLength(1); + expect(prisma._measurements[0]).toMatchObject({ value: 9_000 }); + }); + it("folds unattributed cumulative records into one bounded source bucket", async () => { const { prisma } = await importCumulativeFixture(` diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts index d76027feb..37b5b0c35 100644 --- a/src/lib/measurements/import-apple-health-export.ts +++ b/src/lib/measurements/import-apple-health-export.ts @@ -233,8 +233,17 @@ const UNATTRIBUTED_CUMULATIVE_SOURCE_HASH = createHash("sha256") */ const HK_DEVICE_RUNTIME_ADDRESS = /0x[0-9a-f]+/gi; +/** + * The device's `software:` field is its OS version, which changes when the + * device updates. Like `sourceVersion`, it names the same device twice on an + * update day, so it is dropped from the identity too. + */ +const HK_DEVICE_SOFTWARE_VERSION = /software:[^,>]*/gi; + function stableDeviceIdentity(device: string | undefined): string | undefined { - return device?.replace(HK_DEVICE_RUNTIME_ADDRESS, "0x0"); + return device + ?.replace(HK_DEVICE_RUNTIME_ADDRESS, "0x0") + .replace(HK_DEVICE_SOFTWARE_VERSION, "software:"); } /** @@ -242,15 +251,18 @@ function stableDeviceIdentity(device: string | undefined): string | undefined { * separate. Records without source metadata share one stable bucket so parser * state remains bounded by actual source cardinality rather than record count. * Raw source/device labels never leave the parser. + * + * The identity is the source and its device, never a version. On the day a + * phone or watch updates, its records carry two `sourceVersion` values; keyed + * on the version, the one device became two sources, and the per-day pick + * (the largest source subtotal) kept only the larger half of that day. */ export function hashCumulativeSourceIdentity( sourceName: string | undefined, - sourceVersion: string | undefined, device: string | undefined, ): string { const sourceTuple = [ sourceName?.trim() ?? "", - sourceVersion?.trim() ?? "", stableDeviceIdentity(device)?.trim() ?? "", ]; if (!sourceTuple.some(Boolean)) { @@ -899,7 +911,6 @@ export async function streamParseExportXml( const dayKey = dayKeyForUserTz(mapped.takenAt, userTimezone); const sourceHash = hashCumulativeSourceIdentity( attrs.sourceName, - attrs.sourceVersion, attrs.device, ); let byDay = cumulativeBucket.get(mapped.type);