diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index db6260cb2..d1a87cbf0 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -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"); diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 5aad8bfdb..c345fdcef 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -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( diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 1ae94a6e5..6dece968c 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -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 { + return fetchCollection( + "/v2/usercollection/daily_stress", + accessToken, + "fetchDailyStress", + query, + ); +} + +export function fetchVo2Max( + accessToken: string, + query: DateRangeQuery, +): Promise { + // Oura camel-cases this path segment (`vO2_max`); every other collection is + // snake_case. Matched verbatim against cloud.ouraring.com/v2/docs. + return fetchCollection( + "/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", + }, + ]; +} diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 15ec2cf9d..3b95d8c5c 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -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 => { - 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; };