mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user