From a04a8352f5692651108f0fe46ca9c80c9fc11df1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 16:25:26 +0200 Subject: [PATCH] fix(withings): keep a brand-18 activity day no other source delivered Skipping every day Health Mate holds from another app (brand 18) lost the steps of an account whose phone steps only Health Mate reads. Such a day is now skipped only when another connected source (not Withings, not a hand entry) has steps on that local day; otherwise it is kept. --- .../withings/__tests__/sync-activity.test.ts | 43 +++++++++++++++-- src/lib/withings/other-step-source-days.ts | 47 +++++++++++++++++++ src/lib/withings/sync-activity.ts | 28 ++++++++--- .../withings-activity-sync.test.ts | 41 ++++++++++++++++ 4 files changed, 149 insertions(+), 10 deletions(-) create mode 100644 src/lib/withings/other-step-source-days.ts diff --git a/src/lib/withings/__tests__/sync-activity.test.ts b/src/lib/withings/__tests__/sync-activity.test.ts index f4df49f8a..b5c0cecc1 100644 --- a/src/lib/withings/__tests__/sync-activity.test.ts +++ b/src/lib/withings/__tests__/sync-activity.test.ts @@ -32,6 +32,12 @@ vi.mock("@/lib/db", () => { }; }); +// Which brand-18 days another connected source already covers; each test +// that needs one sets it. Its SQL is covered by the integration suite. +vi.mock("@/lib/withings/other-step-source-days", () => ({ + daysWithOtherStepSource: vi.fn().mockResolvedValue(new Set()), +})); + vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: vi.fn(), })); @@ -85,6 +91,7 @@ vi.mock("@/lib/rollups/measurement-rollups", async () => { }; }); +import { daysWithOtherStepSource } from "@/lib/withings/other-step-source-days"; import { prisma } from "@/lib/db"; import { parkIntegrationAtReauth, @@ -415,14 +422,17 @@ 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 () => { + it("skips a day Health Mate holds from another app that syncs its own steps (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. + // from Getactivity as brand 18 ("external"). When that app reaches + // HealthLog from its own integration, under WITHINGS it is 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(daysWithOtherStepSource).mockResolvedValueOnce( + new Set(["2026-05-13"]), + ); vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 2, @@ -430,6 +440,11 @@ describe("syncUserActivity — field mapping + idempotency", () => { await syncUserActivity("user-1"); + expect(vi.mocked(daysWithOtherStepSource)).toHaveBeenCalledWith( + "user-1", + ["2026-05-13"], + expect.any(String), + ); const rows = createManyRows(); expect(rows).toHaveLength(2); expect(rows.map((r) => r.value)).toEqual( @@ -437,6 +452,28 @@ describe("syncUserActivity — field mapping + idempotency", () => { ); }); + it("keeps a brand-18 day when no other source delivered steps that day", async () => { + // A phone whose steps only Health Mate reads: the external day is the + // account's only copy, so skipping it would lose the day. + installFetchMock([ + { date: "2026-05-12", steps: 8000, calories: 380, brand: 1 }, + { date: "2026-05-13", steps: 9100, calories: 410, brand: 18 }, + ]); + vi.mocked(daysWithOtherStepSource).mockResolvedValueOnce(new Set()); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.createMany).mockResolvedValue({ + count: 4, + } as never); + + await syncUserActivity("user-1"); + + const rows = createManyRows(); + expect(rows).toHaveLength(4); + expect(rows.map((r) => r.value)).toEqual( + expect.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/other-step-source-days.ts b/src/lib/withings/other-step-source-days.ts new file mode 100644 index 000000000..5a71da1aa --- /dev/null +++ b/src/lib/withings/other-step-source-days.ts @@ -0,0 +1,47 @@ +/** + * The days an account's steps already reach HealthLog from a source other + * than Withings. + * + * Getactivity marks a day Health Mate holds from another app with brand 18 + * ("external"). When that app also reaches HealthLog through its own + * integration (Apple Health, Health Connect, a wearable), the Withings copy is + * a duplicate under the wrong name and the sync skips it. When nothing else + * delivered steps that day, Health Mate is the account's only way in for + * them (a phone whose steps are read only by Health Mate), and skipping the + * day would lose it. This answers which of the two each day is. + * + * Decided per local day in the account's zone, from live rows of any + * connected source: a hand-entered step count is not a connected source and + * does not make the Withings day a duplicate. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { prisma as defaultPrisma } from "@/lib/db"; +import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; +import { shiftDateKey } from "@/lib/tz/format"; + +export async function daysWithOtherStepSource( + userId: string, + dateKeys: readonly string[], + timeZone: string, + db: Pick = defaultPrisma, +): Promise> { + if (dateKeys.length === 0) return new Set(); + const sorted = [...dateKeys].sort(); + // A day's local midnight lies within a day of its UTC midnight in every + // zone, so a one-day margin on each side bounds the index range. + const from = dayKeyAsUtcMidnight(shiftDateKey(sorted[0], -1)); + const to = dayKeyAsUtcMidnight(shiftDateKey(sorted[sorted.length - 1], 2)); + const rows = await db.$queryRaw>` + SELECT DISTINCT + to_char((m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}, 'YYYY-MM-DD') AS day + FROM "measurements" m + WHERE m."user_id" = ${userId} + AND m."type" = 'ACTIVITY_STEPS' + AND m."source" NOT IN ('WITHINGS', 'MANUAL') + AND m."deleted_at" IS NULL + AND m."measured_at" >= ${from} + AND m."measured_at" < ${to} + `; + const wanted = new Set(dateKeys); + return new Set(rows.map((r) => r.day).filter((day) => wanted.has(day))); +} diff --git a/src/lib/withings/sync-activity.ts b/src/lib/withings/sync-activity.ts index bd6425162..9fa6732c5 100644 --- a/src/lib/withings/sync-activity.ts +++ b/src/lib/withings/sync-activity.ts @@ -38,6 +38,7 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; import { shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { daysWithOtherStepSource } from "@/lib/withings/other-step-source-days"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; @@ -367,15 +368,28 @@ export async function syncUserActivity( externalId: string; } const collected: PlannedRow[] = []; + // A day Health Mate holds from another app is that app's data. When the + // app reaches HealthLog through its own integration (Apple Health, Health + // Connect, Google Health, a wearable), the WITHINGS import 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. When + // nothing else delivered steps that day, Health Mate is the account's only + // way in for them, so the day is kept. + const externalDates = entries + .filter((e) => e.date && e.brand === WITHINGS_EXTERNAL_BRAND) + .map((e) => e.date); + const duplicateDays = await daysWithOtherStepSource( + userId, + externalDates, + timeZone, + ); 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) { + if ( + entry.brand === WITHINGS_EXTERNAL_BRAND && + duplicateDays.has(entry.date) + ) { external++; continue; } @@ -402,7 +416,7 @@ export async function syncUserActivity( if (external > 0) { getEvent()?.addWarning( - `withings activity: skipped ${external} day(s) Health Mate holds from another app`, + `withings activity: skipped ${external} day(s) Health Mate holds from another app that syncs its own steps`, ); } diff --git a/tests/integration/withings-activity-sync.test.ts b/tests/integration/withings-activity-sync.test.ts index 8fe82eae4..9c4bad881 100644 --- a/tests/integration/withings-activity-sync.test.ts +++ b/tests/integration/withings-activity-sync.test.ts @@ -121,4 +121,45 @@ describe("syncUserActivity — integration", () => { const distance = rows.find((r) => r.type === "WALKING_RUNNING_DISTANCE"); expect(distance?.value).toBe(7200); }); + + it("skips a brand-18 day only when another connected source has steps that day", async () => { + const prisma = getPrismaClient(); + // Apple Health delivered the 11th; the 10th holds only a hand-entered + // count, which is not a connected source. + await prisma.measurement.createMany({ + data: [ + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + source: "APPLE_HEALTH", + value: 7300, + unit: "steps", + measuredAt: new Date("2026-05-11T10:00:00.000Z"), + externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-05-11", + }, + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + source: "MANUAL", + value: 5000, + unit: "steps", + measuredAt: new Date("2026-05-10T10:00:00.000Z"), + }, + ], + }); + stubWithingsActivity([ + { date: "2026-05-10", steps: 6100, calories: 300, brand: 18 }, + { date: "2026-05-11", steps: 7290, calories: 340, brand: 18 }, + ]); + + const { syncUserActivity } = await import("@/lib/withings/sync-activity"); + await syncUserActivity(TEST_USER_ID); + + const rows = await prisma.measurement.findMany({ + where: { userId: TEST_USER_ID, source: "WITHINGS" }, + orderBy: [{ measuredAt: "asc" }, { type: "asc" }], + select: { type: true, value: true }, + }); + expect(rows.map((r) => r.value).sort((a, b) => a - b)).toEqual([300, 6100]); + }); });