mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Port the hard-fail ledger the Fitbit transport already carries. A revoked WHOOP grant makes every resource short-circuit and return zero rows without throwing, so the cycle read as clean: it stamped success, un-parked the reconnect state, reset the failure streak, and the hourly cohort hammered the dead refresh token on every tick. Record the three dead-token null-return paths on the ledger and fold that verdict into both drivers — the full orchestrator and the per-resource cron path that runs most often — so a dead-token cycle refuses the success stamp and the park holds. Return an imported-plus-failed verdict from the orchestrator and gate the history backfill on it: an interrupted full-history walk now throws for a pg-boss retry instead of being stamped complete, and a connection parked at reauth returns without burning the retry budget. Thread the same verdict into the one-shot sleep-timeline backfill. Also catch a write-path throw on both drivers so a persistent measurement-write failure surfaces on the status ledger instead of leaving a stale-green pill, and widen the cycle overlap to the seven-day re-score window the other collections use so a late-revised day's strain and energy are re-fetched rather than stepped past.
70 lines
2.3 KiB
TypeScript
70 lines
2.3 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,
|
|
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
|
type WhoopMeasurementUpsert,
|
|
} from "./sync-core";
|
|
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;
|
|
|
|
// WHOOP re-scores a cycle THROUGH the day (day strain + energy accrue and
|
|
// settle after the fact) and the collection filters on the cycle's own time
|
|
// range, not on when it updated — so a narrow 1 h overlap could step the
|
|
// cursor past a cycle still being revised and miss the final value. Ride the
|
|
// same 7-day re-score overlap workout/recovery/sleep already use; one cycle
|
|
// per day means ~7 records re-fetched per tick (one page), and the
|
|
// `cycle:<id>:<fieldTag>` upsert keeps the overwrite idempotent.
|
|
const start = incrementalStart(resolveResourceCursor(connection, "cycle"), {
|
|
fullSync: opts.fullSync,
|
|
overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
|
});
|
|
|
|
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;
|
|
}
|