Files
HealthLog/src/lib/oura/client.ts
T
Marc-André Bombeck aff4e9abeb feat(oura): capture daily resilience as an ordinal RESILIENCE metric
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.
2026-06-21 10:44:15 +02:00

822 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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",
},
];
}