mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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>>;
|
||||
|
||||
Reference in new issue
Block a user