mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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.
113 lines
3.8 KiB
TypeScript
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);
|
|
}
|