diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index 0c7a64a69..9ccf98a25 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -154,6 +154,21 @@ describe("DEFAULT_DASHBOARD_LAYOUT contract", () => { expect(widget?.tileVisible).toBe(true); }); + it("ships the vo2Max strip tile default-on, chart row default-off (v1.18.9)", () => { + // VO2max is ingested from Withings / Apple Health / Fitbit / Oura but + // the tile used to be opt-in, so accounts that already synced a sample + // saw nothing on the dashboard. The strip tile is now on by default + // (it still self-gates on having a VO2_MAX sample in page.tsx), while + // the chart row stays off because VO2max charts live on the + // /insights/cardio-fitness sub-page. + const widget = DEFAULT_DASHBOARD_LAYOUT.widgets.find( + (w) => w.id === "vo2Max", + ); + expect(widget).toBeDefined(); + expect(widget?.tileVisible).toBe(true); + expect(widget?.visible).toBe(false); + }); + it("does NOT advertise a retired insightsPreview widget (v1.4.27 B1)", () => { // v1.4.27 B1 retired the standalone dashboard preview because it // duplicated the much-richer `/insights` advisor surface. The diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 9657d5390..a6ff10679 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -29,11 +29,12 @@ export const DASHBOARD_WIDGET_IDS = [ "bpInTarget", "oxygenSaturation", "achievements", - // v1.4.25 W8d — VO2 max trend tile (opt-in, secondary-metric pattern). - // Self-gates on having any VO2_MAX sample; default-invisible so - // existing dashboards stay unchanged until the user enables it in - // Settings → Dashboard. Only the tile-strip surface; chart row - // lands alongside the iOS-app body-composition sub-page in v1.5. + // v1.4.25 W8d — VO2 max trend tile (secondary-metric pattern). The + // strip tile is on by default and self-gates on having any VO2_MAX + // sample, so accounts that sync cardio fitness see it without enabling + // it in Settings → Dashboard, while empty accounts get a clean tile + // strip. Only the tile-strip surface; the chart row lives on the + // /insights/cardio-fitness sub-page. "vo2Max", // v1.4.32 — "Recent workouts" tile. Surfaces the three most-recent // canonical workouts; self-gates on at least one row. Default-on @@ -379,11 +380,14 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { // `tileVisible` is forced false because there is no tile surface // for this widget; only the chart-row card. { id: "achievements", visible: true, tileVisible: false, order: 13 }, - // v1.4.25 W8d — VO2 max trend tile. Default-invisible on both - // surfaces; opt-in via Settings → Dashboard. Only the tile-strip - // representation today (`visible` stays false so the chart-row - // gate never fires; the chart card lands in v1.5). - { id: "vo2Max", visible: false, tileVisible: false, order: 14 }, + // v1.4.25 W8d — VO2 max trend tile. The strip tile is on by default + // (`tileVisible: true`) so accounts that already sync a VO2max sample + // see cardio fitness without hunting through Settings → Dashboard; the + // tile self-gates on having any VO2_MAX sample (see `showVo2Tile` in + // page.tsx), so accounts with no sample still get a clean dashboard. + // The chart row stays off (`visible: false`) — VO2max charts live on + // the /insights/cardio-fitness and /insights/pulse sub-pages. + { id: "vo2Max", visible: false, tileVisible: true, order: 14 }, // v1.4.32 — Recent workouts dashboard tile. Default-visible so // brand-new accounts discover the workout surface without // hunting through Settings; the tile self-gates on a non-empty 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; };