fix(withings): skip activity days Health Mate holds from another app

Getactivity marks a day that came from outside Withings with brand 18
("external"): an Apple Health or Google Fit day written into Health
Mate. Imported under WITHINGS it was a second copy of data HealthLog
reads from that app's own integration, and on a day it was the only
Withings row it could outrank a native source lower on the ladder.
Such days are now skipped at ingest and counted on the wide event.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 12:59:44 +02:00
1 parent c1921d1dd1
commit d02fba6509
2 files changed
+49

No files matched your search

@@ -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
+26
View File
@@ -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