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);