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]); + }); });