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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 16:25:26 +02:00
1 parent bfb8711f1d
commit a04a8352f5
4 files changed
+149 -10

No files matched your search

@@ -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
@@ -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<PrismaClient, "$queryRaw"> = defaultPrisma,
): Promise<Set<string>> {
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<Array<{ day: string }>>`
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)));
}
+21 -7
View File
@@ -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`,
);
}
@@ -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]);
});
});