From e122b50477a0c8c7af2799250da3c563c4a39ba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 22:34:13 +0200 Subject: [PATCH] fix(whoop): the profile weight stops posing as a measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/lib/whoop/__tests__/sync-body.test.ts | 49 ++++++++++---------- src/lib/whoop/sync-body.ts | 54 +++++++++++++---------- 2 files changed, 53 insertions(+), 50 deletions(-) diff --git a/src/lib/whoop/__tests__/sync-body.test.ts b/src/lib/whoop/__tests__/sync-body.test.ts index a3376b757..4613b0cd4 100644 --- a/src/lib/whoop/__tests__/sync-body.test.ts +++ b/src/lib/whoop/__tests__/sync-body.test.ts @@ -23,6 +23,11 @@ const { whoopConnection: { update: vi.fn<(...a: unknown[]) => Promise>(async () => ({})), }, + measurement: { + deleteMany: vi.fn<(...a: unknown[]) => Promise>(async () => ({ + count: 0, + })), + }, user: { findUnique: vi.fn(), update: vi.fn<(...a: unknown[]) => Promise>(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", () => { diff --git a/src/lib/whoop/sync-body.ts b/src/lib/whoop/sync-body.ts index ece003b60..9bc1af7d9 100644 --- a/src/lib/whoop/sync-body.ts +++ b/src/lib/whoop/sync-body.ts @@ -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; }