mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Replace the single shared last-synced cursor with a per-resource cursor map on the WHOOP connection. Recovery, sleep, workout, and cycle each advance only their own cursor on a successful fetch+upsert, so a slow or 403-ing collection never drags the incremental window forward for its siblings and its own late-arriving records stay inside the overlap. Legacy connections fall back to the shared last-synced instant, so no historical state is lost. Add single-record fetch-by-id client routes and per-resource by-id syncs. The update webhook now carries the resource id through the queue payload, so the worker resolves and upserts that one record immediately rather than re-walking the whole collection on the next overlap tick. Anchor a full sync at a fixed deep-history point instead of returning no lower bound, so a backfill covers the multi-year span an incremental tick would skip and a stale cursor cannot silently narrow it.
61 lines
1.7 KiB
TypeScript
61 lines
1.7 KiB
TypeScript
/**
|
|
* WHOOP cycle (day) sync. There is NO webhook for cycles — this is poll-only,
|
|
* driven by the hourly fallback cron. Maps each scored cycle via `mapCycle`
|
|
* (DAY_STRAIN + ENERGY_EXPENDITURE_KJ) and upserts as `source = WHOOP`.
|
|
*
|
|
* Cycle id is an int64 (not a UUID); externalId = `cycle:<id>:<fieldTag>`.
|
|
*/
|
|
import { fetchCycles, mapCycle } from "./client";
|
|
import {
|
|
getValidToken,
|
|
incrementalStart,
|
|
handleCollectionFetchError,
|
|
markResourceSynced,
|
|
resolveResourceCursor,
|
|
upsertWhoopMeasurements,
|
|
type WhoopMeasurementUpsert,
|
|
} from "./sync";
|
|
import { prisma } from "@/lib/db";
|
|
|
|
export async function syncUserCycle(
|
|
userId: string,
|
|
opts: { fullSync?: boolean } = {},
|
|
): Promise<number> {
|
|
const tokenInfo = await getValidToken(userId);
|
|
if (!tokenInfo) return 0;
|
|
|
|
const connection = await prisma.whoopConnection.findUnique({
|
|
where: { userId },
|
|
select: { lastSyncedAt: true, resourceCursors: true },
|
|
});
|
|
if (!connection) return 0;
|
|
|
|
const start = incrementalStart(resolveResourceCursor(connection, "cycle"), {
|
|
fullSync: opts.fullSync,
|
|
});
|
|
|
|
let records: Awaited<ReturnType<typeof fetchCycles>>;
|
|
try {
|
|
records = await fetchCycles(tokenInfo.accessToken, { start });
|
|
} catch (err) {
|
|
return handleCollectionFetchError("cycle", userId, err);
|
|
}
|
|
|
|
const readings: WhoopMeasurementUpsert[] = [];
|
|
for (const c of records) {
|
|
for (const m of mapCycle(c)) {
|
|
readings.push({
|
|
type: m.type,
|
|
value: m.value,
|
|
unit: m.unit,
|
|
measuredAt: m.measuredAt,
|
|
externalId: `cycle:${c.id}:${m.fieldTag}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const imported = await upsertWhoopMeasurements(userId, readings);
|
|
await markResourceSynced(userId, "cycle");
|
|
return imported;
|
|
}
|