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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 15:59:07 +02:00
1 parent 34f847f12f
commit b2bb81cac3
2 files changed
+41 -4

No files matched your search

@@ -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(`<?xml version="1.0"?>
<HealthData locale="en_US">
<Record type="HKQuantityTypeIdentifierStepCount" unit="count"
startDate="2026-05-14 08:00:00 +0200"
endDate="2026-05-14 08:30:00 +0200" value="6000"
sourceName="iPhone" sourceVersion="17.4.1"
device="&lt;&lt;HKDevice: 0x283a08640&gt;, name:iPhone, hardware:iPhone14,2, software:17.4.1&gt;"/>
<Record type="HKQuantityTypeIdentifierStepCount" unit="count"
startDate="2026-05-14 15:00:00 +0200"
endDate="2026-05-14 15:30:00 +0200" value="3000"
sourceName="iPhone" sourceVersion="17.5"
device="&lt;&lt;HKDevice: 0x283a09999&gt;, name:iPhone, hardware:iPhone14,2, software:17.5&gt;"/>
<Record type="HKQuantityTypeIdentifierStepCount" unit="count"
startDate="2026-05-14 09:00:00 +0200"
endDate="2026-05-14 09:30:00 +0200" value="7000"
sourceName="Zepp" sourceVersion="1.0"/>
</HealthData>`);
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(`<?xml version="1.0"?>
<HealthData locale="en_US">
@@ -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);