fix(whoop): the profile weight stops posing as a measurement

WHOOP's body endpoint carries a self-reported profile weight, not a
reading — the overwrite-in-place row took a fresh measuredAt on every
sync, so a stale manual entry kept resurfacing as the newest weight no
matter what the scale said. The sync no longer ingests it and clears
the legacy row off existing accounts on its next pass.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 22:34:13 +02:00
1 parent e9f776937d
commit e122b50477
2 files changed
+53 -50

No files matched your search

+23 -26
View File
@@ -23,6 +23,11 @@ const {
whoopConnection: {
update: vi.fn<(...a: unknown[]) => Promise<unknown>>(async () => ({})),
},
measurement: {
deleteMany: vi.fn<(...a: unknown[]) => Promise<unknown>>(async () => ({
count: 0,
})),
},
user: {
findUnique: vi.fn(),
update: vi.fn<(...a: unknown[]) => Promise<unknown>>(async () => ({})),
@@ -111,40 +116,32 @@ describe("mapBody", () => {
});
});
describe("syncUserBody — weight overwrite", () => {
it("upserts weight against the stable externalId (no duplicate per sync)", async () => {
describe("syncUserBody — profile weight is not ingested (v1.16.11)", () => {
it("never upserts a weight Measurement, even when WHOOP sends one", async () => {
fetchBodyMeasurementMock.mockResolvedValue({ weight_kilogram: 80 });
prismaMock.user.findUnique.mockResolvedValue({ heightCm: 170 });
const imported = await syncUserBody("user1");
expect(imported).toBe(1);
expect(upsertWhoopMeasurementsMock).toHaveBeenCalledTimes(1);
const readings = upsertWhoopMeasurementsMock.mock
.calls[0]![1] as WhoopMeasurementUpsert[];
expect(readings).toHaveLength(1);
expect(readings[0]).toMatchObject({
type: "WEIGHT",
value: 80,
unit: "kg",
externalId: WHOOP_BODY_WEIGHT_EXTERNAL_ID,
});
// A second sync re-uses the SAME externalId — the upsert collapses it.
await syncUserBody("user1");
const readings2 = upsertWhoopMeasurementsMock.mock
.calls[1]![1] as WhoopMeasurementUpsert[];
expect(readings2[0]!.externalId).toBe(readings[0]!.externalId);
});
it("does not upsert a weight row when WHOOP omits weight", async () => {
fetchBodyMeasurementMock.mockResolvedValue({ max_heart_rate: 185 });
prismaMock.user.findUnique.mockResolvedValue({ heightCm: 170 });
const imported = await syncUserBody("user1");
expect(imported).toBe(0);
expect(upsertWhoopMeasurementsMock).not.toHaveBeenCalled();
});
it("clears the legacy overwrite row on every pass (idempotent no-op after the first)", async () => {
fetchBodyMeasurementMock.mockResolvedValue({ weight_kilogram: 80 });
prismaMock.user.findUnique.mockResolvedValue({ heightCm: 170 });
await syncUserBody("user1");
expect(prismaMock.measurement.deleteMany).toHaveBeenCalledWith({
where: {
userId: "user1",
type: "WEIGHT",
source: "WHOOP",
externalId: WHOOP_BODY_WEIGHT_EXTERNAL_ID,
},
});
});
});
describe("syncUserBody — max heart rate", () => {
+30 -24
View File
@@ -1,21 +1,21 @@
/**
* WHOOP body-measurement sync. The body endpoint is a single object (not a
* paginated collection): one self-reported profile snapshot carrying weight,
* max heart rate, and height. It fans out to three destinations:
* max heart rate, and height. Two destinations remain:
*
* - `weight_kilogram` → a `WEIGHT` `Measurement` (source = WHOOP) keyed on a
* STABLE externalId (`whoop:body:weight`). Because the profile weight is a
* single value rather than a time series, the externalId never carries the
* fetch time — a re-sync overwrites the same row in place rather than
* accumulating a duplicate per poll. `measuredAt` is the fetch time so the
* read-time source-priority picker (a real scale outranks WHOOP) and the
* trend view treat it as "as of now".
* - `max_heart_rate` → `WhoopConnection.maxHeartRate` (a profile constant,
* not a time series — lives on the connection row, not a `Measurement`).
* - `height_meter` → `User.heightCm`, converted m→cm, written ONLY when the
* user has no height yet. A user-set height is never overwritten, and
* height is never minted as a `Measurement`.
*
* `weight_kilogram` is deliberately NOT ingested (v1.16.11). It is a
* self-reported profile field, not a reading — the previous overwrite-in-
* place row took a fresh `measuredAt` on every sync, so a stale manual
* entry kept resurfacing as the newest weight "measurement" no matter
* what the scale said. The sync now also removes the legacy
* `whoop:body:weight` row once, so existing accounts heal themselves.
*
* Every write is field-by-field and idempotent across reruns. A per-resource
* 403 soft-skips this data class (returns 0, leaves the connection connected)
* rather than parking the whole integration — see `isCollectionForbidden`.
@@ -25,13 +25,14 @@ import {
getValidToken,
handleCollectionFetchError,
markSynced,
upsertWhoopMeasurements,
type WhoopMeasurementUpsert,
} from "./sync";
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
/** Stable externalId for the single WHOOP profile weight row (overwrite). */
/**
* Stable externalId the retired profile-weight ingestion wrote under —
* kept only so the sync can clean the legacy row off existing accounts.
*/
export const WHOOP_BODY_WEIGHT_EXTERNAL_ID = "whoop:body:weight";
/**
@@ -57,19 +58,23 @@ export async function syncUserBody(
}
const mapped = mapBody(body);
const measuredAt = new Date();
// Weight → a single overwrite-in-place WEIGHT Measurement.
let imported = 0;
if (mapped.weightKg !== null) {
const reading: WhoopMeasurementUpsert = {
type: "WEIGHT",
value: mapped.weightKg,
unit: "kg",
measuredAt,
externalId: WHOOP_BODY_WEIGHT_EXTERNAL_ID,
};
imported += await upsertWhoopMeasurements(userId, [reading]);
// Profile weight is NOT ingested (see the module header). Remove the
// legacy overwrite row once; deleteMany is a no-op after the first
// pass, keeping the sync idempotent.
try {
await prisma.measurement.deleteMany({
where: {
userId,
type: "WEIGHT",
source: "WHOOP",
externalId: WHOOP_BODY_WEIGHT_EXTERNAL_ID,
},
});
} catch (err) {
getEvent()?.addWarning(
`whoop: failed to clear the legacy profile-weight row for ${userId}: ${err}`,
);
}
// Max heart rate → WhoopConnection.maxHeartRate (profile constant).
@@ -108,5 +113,6 @@ export async function syncUserBody(
}
await markSynced(userId);
return imported;
// No Measurement rows are minted here any more.
return 0;
}