feat(oura): ingest the vO2_max and daily_stress collections

The Oura v2 client fetched sleep, readiness, activity, daily sleep, and
daily SpO2 but skipped the dedicated vO2_max collection and daily_stress.
An Oura user got no VO2max from any path even though the enum, the
cardio-fitness page, fitness-age, norms, and the LOINC code already exist.

Add fetchVo2Max + mapVo2Max (vo2_max -> VO2_MAX, mL/(kg·min)) and
fetchDailyStress + mapDailyStress. Oura exposes no headline stress score,
only minutes in high stress vs high recovery, so derive a bounded 0-100
STRESS_SCORE from the high-stress share of the day's charged time. Both
collections wire into the existing reactive-refresh sync tail; no
migration, no schema change, no iOS-contract impact.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 12:10:04 +02:00
1 parent d9ed7961bb
commit 72b1d1ef8a
4 files changed
+232 -6

No files matched your search

+84
View File
@@ -4,19 +4,24 @@ import {
OURA_OAUTH_SCOPE,
exchangeCode,
fetchReadiness,
fetchVo2Max,
getAuthorizationUrl,
getOuraCredentials,
mapDailyActivity,
mapDailySleep,
mapDailySpo2,
mapDailyStress,
mapReadiness,
mapSleep,
mapVo2Max,
refreshAccessToken,
type OuraDailyActivity,
type OuraDailySleep,
type OuraDailySpo2,
type OuraDailyStress,
type OuraReadiness,
type OuraSleep,
type OuraVo2Max,
} from "../client";
import { OuraApiError } from "../response-classifier";
@@ -309,6 +314,85 @@ describe("mapDailySpo2", () => {
});
});
describe("fetchVo2Max", () => {
it("reads the camel-cased vO2_max collection path", async () => {
const fetchMock = installFetchMock([
{
status: 200,
body: {
data: [{ id: "1", day: "2026-06-10", vo2_max: 47.3 }],
next_token: null,
},
},
]);
const r = await fetchVo2Max("tok", {
startDate: "2026-06-01",
endDate: "2026-06-10",
});
expect(r).toHaveLength(1);
const [url] = fetchMock.mock.calls[0]!;
expect(url).toContain("/v2/usercollection/vO2_max");
});
});
describe("mapVo2Max", () => {
it("maps vo2_max -> VO2_MAX (mL/(kg·min))", () => {
const v: OuraVo2Max = { id: "1", day: "2026-06-10", vo2_max: 47.3 };
const mapped = mapVo2Max(v);
expect(mapped).toHaveLength(1);
expect(mapped[0]).toMatchObject({
type: "VO2_MAX",
value: 47.3,
unit: "mL/(kg·min)",
fieldTag: "vo2_max",
});
});
it("skips a record with no positive value", () => {
expect(mapVo2Max({ id: "1", day: "2026-06-10", vo2_max: 0 })).toEqual([]);
expect(mapVo2Max({ id: "1", day: "2026-06-10", vo2_max: null })).toEqual(
[],
);
});
});
describe("mapDailyStress", () => {
it("derives a 0-100 STRESS_SCORE from the high-stress share of charged time", () => {
const s: OuraDailyStress = {
id: "1",
day: "2026-06-10",
stress_high: 90,
recovery_high: 30,
};
const mapped = mapDailyStress(s);
expect(mapped).toHaveLength(1);
// 90 / (90 + 30) = 75 %.
expect(mapped[0]).toMatchObject({
type: "STRESS_SCORE",
value: 75,
unit: "score",
fieldTag: "stress",
});
});
it("treats a missing recovery field as zero recovery", () => {
const mapped = mapDailyStress({
id: "1",
day: "2026-06-10",
stress_high: 60,
});
expect(mapped[0]?.value).toBe(100);
});
it("skips a day with no charged minutes (no signal)", () => {
expect(
mapDailyStress({
id: "1",
day: "2026-06-10",
stress_high: 0,
recovery_high: 0,
}),
).toEqual([]);
});
});
describe("OURA_OAUTH_SCOPE", () => {
it("requests daily + personal", () => {
expect(OURA_OAUTH_SCOPE).toBe("daily personal");
+34
View File
@@ -9,6 +9,8 @@ const {
fetchActivityMock,
fetchDailySleepMock,
fetchSpo2Mock,
fetchStressMock,
fetchVo2MaxMock,
refreshMock,
upsertMock,
recordSuccessMock,
@@ -24,6 +26,8 @@ const {
fetchActivityMock: vi.fn(),
fetchDailySleepMock: vi.fn(),
fetchSpo2Mock: vi.fn(),
fetchStressMock: vi.fn(),
fetchVo2MaxMock: vi.fn(),
refreshMock: vi.fn(),
upsertMock: vi.fn(),
recordSuccessMock: vi.fn(),
@@ -67,6 +71,8 @@ vi.mock("../client", async (importOriginal) => {
fetchDailyActivity: fetchActivityMock,
fetchDailySleep: fetchDailySleepMock,
fetchDailySpo2: fetchSpo2Mock,
fetchDailyStress: fetchStressMock,
fetchVo2Max: fetchVo2MaxMock,
refreshAccessToken: refreshMock,
};
});
@@ -87,6 +93,8 @@ beforeEach(() => {
fetchActivityMock.mockReset().mockResolvedValue([]);
fetchDailySleepMock.mockReset().mockResolvedValue([]);
fetchSpo2Mock.mockReset().mockResolvedValue([]);
fetchStressMock.mockReset().mockResolvedValue([]);
fetchVo2MaxMock.mockReset().mockResolvedValue([]);
refreshMock.mockReset();
upsertMock.mockReset().mockResolvedValue({});
recordSuccessMock.mockReset().mockResolvedValue(undefined);
@@ -210,6 +218,32 @@ describe("syncUserOura", () => {
});
});
it("writes VO2_MAX and STRESS_SCORE from the new collections", async () => {
getConnMock.mockResolvedValue(CONN);
fetchVo2MaxMock.mockResolvedValue([
{ id: "v", day: "2026-06-10", vo2_max: 47.3 },
]);
fetchStressMock.mockResolvedValue([
{ id: "s", day: "2026-06-10", stress_high: 90, recovery_high: 30 },
]);
await syncUserOura("u1");
const written = upsertMock.mock.calls.map((c) => ({
type: c[0].where.userId_type_source_externalId.type,
externalId: c[0].where.userId_type_source_externalId.externalId,
value: c[0].create.value,
}));
expect(written).toContainEqual({
type: "VO2_MAX",
externalId: "vo2max:2026-06-10:vo2_max",
value: 47.3,
});
expect(written).toContainEqual({
type: "STRESS_SCORE",
externalId: "stress:2026-06-10:stress",
value: 75,
});
});
it("does NOT refresh on a 403 (not an expiry case)", async () => {
getConnMock.mockResolvedValue(CONN);
fetchReadinessMock.mockRejectedValue(
+100
View File
@@ -218,6 +218,32 @@ export interface OuraDailySpo2 {
spo2_percentage?: { average?: number | null } | null;
}
/**
* Oura `daily_stress` — minutes spent in high stress vs. high recovery for the
* day (mutually exclusive). The collection carries no headline 0–100 score; the
* actionable scalars are the two durations plus a categorical `day_summary`
* (`restored` | `normal` | `stressed`), which has no enum and is dropped.
*/
export interface OuraDailyStress {
id: string;
day: string;
timestamp?: string;
/** Minutes spent in high stress. */
stress_high?: number | null;
/** Minutes spent in high recovery. */
recovery_high?: number | null;
/** `restored` | `normal` | `stressed` — categorical; no enum, not mapped. */
day_summary?: string | 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;
}
export interface OuraDailyActivity {
id: string;
day: string;
@@ -348,6 +374,32 @@ export function fetchDailySpo2(
);
}
export function fetchDailyStress(
accessToken: string,
query: DateRangeQuery,
): Promise<OuraDailyStress[]> {
return fetchCollection<OuraDailyStress>(
"/v2/usercollection/daily_stress",
accessToken,
"fetchDailyStress",
query,
);
}
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,
);
}
// ─── Field → Measurement mapping ───────────────────────────────
const SEC_TO_MIN = 1 / 60;
@@ -653,3 +705,51 @@ export function mapDailyActivity(a: OuraDailyActivity): MappedMeasurement[] {
}
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_stress` record → `STRESS_SCORE`. Oura exposes no headline
* 0–100 stress number — only minutes spent in high stress vs. high recovery.
* We derive a bounded 0–100 score from the share of the day's "charged" time
* (stress + recovery minutes) spent in high stress, which keeps the canonical
* `STRESS_SCORE` contract (0–100, unit "score", higher = more stress) and lines
* up with how RECOVERY_SCORE is stored. A day with no charged minutes carries
* no signal and is skipped; the categorical `day_summary` has no enum and is
* dropped.
*/
export function mapDailyStress(s: OuraDailyStress): MappedMeasurement[] {
const stress = typeof s.stress_high === "number" ? s.stress_high : 0;
const recovery = typeof s.recovery_high === "number" ? s.recovery_high : 0;
const charged = stress + recovery;
if (charged <= 0) return [];
const measuredAt = dayAnchor(s.day, s.timestamp);
if (!measuredAt) return [];
return [
{
type: "STRESS_SCORE",
value: Math.round((stress / charged) * 100),
unit: "score",
measuredAt,
fieldTag: "stress",
},
];
}
+14 -6
View File
@@ -5,8 +5,9 @@
* (real per-segment hypnogram timeline when present, else per-stage totals;
* efficiency, HRV, RHR, respiratory rate), daily activity (steps, active
* energy, equivalent walking distance), the daily Sleep Score (→ SLEEP_SCORE),
* and daily SpO2 (→ OXYGEN_SATURATION) for one connected user, mapping each into
* `Measurement` rows tagged `source = OURA`.
* daily SpO2 (→ OXYGEN_SATURATION), daily stress (→ STRESS_SCORE), and the
* dedicated vO2_max collection (→ VO2_MAX) for one connected user, mapping each
* into `Measurement` rows tagged `source = OURA`.
*
* Token model: Oura uses refresh tokens. The merged schema has no expiry
* column, so the sync refreshes REACTIVELY — the first read that 401s triggers
@@ -40,13 +41,17 @@ import {
fetchDailyActivity,
fetchDailySleep,
fetchDailySpo2,
fetchDailyStress,
fetchReadiness,
fetchSleep,
fetchVo2Max,
mapDailyActivity,
mapDailySleep,
mapDailySpo2,
mapDailyStress,
mapReadiness,
mapSleep,
mapVo2Max,
refreshAccessToken,
type MappedMeasurement,
} from "./client";
@@ -114,15 +119,16 @@ async function fetchAll(
const query = { startDate: ymd(start), endDate: ymd(now) };
const run = async (token: string): Promise<OuraMeasurementUpsert[]> => {
const [readiness, sleeps, activities, dailySleep, spo2] = await Promise.all(
[
const [readiness, sleeps, activities, dailySleep, spo2, stress, vo2max] =
await Promise.all([
fetchReadiness(token, query),
fetchSleep(token, query),
fetchDailyActivity(token, query),
fetchDailySleep(token, query),
fetchDailySpo2(token, query),
],
);
fetchDailyStress(token, query),
fetchVo2Max(token, query),
]);
const out: OuraMeasurementUpsert[] = [];
for (const r of readiness)
out.push(...toUpsert(mapReadiness(r), "readiness"));
@@ -132,6 +138,8 @@ async function fetchAll(
for (const d of dailySleep)
out.push(...toUpsert(mapDailySleep(d), "daily_sleep"));
for (const s of spo2) out.push(...toUpsert(mapDailySpo2(s), "spo2"));
for (const s of stress) out.push(...toUpsert(mapDailyStress(s), "stress"));
for (const v of vo2max) out.push(...toUpsert(mapVo2Max(v), "vo2max"));
return out;
};