From 6a4d3fd55356f6bfe7e241c6cee11a0a39a64c5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 10:36:53 +0200 Subject: [PATCH] fix(whoop): widen workout ingest overlap to match recovery and sleep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WHOOP workout sync used the default 1 h incremental overlap while recovery and sleep use the wider re-score overlap. The activity collection filters on the workout's own time range, not on when it reached the cloud, so a workout whose phone sync lands more than an hour after its window fell permanently before the cursor and was never ingested — operator-visible data loss. The workout sync now reuses the recovery/sleep overlap; the externalId upsert keeps the re-fetch idempotent. --- .../__tests__/sync-workout-overlap.test.ts | 78 +++++++++++++++++++ src/lib/whoop/sync-workout.ts | 10 +++ 2 files changed, 88 insertions(+) create mode 100644 src/lib/whoop/__tests__/sync-workout-overlap.test.ts diff --git a/src/lib/whoop/__tests__/sync-workout-overlap.test.ts b/src/lib/whoop/__tests__/sync-workout-overlap.test.ts new file mode 100644 index 000000000..3462568e7 --- /dev/null +++ b/src/lib/whoop/__tests__/sync-workout-overlap.test.ts @@ -0,0 +1,78 @@ +/** + * v1.16.13 (iOS #17) — WHOOP workout ingest used only a 1 h overlap window + * while recovery/sleep use the wide re-score overlap. A workout whose phone + * sync to the WHOOP cloud lands more than 1 h after the workout's own time + * window then falls permanently before the incremental `start` and is never + * ingested. This pins the workout sync onto the SAME overlap recovery/sleep + * use, so a late-synced workout is still re-fetched (the upsert is idempotent). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const fetchWorkouts = vi.fn(); + +vi.mock("../client", () => ({ + fetchWorkouts: (...a: unknown[]) => fetchWorkouts(...a), + KJ_TO_KCAL: 0.239006, +})); + +const getValidToken = vi.fn(); +const markSynced = vi.fn(); +vi.mock("../sync", async () => { + const actual = await vi.importActual("../sync"); + return { + ...actual, + getValidToken: (...a: unknown[]) => getValidToken(...a), + markSynced: (...a: unknown[]) => markSynced(...a), + }; +}); + +const whoopConnectionFindUnique = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + whoopConnection: { + findUnique: (...a: unknown[]) => whoopConnectionFindUnique(...a), + }, + workout: { upsert: vi.fn() }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: vi.fn() }), +})); + +import { syncUserWorkout } from "../sync-workout"; +import { WHOOP_RECOVERY_SLEEP_OVERLAP_MS } from "../sync"; + +const LAST_SYNCED = new Date("2026-06-10T12:00:00.000Z"); + +beforeEach(() => { + vi.clearAllMocks(); + getValidToken.mockResolvedValue({ + accessToken: "tok", + connection: { id: "c1", whoopUserId: "w1" }, + }); + whoopConnectionFindUnique.mockResolvedValue({ lastSyncedAt: LAST_SYNCED }); + markSynced.mockResolvedValue(undefined); + fetchWorkouts.mockResolvedValue([]); +}); + +describe("syncUserWorkout — late-synced workout overlap window", () => { + it("fetches from lastSyncedAt minus the recovery/sleep overlap, not 1 h", async () => { + await syncUserWorkout("user-1"); + expect(fetchWorkouts).toHaveBeenCalledTimes(1); + const arg = fetchWorkouts.mock.calls[0]![1] as { start?: Date }; + expect(arg.start?.getTime()).toBe( + LAST_SYNCED.getTime() - WHOOP_RECOVERY_SLEEP_OVERLAP_MS, + ); + // A workout that landed in the WHOOP cloud 6 h after its window is still + // inside the fetch range; under the old 1 h overlap it would be missed. + const sixHoursBefore = LAST_SYNCED.getTime() - 6 * 60 * 60 * 1000; + expect(arg.start!.getTime()).toBeLessThanOrEqual(sixHoursBefore); + }); + + it("fullSync passes no start (deep backfill anchor handles history)", async () => { + await syncUserWorkout("user-1", { fullSync: true }); + const arg = fetchWorkouts.mock.calls[0]![1] as { start?: Date }; + expect(arg.start).toBeUndefined(); + }); +}); diff --git a/src/lib/whoop/sync-workout.ts b/src/lib/whoop/sync-workout.ts index c960b2124..8e46f8fe9 100644 --- a/src/lib/whoop/sync-workout.ts +++ b/src/lib/whoop/sync-workout.ts @@ -19,6 +19,7 @@ import { incrementalStart, handleCollectionFetchError, markSynced, + WHOOP_RECOVERY_SLEEP_OVERLAP_MS, } from "./sync"; import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; @@ -44,8 +45,17 @@ export async function syncUserWorkout( }); if (!connection) return 0; + // iOS #17 — the WHOOP `/activity/workout` collection filters on the + // workout's OWN time range, not on when it reached the cloud. A workout + // whose phone sync lands more than the overlap late then falls permanently + // before the cursor and is never ingested (operator-visible data loss). A + // 1 h overlap was far too tight for opportunistic phone sync; widen it to + // the same window recovery/sleep already use for their late re-scores. The + // `(userId, WHOOP, externalId)` upsert keeps the re-fetch idempotent and a + // handful of workouts/day keeps the page count down. const start = incrementalStart(connection.lastSyncedAt, { fullSync: opts.fullSync, + overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS, }); let records: Awaited>;