mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Platform-wide hardening: the failure classes found live this week hunted down across every integration. Fitbit carried the same tombstone wedge fixed for Google — probe now matches soft-deleted rows and the update resurrects them; the comment describing a partial unique index that never existed is corrected. Sleep segment ids are stable across re-scoring for Withings (session+segment-start), WHOOP/Polar (one segment per stage, no positional index) and Oura (run-start keyed), each with a session-scoped sweep that soft-deletes rows a re-score orphaned — so legacy and affected nights heal as they are re-read. One platform rule for deleted rows: source-owned readings resurrect on re-import (Withings, WHOOP, Oura, Polar, Nightscout, mood webhook, CSV re-imports, Apple stats day-totals); Apple sample-grain deletions stick per the paired-client contract, and the mood bulk route's tombstone match is now a true no-op reported as duplicate. Import counts reconcile against actual createMany counts. Google lifecycle: a dead token registers on the cycle's hard-fail ledger, so an expired connection parks instead of stamping success hourly; backfill and sleep-repair markers stamp only after a clean pass (pg-boss retries are now real); failed measurement writes hold the watermark; no-op updates are skipped; mapping.md tells the current truth. Scale: doctor-report reads only consumed columns and enabled sections; exports and backups keyset-paginate; achievements tally via SQL buckets; series charts day-bucket dense kinds beyond 90 days; BP pairing is a linear two-pointer; distinct-type scans run server-side; the graded-series cold fallback is bounded; the fitbit defer tracker loops instead of spreading.
894 lines
27 KiB
TypeScript
894 lines
27 KiB
TypeScript
/**
|
|
* WHOOP API v2 client for OAuth and data fetching.
|
|
* Docs: https://developer.whoop.com (re-verify at build — the space moves).
|
|
*
|
|
* Mirrors the Withings client structure (`src/lib/withings/client.ts`):
|
|
* hand-rolled fetch over `safeFetch` (no SDK), an OAuth handshake
|
|
* (`getAuthorizationUrl` / `exchangeCode` / `refreshAccessToken`), typed
|
|
* collection fetchers with `next_token` pagination, and a single
|
|
* source-of-truth field→Measurement mapping (`WHOOP_FIELD_MAP`, kept in sync
|
|
* with `src/lib/whoop/mapping.md`).
|
|
*
|
|
* v2 only — v1 was removed 2025-10-01. Sleep / workout / recovery ids are
|
|
* UUID strings; cycle id is int64. Token TTL 3600 s; refresh tokens ROTATE
|
|
* (each refresh invalidates the prior access AND refresh token — the sync
|
|
* layer persists BOTH rotated tokens). The `offline` scope is required to
|
|
* receive a refresh token at all.
|
|
*/
|
|
import { getEvent } from "@/lib/logging/context";
|
|
import { safeFetch } from "@/lib/safe-fetch";
|
|
import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline";
|
|
import { WhoopApiError, classifyWhoopResponse } from "./response-classifier";
|
|
|
|
const WHOOP_API_BASE = "https://api.prod.whoop.com/developer";
|
|
const WHOOP_OAUTH_AUTH_URL = "https://api.prod.whoop.com/oauth/oauth2/auth";
|
|
const WHOOP_OAUTH_TOKEN_URL = "https://api.prod.whoop.com/oauth/oauth2/token";
|
|
|
|
/** WHOOP collection reads cap `limit` at 25. */
|
|
export const WHOOP_PAGE_LIMIT = 25;
|
|
|
|
export interface WhoopCredentials {
|
|
clientId: string;
|
|
clientSecret: string;
|
|
}
|
|
|
|
function getRedirectUri(): string {
|
|
// `||`, not `??`: the compose whitelist materialises the var as an empty
|
|
// string when unset, which must still fall through to the derived URI.
|
|
return (
|
|
process.env.WHOOP_REDIRECT_URI ||
|
|
`${process.env.NEXT_PUBLIC_APP_URL}/api/whoop/callback`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* OAuth scopes HealthLog requests (space-separated on the wire). `offline` is
|
|
* mandatory to receive a refresh token; the rest are read-only collection
|
|
* scopes. Request all so the user grants once and every sync resource is
|
|
* covered.
|
|
*/
|
|
export const WHOOP_OAUTH_SCOPE =
|
|
"offline read:recovery read:sleep read:workout read:cycles read:profile read:body_measurement" as const;
|
|
|
|
/**
|
|
* Generate the WHOOP OAuth authorization URL (a browser redirect, not a
|
|
* fetch). `state` is the opaque CSRF nonce minted by `oauth-state.ts`.
|
|
*/
|
|
export function getAuthorizationUrl(
|
|
state: string,
|
|
creds: WhoopCredentials,
|
|
): string {
|
|
const params = new URLSearchParams({
|
|
response_type: "code",
|
|
client_id: creds.clientId,
|
|
redirect_uri: getRedirectUri(),
|
|
scope: WHOOP_OAUTH_SCOPE,
|
|
state,
|
|
});
|
|
return `${WHOOP_OAUTH_AUTH_URL}?${params}`;
|
|
}
|
|
|
|
export interface WhoopTokenResponse {
|
|
access_token: string;
|
|
refresh_token: string;
|
|
expires_in: number;
|
|
scope?: string;
|
|
token_type?: string;
|
|
}
|
|
|
|
async function postToken(
|
|
params: URLSearchParams,
|
|
verb: string,
|
|
): Promise<WhoopTokenResponse> {
|
|
const start = performance.now();
|
|
const res = await safeFetch(WHOOP_OAUTH_TOKEN_URL, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: params.toString(),
|
|
});
|
|
|
|
const json = await res.json().catch(() => null);
|
|
const verdict = classifyWhoopResponse(res.status);
|
|
getEvent()?.addExternalCall({
|
|
service: "whoop",
|
|
method: verb,
|
|
duration_ms: Math.round(performance.now() - start),
|
|
status: res.status,
|
|
error: verdict.classification === "success" ? undefined : verdict.reason,
|
|
});
|
|
if (verdict.classification !== "success") {
|
|
throw new WhoopApiError({
|
|
verb,
|
|
classification: verdict.classification,
|
|
httpStatus: verdict.httpStatus,
|
|
reason: verdict.reason,
|
|
upstreamError: typeof json?.error === "string" ? json.error : undefined,
|
|
});
|
|
}
|
|
return json as WhoopTokenResponse;
|
|
}
|
|
|
|
/** Exchange an authorization code for the initial token pair. */
|
|
export async function exchangeCode(
|
|
code: string,
|
|
creds: WhoopCredentials,
|
|
): Promise<WhoopTokenResponse> {
|
|
return postToken(
|
|
new URLSearchParams({
|
|
grant_type: "authorization_code",
|
|
code,
|
|
client_id: creds.clientId,
|
|
client_secret: creds.clientSecret,
|
|
redirect_uri: getRedirectUri(),
|
|
}),
|
|
"exchangeCode",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Refresh an expired access token. WHOOP rotates the refresh token on every
|
|
* use — the caller MUST persist the returned `refresh_token`, not just the
|
|
* access token. `scope` must include `offline` (re-sent here per the WHOOP
|
|
* spec) for the rotation to return a fresh refresh token.
|
|
*/
|
|
export async function refreshAccessToken(
|
|
refreshToken: string,
|
|
creds: WhoopCredentials,
|
|
): Promise<WhoopTokenResponse> {
|
|
return postToken(
|
|
new URLSearchParams({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken,
|
|
client_id: creds.clientId,
|
|
client_secret: creds.clientSecret,
|
|
scope: "offline",
|
|
}),
|
|
"refreshAccessToken",
|
|
);
|
|
}
|
|
|
|
// ─── Collection envelopes (v2) ─────────────────────────────────
|
|
|
|
/** WHOOP paginated collection envelope: `{ records, next_token }`. */
|
|
export interface WhoopCollection<T> {
|
|
records: T[];
|
|
next_token?: string | null;
|
|
}
|
|
|
|
/** Recovery record. `score` is null until WHOOP finishes scoring. */
|
|
export interface WhoopRecovery {
|
|
cycle_id: number;
|
|
sleep_id: string;
|
|
user_id: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
score_state: string;
|
|
score: {
|
|
user_calibrating: boolean;
|
|
recovery_score: number;
|
|
resting_heart_rate: number;
|
|
hrv_rmssd_milli: number;
|
|
spo2_percentage?: number;
|
|
skin_temp_celsius?: number;
|
|
} | null;
|
|
}
|
|
|
|
/** Sleep activity record. */
|
|
export interface WhoopSleep {
|
|
id: string;
|
|
user_id: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
start: string;
|
|
end: string;
|
|
nap: boolean;
|
|
score_state: string;
|
|
score: {
|
|
stage_summary: {
|
|
total_in_bed_time_milli: number;
|
|
total_awake_time_milli: number;
|
|
total_light_sleep_time_milli: number;
|
|
total_slow_wave_sleep_time_milli: number;
|
|
total_rem_sleep_time_milli: number;
|
|
// v1.12.8 — per-night sleep disturbance tally. Optional: older / still-
|
|
// scoring records may omit it.
|
|
disturbance_count?: number;
|
|
};
|
|
sleep_needed: {
|
|
baseline_milli: number;
|
|
need_from_sleep_debt_milli: number;
|
|
need_from_recent_strain_milli: number;
|
|
need_from_recent_nap_milli: number;
|
|
};
|
|
respiratory_rate?: number;
|
|
sleep_performance_percentage?: number;
|
|
sleep_consistency_percentage?: number;
|
|
sleep_efficiency_percentage?: number;
|
|
} | null;
|
|
}
|
|
|
|
/** Physiological cycle (day) record. `id` is an int64. */
|
|
export interface WhoopCycle {
|
|
id: number;
|
|
user_id: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
start: string;
|
|
end?: string | null;
|
|
score_state: string;
|
|
score: {
|
|
strain: number;
|
|
kilojoule: number;
|
|
average_heart_rate: number;
|
|
max_heart_rate: number;
|
|
} | null;
|
|
}
|
|
|
|
/** Workout activity record. */
|
|
export interface WhoopWorkout {
|
|
id: string;
|
|
user_id: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
start: string;
|
|
end: string;
|
|
sport_id?: number;
|
|
sport_name?: string;
|
|
score_state: string;
|
|
score: {
|
|
strain: number;
|
|
average_heart_rate: number;
|
|
max_heart_rate: number;
|
|
kilojoule: number;
|
|
percent_recorded: number;
|
|
distance_meter?: number;
|
|
altitude_gain_meter?: number;
|
|
altitude_change_meter?: number;
|
|
zone_durations?: Record<string, number>;
|
|
} | null;
|
|
}
|
|
|
|
/** Body-measurement object (single, not paginated). */
|
|
export interface WhoopBodyMeasurement {
|
|
height_meter?: number;
|
|
weight_kilogram?: number;
|
|
max_heart_rate?: number;
|
|
}
|
|
|
|
/** Basic profile object (single, not paginated). */
|
|
export interface WhoopProfile {
|
|
user_id: number;
|
|
email?: string;
|
|
first_name?: string;
|
|
last_name?: string;
|
|
}
|
|
|
|
interface CollectionQuery {
|
|
start?: Date;
|
|
end?: Date;
|
|
/** Hard ceiling on pages walked (defence against a runaway cursor). */
|
|
maxPages?: number;
|
|
}
|
|
|
|
async function fetchCollection<T>(
|
|
path: string,
|
|
accessToken: string,
|
|
verb: string,
|
|
query: CollectionQuery = {},
|
|
): Promise<T[]> {
|
|
const records: T[] = [];
|
|
let nextToken: string | null | undefined;
|
|
let pageCount = 0;
|
|
const maxPages = query.maxPages ?? 1000;
|
|
|
|
do {
|
|
const params = new URLSearchParams({ limit: String(WHOOP_PAGE_LIMIT) });
|
|
if (query.start) params.set("start", query.start.toISOString());
|
|
if (query.end) params.set("end", query.end.toISOString());
|
|
if (nextToken) params.set("nextToken", nextToken);
|
|
|
|
const pageStart = performance.now();
|
|
const res = await safeFetch(`${WHOOP_API_BASE}${path}?${params}`, {
|
|
method: "GET",
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
});
|
|
|
|
const json = (await res
|
|
.json()
|
|
.catch(() => null)) as WhoopCollection<T> | null;
|
|
const verdict = classifyWhoopResponse(res.status);
|
|
getEvent()?.addExternalCall({
|
|
service: "whoop",
|
|
method: `${verb}(page=${pageCount})`,
|
|
duration_ms: Math.round(performance.now() - pageStart),
|
|
status: res.status,
|
|
error: verdict.classification === "success" ? undefined : verdict.reason,
|
|
});
|
|
if (verdict.classification !== "success") {
|
|
throw new WhoopApiError({
|
|
verb,
|
|
classification: verdict.classification,
|
|
httpStatus: verdict.httpStatus,
|
|
reason: verdict.reason,
|
|
});
|
|
}
|
|
|
|
for (const r of json?.records ?? []) records.push(r);
|
|
nextToken = json?.next_token ?? null;
|
|
pageCount += 1;
|
|
} while (nextToken && pageCount < maxPages);
|
|
|
|
return records;
|
|
}
|
|
|
|
async function fetchSingle<T>(
|
|
path: string,
|
|
accessToken: string,
|
|
verb: string,
|
|
): Promise<T> {
|
|
const start = performance.now();
|
|
const res = await safeFetch(`${WHOOP_API_BASE}${path}`, {
|
|
method: "GET",
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
});
|
|
const json = (await res.json().catch(() => null)) as T | null;
|
|
const verdict = classifyWhoopResponse(res.status);
|
|
getEvent()?.addExternalCall({
|
|
service: "whoop",
|
|
method: verb,
|
|
duration_ms: Math.round(performance.now() - start),
|
|
status: res.status,
|
|
error: verdict.classification === "success" ? undefined : verdict.reason,
|
|
});
|
|
if (verdict.classification !== "success") {
|
|
throw new WhoopApiError({
|
|
verb,
|
|
classification: verdict.classification,
|
|
httpStatus: verdict.httpStatus,
|
|
reason: verdict.reason,
|
|
});
|
|
}
|
|
return json as T;
|
|
}
|
|
|
|
export function fetchRecoveries(
|
|
accessToken: string,
|
|
query?: CollectionQuery,
|
|
): Promise<WhoopRecovery[]> {
|
|
return fetchCollection<WhoopRecovery>(
|
|
"/v2/recovery",
|
|
accessToken,
|
|
"fetchRecoveries",
|
|
query,
|
|
);
|
|
}
|
|
|
|
export function fetchSleeps(
|
|
accessToken: string,
|
|
query?: CollectionQuery,
|
|
): Promise<WhoopSleep[]> {
|
|
return fetchCollection<WhoopSleep>(
|
|
"/v2/activity/sleep",
|
|
accessToken,
|
|
"fetchSleeps",
|
|
query,
|
|
);
|
|
}
|
|
|
|
export function fetchCycles(
|
|
accessToken: string,
|
|
query?: CollectionQuery,
|
|
): Promise<WhoopCycle[]> {
|
|
return fetchCollection<WhoopCycle>(
|
|
"/v2/cycle",
|
|
accessToken,
|
|
"fetchCycles",
|
|
query,
|
|
);
|
|
}
|
|
|
|
export function fetchWorkouts(
|
|
accessToken: string,
|
|
query?: CollectionQuery,
|
|
): Promise<WhoopWorkout[]> {
|
|
return fetchCollection<WhoopWorkout>(
|
|
"/v2/activity/workout",
|
|
accessToken,
|
|
"fetchWorkouts",
|
|
query,
|
|
);
|
|
}
|
|
|
|
// ─── Single-record reads (webhook-driven fetch-by-id) ──────────
|
|
// A `*.updated` webhook carries only the resource id, not the data. Re-fetch
|
|
// that ONE record by id for a targeted refresh — far cheaper than re-walking
|
|
// the whole collection, and it lands the record immediately rather than on the
|
|
// next overlap window. WHOOP v2 single-record paths (re-verify at build):
|
|
// sleep GET /v2/activity/sleep/{sleepId}
|
|
// workout GET /v2/activity/workout/{workoutId}
|
|
// cycle GET /v2/cycle/{cycleId}
|
|
// recovery GET /v2/cycle/{cycleId}/recovery (no recovery-by-recovery-id)
|
|
// `fetchSingle` throws a `WhoopApiError` on a non-2xx (incl. a 404 for a since-
|
|
// deleted id) so the caller's existing classify/soft-skip path handles it.
|
|
|
|
export function fetchSleepById(
|
|
accessToken: string,
|
|
sleepId: string,
|
|
): Promise<WhoopSleep> {
|
|
return fetchSingle<WhoopSleep>(
|
|
`/v2/activity/sleep/${encodeURIComponent(sleepId)}`,
|
|
accessToken,
|
|
"fetchSleepById",
|
|
);
|
|
}
|
|
|
|
export function fetchWorkoutById(
|
|
accessToken: string,
|
|
workoutId: string,
|
|
): Promise<WhoopWorkout> {
|
|
return fetchSingle<WhoopWorkout>(
|
|
`/v2/activity/workout/${encodeURIComponent(workoutId)}`,
|
|
accessToken,
|
|
"fetchWorkoutById",
|
|
);
|
|
}
|
|
|
|
export function fetchCycleById(
|
|
accessToken: string,
|
|
cycleId: string,
|
|
): Promise<WhoopCycle> {
|
|
return fetchSingle<WhoopCycle>(
|
|
`/v2/cycle/${encodeURIComponent(cycleId)}`,
|
|
accessToken,
|
|
"fetchCycleById",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* WHOOP v2 has no recovery-by-recovery-id route — a recovery is read through
|
|
* its cycle (`GET /v2/cycle/{cycleId}/recovery`). The recovery webhook carries
|
|
* the cycle id, so the targeted refresh resolves the recovery from there.
|
|
*/
|
|
export function fetchRecoveryByCycleId(
|
|
accessToken: string,
|
|
cycleId: string,
|
|
): Promise<WhoopRecovery> {
|
|
return fetchSingle<WhoopRecovery>(
|
|
`/v2/cycle/${encodeURIComponent(cycleId)}/recovery`,
|
|
accessToken,
|
|
"fetchRecoveryByCycleId",
|
|
);
|
|
}
|
|
|
|
export function fetchBodyMeasurement(
|
|
accessToken: string,
|
|
): Promise<WhoopBodyMeasurement> {
|
|
return fetchSingle<WhoopBodyMeasurement>(
|
|
"/v2/user/measurement/body",
|
|
accessToken,
|
|
"fetchBodyMeasurement",
|
|
);
|
|
}
|
|
|
|
export function fetchProfile(accessToken: string): Promise<WhoopProfile> {
|
|
return fetchSingle<WhoopProfile>(
|
|
"/v2/user/profile/basic",
|
|
accessToken,
|
|
"fetchProfile",
|
|
);
|
|
}
|
|
|
|
// ─── Field → Measurement mapping ───────────────────────────────
|
|
// The single source of truth is `src/lib/whoop/mapping.md` — keep both in
|
|
// sync when adding entries.
|
|
|
|
/** Milliseconds → minutes. */
|
|
const MS_TO_MIN = 1 / 60_000;
|
|
/** Kilojoules → kilocalories (workout energy). */
|
|
export const KJ_TO_KCAL = 1 / 4.184;
|
|
|
|
/**
|
|
* A single mapped reading destined for one `Measurement` row. The `source`
|
|
* (`WHOOP`) and `externalId` (`<resource-uuid>:<fieldTag>`) are stamped by the
|
|
* sync layer (W3); the mapper only emits the type/value/unit/measuredAt and
|
|
* the field-tag that disambiguates the several rows derived from one resource.
|
|
*/
|
|
export interface MappedMeasurement {
|
|
type: string;
|
|
value: number;
|
|
unit: string;
|
|
measuredAt: Date;
|
|
/** Disambiguator appended to the resource uuid to form the externalId. */
|
|
fieldTag: string;
|
|
/** Per-stage sleep rows carry the SleepStage; everything else omits it. */
|
|
sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED";
|
|
/**
|
|
* Full externalId for rows the mapper must key itself rather than letting
|
|
* the sync layer build `<resource-uuid>:<fieldTag>`. The reconstructed sleep
|
|
* segments (one row per synthetic span) need an index in the key so the
|
|
* several rows of one stage stay distinct under
|
|
* `userId_type_source_externalId`. When set the sync layer uses it verbatim.
|
|
*/
|
|
externalId?: string;
|
|
/**
|
|
* `true` on the synthesised per-segment sleep rows whose ORDER is invented:
|
|
* WHOOP's v2 API returns only per-stage duration totals (no onset
|
|
* timestamps), so the timeline is reconstructed in a fixed physiological
|
|
* order. The night is flagged so the UI labels it as an approximate layout
|
|
* and never presents it as measured stage timing.
|
|
*/
|
|
reconstructed?: boolean;
|
|
}
|
|
|
|
function round2(n: number): number {
|
|
return parseFloat(n.toFixed(2));
|
|
}
|
|
|
|
/**
|
|
* Map one WHOOP recovery record into Measurement readings. An unscored record
|
|
* (`score === null`) yields nothing. `recovery.score.recovery_score` →
|
|
* `RECOVERY_SCORE` (source WHOOP, distinct from the COMPUTED proxy);
|
|
* `hrv_rmssd_milli` → `HRV_RMSSD` (distinct from the SDNN
|
|
* `HEART_RATE_VARIABILITY`); RHR / SpO2 / skin-temp resolve through the
|
|
* cross-source picker.
|
|
*/
|
|
export function mapRecovery(r: WhoopRecovery): MappedMeasurement[] {
|
|
if (!r.score) return [];
|
|
const measuredAt = new Date(r.updated_at);
|
|
const out: MappedMeasurement[] = [
|
|
{
|
|
type: "RECOVERY_SCORE",
|
|
value: round2(r.score.recovery_score),
|
|
unit: "score",
|
|
measuredAt,
|
|
fieldTag: "recovery",
|
|
},
|
|
{
|
|
type: "HRV_RMSSD",
|
|
value: round2(r.score.hrv_rmssd_milli),
|
|
unit: "ms",
|
|
measuredAt,
|
|
fieldTag: "hrv_rmssd",
|
|
},
|
|
{
|
|
type: "RESTING_HEART_RATE",
|
|
value: round2(r.score.resting_heart_rate),
|
|
unit: "bpm",
|
|
measuredAt,
|
|
fieldTag: "rhr",
|
|
},
|
|
];
|
|
if (typeof r.score.spo2_percentage === "number") {
|
|
out.push({
|
|
type: "OXYGEN_SATURATION",
|
|
value: round2(r.score.spo2_percentage),
|
|
unit: "%",
|
|
measuredAt,
|
|
fieldTag: "spo2",
|
|
});
|
|
}
|
|
if (typeof r.score.skin_temp_celsius === "number") {
|
|
out.push({
|
|
type: "SKIN_TEMPERATURE",
|
|
value: round2(r.score.skin_temp_celsius),
|
|
unit: "celsius",
|
|
measuredAt,
|
|
fieldTag: "skin_temp",
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Map one WHOOP sleep record into Measurement readings: per-stage
|
|
* `SLEEP_DURATION` rows (ms→min, one per stage), `SLEEP_NEED` (ms→min, summed
|
|
* components), the `SLEEP_*` percentage scores, and `RESPIRATORY_RATE`.
|
|
*/
|
|
export function mapSleep(s: WhoopSleep): MappedMeasurement[] {
|
|
if (!s.score) return [];
|
|
const measuredAt = new Date(s.end);
|
|
const out: MappedMeasurement[] = [];
|
|
|
|
const stages = s.score.stage_summary;
|
|
|
|
// WHOOP v2 returns only per-stage DURATION totals (no per-stage onset
|
|
// timestamps, no hypnogram endpoint). Stamping every stage total on the one
|
|
// sleep-END instant makes the hypnogram reconstruct five spans that all touch
|
|
// the night's right edge (the stacked-bar artefact). Since the API never
|
|
// carries an order, we RECONSTRUCT an ordered, contiguous timeline: lay the
|
|
// asleep stages back-to-back from sleep ONSET in a fixed physiological order,
|
|
// emitting one timed row per segment with `measuredAt = that segment's END`.
|
|
// The ORDER is synthetic, so every reconstructed row is flagged
|
|
// `reconstructed: true` and the night DTO advertises it — the UI labels it as
|
|
// an approximate layout and never presents it as measured stage timing.
|
|
//
|
|
// IN_BED stays a single envelope row spanning the whole [start, end] window:
|
|
// the in-bed reader wants the union envelope, not a placed segment. AWAKE is
|
|
// laid as a leading segment from onset so the asleep stages still partition
|
|
// the remaining window.
|
|
const onset = new Date(s.start).getTime();
|
|
// Order the laid-out stages: a brief AWAKE settling-in block, then the asleep
|
|
// stages CORE → DEEP → REM. Each carries its own duration; the shared builder
|
|
// walks the cursor forward so the spans are contiguous and non-overlapping,
|
|
// and lays the IN_BED envelope over the whole window. The order is synthetic
|
|
// (WHOOP gives no per-stage onset), so every laid segment is flagged
|
|
// reconstructed; the same algorithm backs Polar's `mapSleep`.
|
|
out.push(
|
|
...reconstructContiguousSleepTimeline({
|
|
startMs: onset,
|
|
stages: [
|
|
{
|
|
durationMs: stages.total_awake_time_milli,
|
|
stage: "AWAKE",
|
|
fieldTag: "sleep_awake",
|
|
},
|
|
{
|
|
durationMs: stages.total_light_sleep_time_milli,
|
|
stage: "CORE",
|
|
fieldTag: "sleep_core",
|
|
},
|
|
{
|
|
durationMs: stages.total_slow_wave_sleep_time_milli,
|
|
stage: "DEEP",
|
|
fieldTag: "sleep_deep",
|
|
},
|
|
{
|
|
durationMs: stages.total_rem_sleep_time_milli,
|
|
stage: "REM",
|
|
fieldTag: "sleep_rem",
|
|
},
|
|
],
|
|
// IN_BED — single envelope row over the whole sleep window. `measuredAt`
|
|
// is the sleep END so `segmentOf` resolves the span back to [start, end];
|
|
// the in-bed union-envelope reader consumes the full window.
|
|
inBed: {
|
|
durationMs: stages.total_in_bed_time_milli,
|
|
measuredAt,
|
|
fieldTag: "sleep_in_bed",
|
|
},
|
|
// The sync layer keys reconstructed rows by this externalId verbatim;
|
|
// the stage tag keeps the several rows of one night distinct under
|
|
// userId_type_source_externalId (one segment per stage, no positional
|
|
// index — an index renumbered on a re-score when a stage's duration
|
|
// flipped 0↔positive, minting fresh ids that double-counted the night).
|
|
externalIdFor: (tag) => `${s.id}:seg:${tag}`,
|
|
}),
|
|
);
|
|
|
|
const need = s.score.sleep_needed;
|
|
const totalNeedMilli =
|
|
need.baseline_milli +
|
|
need.need_from_sleep_debt_milli +
|
|
need.need_from_recent_strain_milli +
|
|
need.need_from_recent_nap_milli;
|
|
out.push({
|
|
type: "SLEEP_NEED",
|
|
value: round2(totalNeedMilli * MS_TO_MIN),
|
|
unit: "minutes",
|
|
measuredAt,
|
|
fieldTag: "sleep_need",
|
|
});
|
|
|
|
const pct: Array<[string, number | undefined, string]> = [
|
|
["SLEEP_PERFORMANCE", s.score.sleep_performance_percentage, "sleep_perf"],
|
|
["SLEEP_EFFICIENCY", s.score.sleep_efficiency_percentage, "sleep_eff"],
|
|
[
|
|
"SLEEP_CONSISTENCY",
|
|
s.score.sleep_consistency_percentage,
|
|
"sleep_consistency",
|
|
],
|
|
];
|
|
for (const [type, value, fieldTag] of pct) {
|
|
if (typeof value === "number") {
|
|
out.push({
|
|
type,
|
|
value: round2(value),
|
|
unit: "%",
|
|
measuredAt,
|
|
fieldTag,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (typeof s.score.respiratory_rate === "number") {
|
|
out.push({
|
|
type: "RESPIRATORY_RATE",
|
|
value: round2(s.score.respiratory_rate),
|
|
unit: "breaths/min",
|
|
measuredAt,
|
|
fieldTag: "resp_rate",
|
|
});
|
|
}
|
|
|
|
if (typeof stages.disturbance_count === "number") {
|
|
out.push({
|
|
type: "SLEEP_DISTURBANCE_COUNT",
|
|
value: stages.disturbance_count,
|
|
unit: "count",
|
|
measuredAt,
|
|
fieldTag: "disturbances",
|
|
});
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Map one WHOOP cycle (day) record: `DAY_STRAIN` (distinct from the COMPUTED
|
|
* `STRAIN_SCORE`), `ENERGY_EXPENDITURE_KJ` (kept in native kJ), and the day's
|
|
* whole-cycle `AVERAGE_HEART_RATE` / `MAX_HEART_RATE`. Energy is NOT converted
|
|
* to kcal here — that conversion is for the workout path only.
|
|
*/
|
|
export function mapCycle(c: WhoopCycle): MappedMeasurement[] {
|
|
if (!c.score) return [];
|
|
const measuredAt = new Date(c.start);
|
|
const out: MappedMeasurement[] = [
|
|
{
|
|
type: "DAY_STRAIN",
|
|
value: round2(c.score.strain),
|
|
unit: "score",
|
|
measuredAt,
|
|
fieldTag: "day_strain",
|
|
},
|
|
{
|
|
type: "ENERGY_EXPENDITURE_KJ",
|
|
value: round2(c.score.kilojoule),
|
|
unit: "kJ",
|
|
measuredAt,
|
|
fieldTag: "energy_kj",
|
|
},
|
|
];
|
|
|
|
// v1.12.8 — the cycle score's average / max heart rate were fetched but
|
|
// dropped before this release. Distinct fieldTags keep them from colliding
|
|
// with the strain / energy rows under the shared cycle `externalId`.
|
|
if (typeof c.score.average_heart_rate === "number") {
|
|
out.push({
|
|
type: "AVERAGE_HEART_RATE",
|
|
value: Math.round(c.score.average_heart_rate),
|
|
unit: "bpm",
|
|
measuredAt,
|
|
fieldTag: "avg_hr",
|
|
});
|
|
}
|
|
if (typeof c.score.max_heart_rate === "number") {
|
|
out.push({
|
|
type: "MAX_HEART_RATE",
|
|
value: Math.round(c.score.max_heart_rate),
|
|
unit: "bpm",
|
|
measuredAt,
|
|
fieldTag: "max_hr",
|
|
});
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
/** Metres → centimetres (WHOOP `height_meter` → `User.heightCm`). */
|
|
const M_TO_CM = 100;
|
|
|
|
/**
|
|
* The three destinations a WHOOP body-measurement object fans out to. Unlike
|
|
* the collection mappers it does NOT emit `MappedMeasurement[]` directly,
|
|
* because only `weight` lands in `Measurement` — `maxHeartRate` is a profile
|
|
* constant on `WhoopConnection` and `heightCm` is a one-time `User` profile
|
|
* seed (written only when the user has no height yet). The sync layer routes
|
|
* each piece to its own table.
|
|
*/
|
|
export interface MappedBodyMeasurement {
|
|
/** Self-reported profile weight in kg, or null when WHOOP omits it. */
|
|
weightKg: number | null;
|
|
/** Profile max heart rate in bpm, or null when WHOOP omits it. */
|
|
maxHeartRate: number | null;
|
|
/** Profile height converted m→cm, or null when WHOOP omits it. */
|
|
heightCm: number | null;
|
|
}
|
|
|
|
/**
|
|
* Map a WHOOP body-measurement object onto its three destinations. The body
|
|
* measurement is a single self-reported profile value, not a timestamped
|
|
* reading, so the sync layer stamps the weight row's `measuredAt` with the
|
|
* fetch time. Every field is optional on the wire — an absent field maps to
|
|
* null and the sync layer skips it.
|
|
*/
|
|
export function mapBody(b: WhoopBodyMeasurement): MappedBodyMeasurement {
|
|
// WHOOP body fields are self-reported profile data. Guard against absent,
|
|
// non-finite (NaN/Infinity), or non-positive values so a garbage reading
|
|
// never seeds a real WEIGHT measurement or a `User.heightCm` of 0
|
|
// (v1.11.3 QA L1).
|
|
const positive = (n: number | undefined): n is number =>
|
|
typeof n === "number" && Number.isFinite(n) && n > 0;
|
|
return {
|
|
weightKg: positive(b.weight_kilogram) ? round2(b.weight_kilogram) : null,
|
|
maxHeartRate: positive(b.max_heart_rate)
|
|
? Math.round(b.max_heart_rate)
|
|
: null,
|
|
heightCm: positive(b.height_meter)
|
|
? round2(b.height_meter * M_TO_CM)
|
|
: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Field→Measurement mapping table (mirror of `mapping.md`). Documents which
|
|
* WHOOP source field becomes which MeasurementType + unit. Used as the
|
|
* single-glance reference and by the mapper tests; the mappers above are the
|
|
* executable form.
|
|
*/
|
|
export const WHOOP_FIELD_MAP: Record<
|
|
string,
|
|
{ type: string; unit: string; factor?: number; note?: string }
|
|
> = {
|
|
"recovery.score.recovery_score": { type: "RECOVERY_SCORE", unit: "score" },
|
|
"recovery.score.hrv_rmssd_milli": { type: "HRV_RMSSD", unit: "ms" },
|
|
"recovery.score.resting_heart_rate": {
|
|
type: "RESTING_HEART_RATE",
|
|
unit: "bpm",
|
|
},
|
|
"recovery.score.spo2_percentage": {
|
|
type: "OXYGEN_SATURATION",
|
|
unit: "%",
|
|
},
|
|
"recovery.score.skin_temp_celsius": {
|
|
type: "SKIN_TEMPERATURE",
|
|
unit: "celsius",
|
|
},
|
|
"sleep.score.stage_summary.*_time_milli": {
|
|
type: "SLEEP_DURATION",
|
|
unit: "minutes",
|
|
factor: MS_TO_MIN,
|
|
note: "one row per stage (light→CORE, slow-wave→DEEP, rem→REM, awake→AWAKE, in-bed→IN_BED)",
|
|
},
|
|
"sleep.score.sleep_needed.*_milli": {
|
|
type: "SLEEP_NEED",
|
|
unit: "minutes",
|
|
factor: MS_TO_MIN,
|
|
note: "baseline + debt + strain + nap components summed",
|
|
},
|
|
"sleep.score.sleep_performance_percentage": {
|
|
type: "SLEEP_PERFORMANCE",
|
|
unit: "%",
|
|
},
|
|
"sleep.score.sleep_efficiency_percentage": {
|
|
type: "SLEEP_EFFICIENCY",
|
|
unit: "%",
|
|
},
|
|
"sleep.score.sleep_consistency_percentage": {
|
|
type: "SLEEP_CONSISTENCY",
|
|
unit: "%",
|
|
},
|
|
"sleep.score.respiratory_rate": {
|
|
type: "RESPIRATORY_RATE",
|
|
unit: "breaths/min",
|
|
},
|
|
"cycle.score.strain": { type: "DAY_STRAIN", unit: "score" },
|
|
"cycle.score.kilojoule": { type: "ENERGY_EXPENDITURE_KJ", unit: "kJ" },
|
|
"workout.score.strain": {
|
|
type: "WORKOUT_STRAIN",
|
|
unit: "score",
|
|
note: "preferentially stored in Workout.metadata, not a free-floating Measurement",
|
|
},
|
|
"workout.score.kilojoule": {
|
|
type: "Workout.totalEnergyKcal",
|
|
unit: "kcal",
|
|
factor: KJ_TO_KCAL,
|
|
note: "kJ→kcal for the workout row",
|
|
},
|
|
"body.weight_kilogram": {
|
|
type: "WEIGHT",
|
|
unit: "kg",
|
|
note: "picker ranks a real scale above WHOOP",
|
|
},
|
|
"body.max_heart_rate": {
|
|
type: "WhoopConnection.maxHeartRate",
|
|
unit: "bpm",
|
|
note: "profile constant — stored on the connection, not a Measurement",
|
|
},
|
|
"body.height_meter": {
|
|
type: "User.heightCm",
|
|
unit: "cm",
|
|
factor: M_TO_CM,
|
|
note: "profile seed — written to User.heightCm only when it is null, never as a Measurement",
|
|
},
|
|
};
|