mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Oura's daily_resilience collection reports a categorical level — limited, adequate, solid, strong, exceptional. Encode it ordinally (limited=1 … exceptional=5) into the numeric measurement value so it fits the existing model with no new categorical column: add the RESILIENCE measurement type (migration 0186), fetch and map daily_resilience in the Oura sync, and skip an unknown or missing level rather than coercing it to zero. RESILIENCE_LEVELS in the Oura client is the single source of truth for the encoding. The day-keyed externalId keeps a re-sync idempotent so a re-scored day overwrites in place. Extend the measurement-type label, unit, range, category, chart-token, and PR-direction maps; add the metric label across all six locales.
822 lines
25 KiB
TypeScript
822 lines
25 KiB
TypeScript
/**
|
||
* v1.17.0 (F4) — Oura Cloud API v2 client for OAuth + data fetching.
|
||
* Docs: https://cloud.ouraring.com/v2/docs (re-verify at build).
|
||
*
|
||
* Mirrors the WHOOP / Polar client structure: hand-rolled fetch over
|
||
* `safeFetch` (no SDK), an OAuth handshake (`getAuthorizationUrl` /
|
||
* `exchangeCode` / `refreshAccessToken`), typed v2 collection fetchers with
|
||
* `next_token` pagination, and a single source-of-truth field→Measurement
|
||
* mapping.
|
||
*
|
||
* Oura specifics: standard authorization-code OAuth WITH refresh tokens (the
|
||
* sync layer refreshes on 401, persisting BOTH rotated tokens). The token
|
||
* endpoint takes client_id/secret in the body. Collections are read by
|
||
* `start_date` / `end_date` (YYYY-MM-DD) and page via `next_token`.
|
||
*/
|
||
import { getEvent } from "@/lib/logging/context";
|
||
import { safeFetch } from "@/lib/safe-fetch";
|
||
import { OuraApiError, classifyOuraResponse } from "./response-classifier";
|
||
|
||
const OURA_API_BASE = "https://api.ouraring.com";
|
||
const OURA_OAUTH_AUTH_URL = "https://cloud.ouraring.com/oauth/authorize";
|
||
const OURA_OAUTH_TOKEN_URL = "https://api.ouraring.com/oauth/token";
|
||
|
||
export interface OuraCredentials {
|
||
clientId: string;
|
||
clientSecret: string;
|
||
}
|
||
|
||
/**
|
||
* Env-configured shared OAuth credentials — the FALLBACK app used when a user
|
||
* has not registered their own BYO Oura client id/secret. As of v1.17.1 the
|
||
* per-user resolver `getOuraClientCredentials` (DB-first) is the primary path
|
||
* and calls this only when no per-user pair is stored.
|
||
*/
|
||
export function getOuraCredentials(): OuraCredentials | null {
|
||
const clientId = process.env.OURA_CLIENT_ID;
|
||
const clientSecret = process.env.OURA_CLIENT_SECRET;
|
||
if (!clientId || !clientSecret) return null;
|
||
return { clientId, clientSecret };
|
||
}
|
||
|
||
export function getOuraRedirectUri(): string {
|
||
return (
|
||
process.env.OURA_REDIRECT_URI ||
|
||
`${process.env.NEXT_PUBLIC_APP_URL}/api/oura/callback`
|
||
);
|
||
}
|
||
|
||
/** `daily` covers sleep / readiness / activity; `personal` is profile info. */
|
||
export const OURA_OAUTH_SCOPE = "daily personal" as const;
|
||
|
||
export function getAuthorizationUrl(
|
||
state: string,
|
||
creds: OuraCredentials,
|
||
): string {
|
||
const params = new URLSearchParams({
|
||
response_type: "code",
|
||
client_id: creds.clientId,
|
||
redirect_uri: getOuraRedirectUri(),
|
||
scope: OURA_OAUTH_SCOPE,
|
||
state,
|
||
});
|
||
return `${OURA_OAUTH_AUTH_URL}?${params}`;
|
||
}
|
||
|
||
export interface OuraTokenResponse {
|
||
access_token: string;
|
||
refresh_token: string;
|
||
expires_in: number;
|
||
token_type?: string;
|
||
scope?: string;
|
||
}
|
||
|
||
async function postToken(
|
||
params: URLSearchParams,
|
||
verb: string,
|
||
): Promise<OuraTokenResponse> {
|
||
const start = performance.now();
|
||
const res = await safeFetch(OURA_OAUTH_TOKEN_URL, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Accept: "application/json",
|
||
},
|
||
body: params.toString(),
|
||
});
|
||
const json = await res.json().catch(() => null);
|
||
const verdict = classifyOuraResponse(res.status);
|
||
getEvent()?.addExternalCall({
|
||
service: "oura",
|
||
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 OuraApiError({
|
||
verb,
|
||
classification: verdict.classification,
|
||
httpStatus: verdict.httpStatus,
|
||
reason: verdict.reason,
|
||
upstreamError: typeof json?.error === "string" ? json.error : undefined,
|
||
});
|
||
}
|
||
return json as OuraTokenResponse;
|
||
}
|
||
|
||
export async function exchangeCode(
|
||
code: string,
|
||
creds: OuraCredentials,
|
||
): Promise<OuraTokenResponse> {
|
||
return postToken(
|
||
new URLSearchParams({
|
||
grant_type: "authorization_code",
|
||
code,
|
||
redirect_uri: getOuraRedirectUri(),
|
||
client_id: creds.clientId,
|
||
client_secret: creds.clientSecret,
|
||
}),
|
||
"exchangeCode",
|
||
);
|
||
}
|
||
|
||
export async function refreshAccessToken(
|
||
refreshToken: string,
|
||
creds: OuraCredentials,
|
||
): Promise<OuraTokenResponse> {
|
||
return postToken(
|
||
new URLSearchParams({
|
||
grant_type: "refresh_token",
|
||
refresh_token: refreshToken,
|
||
client_id: creds.clientId,
|
||
client_secret: creds.clientSecret,
|
||
}),
|
||
"refreshAccessToken",
|
||
);
|
||
}
|
||
|
||
// ─── Collection reads (v2) ─────────────────────────────────────
|
||
|
||
/** v2 paginated envelope: `{ data, next_token }`. */
|
||
export interface OuraCollection<T> {
|
||
data: T[];
|
||
next_token?: string | null;
|
||
}
|
||
|
||
export interface OuraSleep {
|
||
id: string;
|
||
day: string;
|
||
/** `long` | `short` | `nap` | `rest` — disambiguates a nap from the main sleep. */
|
||
type?: string | null;
|
||
bedtime_start?: string;
|
||
bedtime_end?: string;
|
||
total_sleep_duration?: number | null;
|
||
time_in_bed?: number | null;
|
||
efficiency?: number | null;
|
||
rem_sleep_duration?: number | null;
|
||
deep_sleep_duration?: number | null;
|
||
light_sleep_duration?: number | null;
|
||
awake_time?: number | null;
|
||
average_heart_rate?: number | null;
|
||
lowest_heart_rate?: number | null;
|
||
average_hrv?: number | null;
|
||
average_breath?: number | null;
|
||
/**
|
||
* 5-minute hypnogram: one digit per 5-min interval from `bedtime_start`.
|
||
* Oura's measured per-stage timeline — `1`=deep, `2`=light, `3`=rem,
|
||
* `4`=awake. Re-verify the digit→stage mapping against
|
||
* `cloud.ouraring.com/v2/docs` at build time (the encoding is published in
|
||
* the `Sleep` model; cross-checked against the `oura-api`/`@pinta365/oura-api`
|
||
* client libraries). Unlike WHOOP — which exposes only stage totals and must
|
||
* synthesise an order — Oura gives the real onsets, so we emit timed
|
||
* per-segment rows (`reconstructed: false`).
|
||
*/
|
||
sleep_phase_5_min?: string | null;
|
||
}
|
||
|
||
/**
|
||
* Oura `daily_readiness` contributors — each a 1–100 sub-score that explains
|
||
* the headline readiness number. All optional (a record may predate a
|
||
* contributor or carry a null for a missing input).
|
||
*/
|
||
export interface OuraReadinessContributors {
|
||
activity_balance?: number | null;
|
||
body_temperature?: number | null;
|
||
hrv_balance?: number | null;
|
||
previous_day_activity?: number | null;
|
||
previous_night?: number | null;
|
||
recovery_index?: number | null;
|
||
resting_heart_rate?: number | null;
|
||
sleep_balance?: number | null;
|
||
}
|
||
|
||
export interface OuraReadiness {
|
||
id: string;
|
||
day: string;
|
||
timestamp?: string;
|
||
score?: number | null;
|
||
contributors?: OuraReadinessContributors | null;
|
||
/** Nightly body-temperature deviation from the personal baseline, °C (signed). */
|
||
temperature_deviation?: number | null;
|
||
/** Long-term body-temperature trend deviation, °C (signed). */
|
||
temperature_trend_deviation?: number | null;
|
||
}
|
||
|
||
/** Oura `daily_sleep` — the headline Sleep Score (distinct from `sleep` detail). */
|
||
export interface OuraDailySleep {
|
||
id: string;
|
||
day: string;
|
||
timestamp?: string;
|
||
score?: number | null;
|
||
}
|
||
|
||
/** Oura `daily_spo2` — average overnight blood-oxygen saturation. */
|
||
export interface OuraDailySpo2 {
|
||
id: string;
|
||
day: string;
|
||
spo2_percentage?: { average?: number | null } | null;
|
||
}
|
||
|
||
/** Oura `vO2_max` — the dedicated cardio-fitness collection, mL/(kg·min). */
|
||
export interface OuraVo2Max {
|
||
id: string;
|
||
day: string;
|
||
timestamp?: string;
|
||
vo2_max?: number | null;
|
||
}
|
||
|
||
/**
|
||
* Oura `daily_cardiovascular_age` — Oura's estimate of how the user's
|
||
* cardiovascular system is ageing relative to chronological age, in years.
|
||
* Maps cleanly onto the existing `VASCULAR_AGE` enum (the same years-unit
|
||
* arterial-age concept Withings' Body Scan reports under meastype 155).
|
||
*/
|
||
export interface OuraCardiovascularAge {
|
||
id?: string;
|
||
day: string;
|
||
/** Estimated vascular age in years. */
|
||
vascular_age?: number | null;
|
||
}
|
||
|
||
/**
|
||
* Oura `daily_resilience` — the daily resilience LEVEL, a categorical band
|
||
* describing how well the body copes with cumulative load. The headline is the
|
||
* `level` string (limited / adequate / solid / strong / exceptional); the
|
||
* collection also carries `contributors` (sleep_recovery / daytime_recovery /
|
||
* stress) which we do NOT ingest — we capture only the headline level.
|
||
* Re-verify the `level` field name + value set against
|
||
* `cloud.ouraring.com/v2/docs` at build time.
|
||
*/
|
||
export interface OuraResilience {
|
||
id?: string;
|
||
day: string;
|
||
/** limited | adequate | solid | strong | exceptional */
|
||
level?: string | null;
|
||
}
|
||
|
||
export interface OuraDailyActivity {
|
||
id: string;
|
||
day: string;
|
||
timestamp?: string;
|
||
steps?: number | null;
|
||
active_calories?: number | null;
|
||
total_calories?: number | null;
|
||
/** Equivalent walking distance for the day, metres. */
|
||
equivalent_walking_distance?: number | null;
|
||
}
|
||
|
||
interface DateRangeQuery {
|
||
startDate: string; // YYYY-MM-DD
|
||
endDate: string; // YYYY-MM-DD
|
||
maxPages?: number;
|
||
}
|
||
|
||
async function fetchCollection<T>(
|
||
path: string,
|
||
accessToken: string,
|
||
verb: string,
|
||
query: DateRangeQuery,
|
||
): Promise<T[]> {
|
||
const records: T[] = [];
|
||
let nextToken: string | null | undefined;
|
||
let pageCount = 0;
|
||
const maxPages = query.maxPages ?? 100;
|
||
|
||
do {
|
||
const params = new URLSearchParams({
|
||
start_date: query.startDate,
|
||
end_date: query.endDate,
|
||
});
|
||
if (nextToken) params.set("next_token", nextToken);
|
||
|
||
const pageStart = performance.now();
|
||
const res = await safeFetch(`${OURA_API_BASE}${path}?${params}`, {
|
||
method: "GET",
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
Accept: "application/json",
|
||
},
|
||
});
|
||
const json = (await res
|
||
.json()
|
||
.catch(() => null)) as OuraCollection<T> | null;
|
||
const verdict = classifyOuraResponse(res.status);
|
||
getEvent()?.addExternalCall({
|
||
service: "oura",
|
||
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 OuraApiError({
|
||
verb,
|
||
classification: verdict.classification,
|
||
httpStatus: verdict.httpStatus,
|
||
reason: verdict.reason,
|
||
});
|
||
}
|
||
for (const r of json?.data ?? []) records.push(r);
|
||
nextToken = json?.next_token ?? null;
|
||
pageCount += 1;
|
||
} while (nextToken && pageCount < maxPages);
|
||
|
||
return records;
|
||
}
|
||
|
||
export function fetchSleep(
|
||
accessToken: string,
|
||
query: DateRangeQuery,
|
||
): Promise<OuraSleep[]> {
|
||
return fetchCollection<OuraSleep>(
|
||
"/v2/usercollection/sleep",
|
||
accessToken,
|
||
"fetchSleep",
|
||
query,
|
||
);
|
||
}
|
||
|
||
export function fetchReadiness(
|
||
accessToken: string,
|
||
query: DateRangeQuery,
|
||
): Promise<OuraReadiness[]> {
|
||
return fetchCollection<OuraReadiness>(
|
||
"/v2/usercollection/daily_readiness",
|
||
accessToken,
|
||
"fetchReadiness",
|
||
query,
|
||
);
|
||
}
|
||
|
||
export function fetchDailyActivity(
|
||
accessToken: string,
|
||
query: DateRangeQuery,
|
||
): Promise<OuraDailyActivity[]> {
|
||
return fetchCollection<OuraDailyActivity>(
|
||
"/v2/usercollection/daily_activity",
|
||
accessToken,
|
||
"fetchDailyActivity",
|
||
query,
|
||
);
|
||
}
|
||
|
||
export function fetchDailySleep(
|
||
accessToken: string,
|
||
query: DateRangeQuery,
|
||
): Promise<OuraDailySleep[]> {
|
||
return fetchCollection<OuraDailySleep>(
|
||
"/v2/usercollection/daily_sleep",
|
||
accessToken,
|
||
"fetchDailySleep",
|
||
query,
|
||
);
|
||
}
|
||
|
||
export function fetchDailySpo2(
|
||
accessToken: string,
|
||
query: DateRangeQuery,
|
||
): Promise<OuraDailySpo2[]> {
|
||
return fetchCollection<OuraDailySpo2>(
|
||
"/v2/usercollection/daily_spo2",
|
||
accessToken,
|
||
"fetchDailySpo2",
|
||
query,
|
||
);
|
||
}
|
||
|
||
// Oura `daily_stress` → STRESS_SCORE is deferred pending STRESS_SCORE
|
||
// source-priority + graded-series wiring (an HRV-derived COMPUTED producer
|
||
// already exists; a second producer here would double-count). The mapping is
|
||
// withdrawn until the ladder is in place.
|
||
|
||
export function fetchVo2Max(
|
||
accessToken: string,
|
||
query: DateRangeQuery,
|
||
): Promise<OuraVo2Max[]> {
|
||
// Oura camel-cases this path segment (`vO2_max`); every other collection is
|
||
// snake_case. Matched verbatim against cloud.ouraring.com/v2/docs.
|
||
return fetchCollection<OuraVo2Max>(
|
||
"/v2/usercollection/vO2_max",
|
||
accessToken,
|
||
"fetchVo2Max",
|
||
query,
|
||
);
|
||
}
|
||
|
||
export function fetchCardiovascularAge(
|
||
accessToken: string,
|
||
query: DateRangeQuery,
|
||
): Promise<OuraCardiovascularAge[]> {
|
||
return fetchCollection<OuraCardiovascularAge>(
|
||
"/v2/usercollection/daily_cardiovascular_age",
|
||
accessToken,
|
||
"fetchCardiovascularAge",
|
||
query,
|
||
);
|
||
}
|
||
|
||
export function fetchResilience(
|
||
accessToken: string,
|
||
query: DateRangeQuery,
|
||
): Promise<OuraResilience[]> {
|
||
return fetchCollection<OuraResilience>(
|
||
"/v2/usercollection/daily_resilience",
|
||
accessToken,
|
||
"fetchResilience",
|
||
query,
|
||
);
|
||
}
|
||
|
||
// ─── Field → Measurement mapping ───────────────────────────────
|
||
|
||
const SEC_TO_MIN = 1 / 60;
|
||
|
||
function round2(n: number): number {
|
||
return parseFloat(n.toFixed(2));
|
||
}
|
||
|
||
export interface MappedMeasurement {
|
||
type: string;
|
||
value: number;
|
||
unit: string;
|
||
measuredAt: Date;
|
||
fieldTag: string;
|
||
sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE";
|
||
/**
|
||
* Per-row externalId override. Most rows derive their externalId from the
|
||
* resource prefix + day + fieldTag in the sync layer, but the per-segment
|
||
* sleep-timeline rows (and any other row that needs a record-scoped key)
|
||
* carry their own here so several rows of one night stay distinct under
|
||
* `userId_type_source_externalId`. The sync layer uses `m.externalId ??`
|
||
* the default key.
|
||
*/
|
||
externalId?: string;
|
||
}
|
||
|
||
function dayAnchor(day: string, bedtimeEnd?: string): Date | null {
|
||
// Prefer the precise wake instant when present; else anchor the day at UTC
|
||
// midnight. The recovery resolver reads the local day key off this stamp.
|
||
const candidate = bedtimeEnd ?? `${day}T00:00:00.000Z`;
|
||
const d = new Date(candidate);
|
||
return Number.isNaN(d.getTime()) ? null : d;
|
||
}
|
||
|
||
/**
|
||
* Map one Oura readiness record:
|
||
* - `score` → `RECOVERY_SCORE` (source OURA, feeding the recovery ladder
|
||
* below WHOOP).
|
||
* - `temperature_deviation` → `BODY_TEMPERATURE_DEVIATION` (signed °C offset
|
||
* from the personal baseline — illness / luteal-phase / stress signal).
|
||
*
|
||
* The eight readiness `contributors` are the *why* behind the score; they are
|
||
* carried back to the caller separately (not minted as Measurement rows here
|
||
* since each is a sub-score, not a stored metric) — see the dropped-field note.
|
||
* `temperature_trend_deviation` is the long-term trend twin; we keep only the
|
||
* nightly deviation as the actionable signal.
|
||
*/
|
||
export function mapReadiness(r: OuraReadiness): MappedMeasurement[] {
|
||
const measuredAt = dayAnchor(r.day, r.timestamp);
|
||
if (!measuredAt) return [];
|
||
const out: MappedMeasurement[] = [];
|
||
if (typeof r.score === "number") {
|
||
out.push({
|
||
type: "RECOVERY_SCORE",
|
||
value: round2(r.score),
|
||
unit: "score",
|
||
measuredAt,
|
||
fieldTag: "recovery",
|
||
});
|
||
}
|
||
if (typeof r.temperature_deviation === "number") {
|
||
out.push({
|
||
type: "BODY_TEMPERATURE_DEVIATION",
|
||
value: round2(r.temperature_deviation),
|
||
unit: "celsius",
|
||
measuredAt,
|
||
fieldTag: "temp_deviation",
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Oura 5-minute hypnogram digit → our `SleepStage`. Oura encodes one digit per
|
||
* 5-min interval from `bedtime_start`: `1`=deep, `2`=light, `3`=rem, `4`=awake.
|
||
* Light maps to our CORE stage (the granular-asleep stage the sleep-night
|
||
* reader scores), matching the `light_sleep_duration → CORE` convention the
|
||
* stage-totals path already uses. Re-verify against `cloud.ouraring.com/v2/docs`.
|
||
*/
|
||
const HYPNOGRAM_STAGE: Record<
|
||
string,
|
||
NonNullable<MappedMeasurement["sleepStage"]>
|
||
> = {
|
||
"1": "DEEP",
|
||
"2": "CORE",
|
||
"3": "REM",
|
||
"4": "AWAKE",
|
||
};
|
||
|
||
/** Each hypnogram digit covers 5 minutes. */
|
||
const HYPNOGRAM_INTERVAL_MIN = 5;
|
||
const HYPNOGRAM_INTERVAL_MS = HYPNOGRAM_INTERVAL_MIN * 60_000;
|
||
|
||
/**
|
||
* Build timed per-segment `SLEEP_DURATION` rows from Oura's real measured
|
||
* `sleep_phase_5_min` hypnogram. Consecutive identical digits collapse into one
|
||
* segment; each segment is stamped at its own END instant (start + duration)
|
||
* relative to `bedtime_start`, so the sleep-night reader (which derives
|
||
* `start = end − value·60_000`) reconstructs the actual clock-time timeline —
|
||
* NOT the degraded all-on-`bedtime_end` shape. The rows are `reconstructed:
|
||
* false` by virtue of OURA being absent from the reader's
|
||
* `RECONSTRUCTED_TIMELINE_SOURCES` set (WHOOP only). Returns `null` when the
|
||
* hypnogram is absent / unusable so the caller falls back to stage totals.
|
||
*/
|
||
function mapSleepTimeline(s: OuraSleep): MappedMeasurement[] | null {
|
||
const hyp = s.sleep_phase_5_min;
|
||
if (typeof hyp !== "string" || hyp.length === 0 || !s.bedtime_start) {
|
||
return null;
|
||
}
|
||
const onset = new Date(s.bedtime_start);
|
||
if (Number.isNaN(onset.getTime())) return null;
|
||
|
||
const out: MappedMeasurement[] = [];
|
||
let segIndex = 0;
|
||
let i = 0;
|
||
while (i < hyp.length) {
|
||
const digit = hyp[i];
|
||
const stage = HYPNOGRAM_STAGE[digit];
|
||
// Walk the run of identical digits.
|
||
let run = 1;
|
||
while (i + run < hyp.length && hyp[i + run] === digit) run += 1;
|
||
if (stage) {
|
||
const segStartMs = onset.getTime() + i * HYPNOGRAM_INTERVAL_MS;
|
||
const segEndMs = segStartMs + run * HYPNOGRAM_INTERVAL_MS;
|
||
const fieldTag = `seg:${segIndex}`;
|
||
out.push({
|
||
type: "SLEEP_DURATION",
|
||
value: round2(run * HYPNOGRAM_INTERVAL_MIN),
|
||
unit: "minutes",
|
||
measuredAt: new Date(segEndMs),
|
||
fieldTag,
|
||
sleepStage: stage,
|
||
// Record-scoped + segment-indexed so a nap's segments never collide
|
||
// with the main sleep's and a re-fetch overwrites in place.
|
||
externalId: `sleep:${s.id}:${fieldTag}`,
|
||
});
|
||
segIndex += 1;
|
||
}
|
||
i += run;
|
||
}
|
||
return out.length > 0 ? out : null;
|
||
}
|
||
|
||
/**
|
||
* Map one Oura sleep record. When the real `sleep_phase_5_min` hypnogram is
|
||
* present we emit timed per-segment `SLEEP_DURATION` rows (Oura's measured
|
||
* timeline); otherwise we fall back to the four per-stage totals stamped on the
|
||
* sleep END. Either way we add efficiency, HRV, RHR (`lowest_heart_rate`), and
|
||
* respiratory rate.
|
||
*/
|
||
export function mapSleep(s: OuraSleep): MappedMeasurement[] {
|
||
const measuredAt = dayAnchor(s.day, s.bedtime_end);
|
||
if (!measuredAt) return [];
|
||
const out: MappedMeasurement[] = [];
|
||
|
||
const timeline = mapSleepTimeline(s);
|
||
if (timeline) {
|
||
out.push(...timeline);
|
||
} else {
|
||
// Fallback: no hypnogram — stamp the four stage totals on the sleep END.
|
||
const stages: Array<
|
||
[number | null | undefined, MappedMeasurement["sleepStage"], string]
|
||
> = [
|
||
[s.light_sleep_duration, "CORE", "sleep_core"],
|
||
[s.deep_sleep_duration, "DEEP", "sleep_deep"],
|
||
[s.rem_sleep_duration, "REM", "sleep_rem"],
|
||
[s.awake_time, "AWAKE", "sleep_awake"],
|
||
];
|
||
for (const [sec, stage, fieldTag] of stages) {
|
||
if (typeof sec === "number" && sec >= 0) {
|
||
out.push({
|
||
type: "SLEEP_DURATION",
|
||
value: round2(sec * SEC_TO_MIN),
|
||
unit: "minutes",
|
||
measuredAt,
|
||
fieldTag,
|
||
sleepStage: stage,
|
||
// Record-scoped so a nap and the main sleep on the same day don't
|
||
// collapse onto one key (B2) — the legacy day-keyed key did.
|
||
externalId: `sleep:${s.id}:${fieldTag}`,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
// The nightly scalars are record-scoped too (B2): a nap and the main sleep on
|
||
// the same day would otherwise overwrite each other's efficiency / HRV / RHR /
|
||
// respiratory rate on a shared day key.
|
||
if (typeof s.efficiency === "number") {
|
||
out.push({
|
||
type: "SLEEP_EFFICIENCY",
|
||
value: round2(s.efficiency),
|
||
unit: "%",
|
||
measuredAt,
|
||
fieldTag: "sleep_eff",
|
||
externalId: `sleep:${s.id}:sleep_eff`,
|
||
});
|
||
}
|
||
if (typeof s.average_hrv === "number" && s.average_hrv > 0) {
|
||
out.push({
|
||
type: "HRV_RMSSD",
|
||
value: round2(s.average_hrv),
|
||
unit: "ms",
|
||
measuredAt,
|
||
fieldTag: "hrv_rmssd",
|
||
externalId: `sleep:${s.id}:hrv_rmssd`,
|
||
});
|
||
}
|
||
if (typeof s.lowest_heart_rate === "number" && s.lowest_heart_rate > 0) {
|
||
out.push({
|
||
type: "RESTING_HEART_RATE",
|
||
value: Math.round(s.lowest_heart_rate),
|
||
unit: "bpm",
|
||
measuredAt,
|
||
fieldTag: "rhr",
|
||
externalId: `sleep:${s.id}:rhr`,
|
||
});
|
||
}
|
||
if (typeof s.average_breath === "number" && s.average_breath > 0) {
|
||
out.push({
|
||
type: "RESPIRATORY_RATE",
|
||
value: round2(s.average_breath),
|
||
unit: "breaths/min",
|
||
measuredAt,
|
||
fieldTag: "resp_rate",
|
||
externalId: `sleep:${s.id}:resp_rate`,
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Map one Oura daily-sleep record: the headline Sleep Score → `SLEEP_SCORE`. */
|
||
export function mapDailySleep(d: OuraDailySleep): MappedMeasurement[] {
|
||
if (typeof d.score !== "number") return [];
|
||
const measuredAt = dayAnchor(d.day, d.timestamp);
|
||
if (!measuredAt) return [];
|
||
return [
|
||
{
|
||
type: "SLEEP_SCORE",
|
||
value: round2(d.score),
|
||
unit: "score",
|
||
measuredAt,
|
||
fieldTag: "sleep_score",
|
||
},
|
||
];
|
||
}
|
||
|
||
/** Map one Oura daily-spo2 record: average overnight SpO2 → `OXYGEN_SATURATION`. */
|
||
export function mapDailySpo2(s: OuraDailySpo2): MappedMeasurement[] {
|
||
const avg = s.spo2_percentage?.average;
|
||
if (typeof avg !== "number" || avg <= 0) return [];
|
||
// Anchor SpO2 at the day's UTC midnight (no per-record instant in the
|
||
// daily_spo2 collection); the recovery / rollup readers key off the local day.
|
||
const measuredAt = dayAnchor(s.day);
|
||
if (!measuredAt) return [];
|
||
return [
|
||
{
|
||
type: "OXYGEN_SATURATION",
|
||
value: round2(avg),
|
||
unit: "%",
|
||
measuredAt,
|
||
fieldTag: "spo2",
|
||
},
|
||
];
|
||
}
|
||
|
||
/** Map one Oura daily-activity record: steps, active energy, and the
|
||
* equivalent walking distance (B5 — the OURA distance ladder slot was dead). */
|
||
export function mapDailyActivity(a: OuraDailyActivity): MappedMeasurement[] {
|
||
const measuredAt = dayAnchor(a.day, a.timestamp);
|
||
if (!measuredAt) return [];
|
||
const out: MappedMeasurement[] = [];
|
||
if (typeof a.steps === "number" && a.steps >= 0) {
|
||
out.push({
|
||
type: "ACTIVITY_STEPS",
|
||
value: Math.round(a.steps),
|
||
unit: "steps",
|
||
measuredAt,
|
||
fieldTag: "steps",
|
||
});
|
||
}
|
||
if (typeof a.active_calories === "number" && a.active_calories >= 0) {
|
||
out.push({
|
||
type: "ACTIVE_ENERGY_BURNED",
|
||
value: round2(a.active_calories),
|
||
unit: "kcal",
|
||
measuredAt,
|
||
fieldTag: "active_energy",
|
||
});
|
||
}
|
||
if (
|
||
typeof a.equivalent_walking_distance === "number" &&
|
||
a.equivalent_walking_distance >= 0
|
||
) {
|
||
out.push({
|
||
type: "WALKING_RUNNING_DISTANCE",
|
||
// Oura reports the equivalent walking distance in metres — the canonical
|
||
// WALKING_RUNNING_DISTANCE unit (no conversion).
|
||
value: round2(a.equivalent_walking_distance),
|
||
unit: "m",
|
||
measuredAt,
|
||
fieldTag: "distance",
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Map one Oura `vO2_max` record: the dedicated cardio-fitness collection →
|
||
* `VO2_MAX` (mL/(kg·min), the canonical DB unit — no conversion). Skips a
|
||
* record with no positive value.
|
||
*/
|
||
export function mapVo2Max(v: OuraVo2Max): MappedMeasurement[] {
|
||
if (typeof v.vo2_max !== "number" || v.vo2_max <= 0) return [];
|
||
const measuredAt = dayAnchor(v.day, v.timestamp);
|
||
if (!measuredAt) return [];
|
||
return [
|
||
{
|
||
type: "VO2_MAX",
|
||
value: round2(v.vo2_max),
|
||
unit: "mL/(kg·min)",
|
||
measuredAt,
|
||
fieldTag: "vo2_max",
|
||
},
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Map one Oura `daily_cardiovascular_age` record → `VASCULAR_AGE` (years, the
|
||
* canonical DB unit — no conversion). Skips a record with no positive value.
|
||
* Shares the `VASCULAR_AGE` bucket with Withings' Body Scan arterial-age, kept
|
||
* distinct from the two vendors only by `source`.
|
||
*/
|
||
export function mapCardiovascularAge(
|
||
c: OuraCardiovascularAge,
|
||
): MappedMeasurement[] {
|
||
if (typeof c.vascular_age !== "number" || c.vascular_age <= 0) return [];
|
||
const measuredAt = dayAnchor(c.day);
|
||
if (!measuredAt) return [];
|
||
return [
|
||
{
|
||
type: "VASCULAR_AGE",
|
||
value: round2(c.vascular_age),
|
||
unit: "years",
|
||
measuredAt,
|
||
fieldTag: "vascular_age",
|
||
},
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Single source of truth for the Oura resilience level → ordinal encoding.
|
||
* Oura's `daily_resilience.level` is a categorical band; we store it ORDINAL-
|
||
* ENCODED in the numeric Measurement `value` so it fits the existing model with
|
||
* no new categorical column. Keep in lock-step with the schema comment on the
|
||
* `RESILIENCE` enum value and migration 0186.
|
||
*/
|
||
export const RESILIENCE_LEVELS: Record<string, number> = {
|
||
limited: 1,
|
||
adequate: 2,
|
||
solid: 3,
|
||
strong: 4,
|
||
exceptional: 5,
|
||
};
|
||
|
||
/** The unit recorded for a RESILIENCE row — the ordinal level scale (1–5). */
|
||
export const RESILIENCE_UNIT = "level" as const;
|
||
|
||
/**
|
||
* Map one Oura `daily_resilience` record → `RESILIENCE`, ordinal-encoded
|
||
* (limited=1 … exceptional=5) into the numeric `value`. An unknown / missing
|
||
* level string mints NO row (skipped, never coerced to 0) so a future Oura band
|
||
* we do not recognise never lands as a misleading reading. Anchored at the
|
||
* day's UTC midnight (the collection carries no per-record instant).
|
||
*/
|
||
export function mapResilience(r: OuraResilience): MappedMeasurement[] {
|
||
const level = typeof r.level === "string" ? r.level.toLowerCase() : null;
|
||
if (!level) return [];
|
||
const ordinal = RESILIENCE_LEVELS[level];
|
||
if (typeof ordinal !== "number") return [];
|
||
const measuredAt = dayAnchor(r.day);
|
||
if (!measuredAt) return [];
|
||
return [
|
||
{
|
||
type: "RESILIENCE",
|
||
value: ordinal,
|
||
unit: RESILIENCE_UNIT,
|
||
measuredAt,
|
||
fieldTag: "resilience",
|
||
},
|
||
];
|
||
}
|