fix(whoop): widen workout ingest overlap to match recovery and sleep

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 10:36:53 +02:00
1 parent c21338df45
commit 6a4d3fd553
2 files changed
+88

No files matched your search

@@ -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<typeof import("../sync")>("../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();
});
});
+10
View File
@@ -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<ReturnType<typeof fetchWorkouts>>;