Files
HealthLog/src/lib/whoop/sync-sleep.ts
T
Marc-André Bombeck bc12633569 fix(sleep): reconstruct an ordered WHOOP hypnogram timeline
WHOOP's v2 API returns only per-stage sleep duration totals — no onset
timestamps, no hypnogram endpoint. The mapper stamped all five stage
totals on the one sleep-END instant, so the hypnogram reconstructed
every stage as a span touching the night's right edge: bars stacked
right-aligned with no clock times whenever WHOOP was the sole source.

Lay the asleep stages back-to-back from sleep onset in a fixed
physiological order (AWAKE lead, then CORE → DEEP → REM), emitting one
timed row per segment with measuredAt at that segment's end and a
distinct indexed externalId. IN_BED stays a single envelope row over the
whole window. The existing reader and renderer are untouched — they now
see distinct per-segment instants and place each block at its own time.

The order is synthesised, not measured, so every reconstructed row is
flagged and the night DTO carries a per-session `reconstructed` boolean
(GET /api/sleep/night, regenerated OpenAPI). The client renders the
hypnogram but labels it an approximate layout and never recomputes;
real-series sources (Apple Health, Withings, Fitbit) stay false.
2026-06-14 23:16:09 +02:00

113 lines
3.8 KiB
TypeScript

/**
* WHOOP sleep sync. Fetches sleep activity records since the incremental
* cursor (24 h overlap for the re-score lag), maps each scored record via
* `mapSleep` (per-stage SLEEP_DURATION rows, SLEEP_NEED, the SLEEP_*
* percentages, RESPIRATORY_RATE), and upserts as `source = WHOOP`.
*
* Sleep rows carry the `sleepStage` axis so the night's rows stay distinct
* under the dedup key. WHOOP exposes only per-stage DURATION totals (no onset
* timestamps), so `mapSleep` RECONSTRUCTS an ordered, contiguous per-segment
* timeline (CORE/DEEP/REM/AWAKE laid back-to-back from sleep onset) and flags
* those rows `reconstructed`; each reconstructed segment carries its own
* indexed externalId `<sleep_id>:seg:<tag>:<i>`. IN_BED stays a single
* envelope row keyed `<sleep_id>:sleep_in_bed`. The non-segment scores keep
* the `<sleep_id>:<fieldTag>` shape.
*/
import { fetchSleeps, fetchSleepById, mapSleep } from "./client";
import {
getValidToken,
incrementalStart,
handleCollectionFetchError,
markResourceSynced,
resolveResourceCursor,
upsertWhoopMeasurements,
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
type WhoopMeasurementUpsert,
} from "./sync";
import { prisma } from "@/lib/db";
export async function syncUserSleep(
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, "sleep"), {
fullSync: opts.fullSync,
overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
});
let records: Awaited<ReturnType<typeof fetchSleeps>>;
try {
records = await fetchSleeps(tokenInfo.accessToken, { start });
} catch (err) {
return handleCollectionFetchError("sleep", userId, err);
}
const readings: WhoopMeasurementUpsert[] = [];
for (const s of records) {
for (const m of mapSleep(s)) {
readings.push({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
// Reconstructed sleep segments carry their own indexed externalId so
// the several rows of one stage stay distinct; everything else keeps
// the `<sleep_id>:<fieldTag>` shape.
externalId: m.externalId ?? `${s.id}:${m.fieldTag}`,
sleepStage: m.sleepStage ?? null,
});
}
}
const imported = await upsertWhoopMeasurements(userId, readings);
await markResourceSynced(userId, "sleep");
return imported;
}
/**
* Webhook-driven targeted refresh: resolve ONE sleep activity by its uuid and
* upsert its readings, instead of re-walking the whole collection. An unscored
* / since-deleted record yields nothing. Does NOT advance the resource cursor
* (a single-id refresh proves nothing about the records between the cursor and
* now).
*/
export async function syncWhoopSleepById(
userId: string,
sleepId: string,
): Promise<number> {
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
let record: Awaited<ReturnType<typeof fetchSleepById>>;
try {
record = await fetchSleepById(tokenInfo.accessToken, sleepId);
} catch (err) {
return handleCollectionFetchError("sleep", userId, err);
}
const readings: WhoopMeasurementUpsert[] = [];
for (const m of mapSleep(record)) {
readings.push({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
// Reconstructed sleep segments carry their own indexed externalId
// (see syncUserSleep).
externalId: m.externalId ?? `${record.id}:${m.fieldTag}`,
sleepStage: m.sleepStage ?? null,
});
}
return upsertWhoopMeasurements(userId, readings);
}