diff --git a/src/lib/withings/__tests__/sync-activity.test.ts b/src/lib/withings/__tests__/sync-activity.test.ts index 7155da2f8..f4df49f8a 100644 --- a/src/lib/withings/__tests__/sync-activity.test.ts +++ b/src/lib/withings/__tests__/sync-activity.test.ts @@ -101,6 +101,7 @@ interface FakeActivity { steps?: number; distance?: number; calories?: number; + brand?: number; } function installFetchMock(entries: FakeActivity[]) { @@ -414,6 +415,28 @@ describe("syncUserActivity — field mapping + idempotency", () => { expect(types).not.toContain("WALKING_RUNNING_DISTANCE"); }); + it("skips a day Health Mate holds from another app (brand 18)", async () => { + // An Apple Health or Google Fit day written into Health Mate comes back + // from Getactivity as brand 18 ("external"). It reaches HealthLog from + // its own integration; under WITHINGS it was a second copy. + installFetchMock([ + { date: "2026-05-12", steps: 8000, calories: 380, brand: 1 }, + { date: "2026-05-13", steps: 9100, calories: 410, brand: 18 }, + ]); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.createMany).mockResolvedValue({ + count: 2, + } as never); + + await syncUserActivity("user-1"); + + const rows = createManyRows(); + expect(rows).toHaveLength(2); + expect(rows.map((r) => r.value)).toEqual( + expect.not.arrayContaining([9100, 410]), + ); + }); + it("ingests a 0-step rest day rather than dropping it", async () => { // Withings reports 0 steps for a day a user spent in bed — that's // a valid datapoint, not a missing one. The mapper must not skip diff --git a/src/lib/withings/sync-activity.ts b/src/lib/withings/sync-activity.ts index 1a075fec6..bd6425162 100644 --- a/src/lib/withings/sync-activity.ts +++ b/src/lib/withings/sync-activity.ts @@ -82,6 +82,9 @@ const WITHINGS_MEASURE_URL = "https://wbsapi.withings.net/v2/measure"; */ const ACTIVITY_BACKFILL_DAYS = 30; +/** `brand` of a Getactivity day that came from outside Withings. */ +const WITHINGS_EXTERNAL_BRAND = 18; + /** * Withings `getactivity` response shape — one entry per calendar day. * Only the fields we consume are typed; the response carries 15+ more @@ -95,6 +98,13 @@ export interface WithingsActivityEntry { steps?: number; distance?: number; // metres calories?: number; // active kcal (NOT totalcalories) + /** + * Where the day came from: 1 for a Withings device or the Health Mate + * app's own tracker, 18 for "an external way" (Getactivity: "Value is 1 + * for Withings and 18 for external"), i.e. data another app wrote into + * Health Mate, such as an Apple Health or Google Fit day. + */ + brand?: number; // The fields below are not ingested today (see file header): elevation?: number; totalcalories?: number; @@ -357,8 +367,18 @@ export async function syncUserActivity( externalId: string; } const collected: PlannedRow[] = []; + let external = 0; for (const entry of entries) { if (!entry.date) continue; + // A day Health Mate holds from another app is that app's data, which + // reaches HealthLog from its own integration (Apple Health, Health + // Connect, Google Health). Imported under WITHINGS it was a second copy + // under the wrong name, and on a day that copy was the only Withings row + // it could outrank a native source further down the ladder. + if (entry.brand === WITHINGS_EXTERNAL_BRAND) { + external++; + continue; + } // Local noon of the date in the user's zone: the instant reads the same // calendar day there. Noon UTC read as the next day from UTC+13 on // (Auckland in summer, Tonga, Samoa), so every daily total landed a day @@ -380,6 +400,12 @@ export async function syncUserActivity( } } + if (external > 0) { + getEvent()?.addWarning( + `withings activity: skipped ${external} day(s) Health Mate holds from another app`, + ); + } + // A daily-activity total the app's own band calls impossible (a step count // past 200 000, a negative distance) is a provider artefact, not a day. Drop // it before the plan is read against the table, so it can neither insert a