From 0f5d67033a94a6f7612a846b9d5b8752ccfaa6e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 00:54:27 +0200 Subject: [PATCH] feat(oura): ingest the daily cardiovascular-age estimate The daily_cardiovascular_age collection reports a years-unit arterial-age estimate that maps directly onto the existing VASCULAR_AGE metric, so no migration is needed. Resilience stays out: it is a categorical level with no numeric home and would require a new type plus a categorical column. --- src/lib/oura/__tests__/client.test.ts | 46 ++++++++++++++++++++++ src/lib/oura/__tests__/sync.test.ts | 4 ++ src/lib/oura/client.ts | 56 +++++++++++++++++++++++++++ src/lib/oura/sync.ts | 19 +++++++-- 4 files changed, 121 insertions(+), 4 deletions(-) diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index 81b86ab6a..07409fafd 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -3,10 +3,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { OURA_OAUTH_SCOPE, exchangeCode, + fetchCardiovascularAge, fetchReadiness, fetchVo2Max, getAuthorizationUrl, getOuraCredentials, + mapCardiovascularAge, mapDailyActivity, mapDailySleep, mapDailySpo2, @@ -14,6 +16,7 @@ import { mapSleep, mapVo2Max, refreshAccessToken, + type OuraCardiovascularAge, type OuraDailyActivity, type OuraDailySleep, type OuraDailySpo2, @@ -353,6 +356,49 @@ describe("mapVo2Max", () => { }); }); +describe("fetchCardiovascularAge", () => { + it("reads the daily_cardiovascular_age collection path", async () => { + const fetchMock = installFetchMock([ + { + status: 200, + body: { + data: [{ id: "1", day: "2026-06-10", vascular_age: 39 }], + next_token: null, + }, + }, + ]); + const r = await fetchCardiovascularAge("tok", { + startDate: "2026-06-01", + endDate: "2026-06-10", + }); + expect(r).toHaveLength(1); + const [url] = fetchMock.mock.calls[0]!; + expect(url).toContain("/v2/usercollection/daily_cardiovascular_age"); + }); +}); + +describe("mapCardiovascularAge", () => { + it("maps vascular_age -> VASCULAR_AGE (years)", () => { + const c: OuraCardiovascularAge = { day: "2026-06-10", vascular_age: 39 }; + const mapped = mapCardiovascularAge(c); + expect(mapped).toHaveLength(1); + expect(mapped[0]).toMatchObject({ + type: "VASCULAR_AGE", + value: 39, + unit: "years", + fieldTag: "vascular_age", + }); + }); + it("skips a record with no positive value", () => { + expect( + mapCardiovascularAge({ day: "2026-06-10", vascular_age: 0 }), + ).toEqual([]); + expect( + mapCardiovascularAge({ day: "2026-06-10", vascular_age: null }), + ).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 6ab30d4ac..8fac99762 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -10,6 +10,7 @@ const { fetchDailySleepMock, fetchSpo2Mock, fetchVo2MaxMock, + fetchCardioAgeMock, refreshMock, upsertMock, recordSuccessMock, @@ -26,6 +27,7 @@ const { fetchDailySleepMock: vi.fn(), fetchSpo2Mock: vi.fn(), fetchVo2MaxMock: vi.fn(), + fetchCardioAgeMock: vi.fn(), refreshMock: vi.fn(), upsertMock: vi.fn(), recordSuccessMock: vi.fn(), @@ -70,6 +72,7 @@ vi.mock("../client", async (importOriginal) => { fetchDailySleep: fetchDailySleepMock, fetchDailySpo2: fetchSpo2Mock, fetchVo2Max: fetchVo2MaxMock, + fetchCardiovascularAge: fetchCardioAgeMock, refreshAccessToken: refreshMock, }; }); @@ -91,6 +94,7 @@ beforeEach(() => { fetchDailySleepMock.mockReset().mockResolvedValue([]); fetchSpo2Mock.mockReset().mockResolvedValue([]); fetchVo2MaxMock.mockReset().mockResolvedValue([]); + fetchCardioAgeMock.mockReset().mockResolvedValue([]); refreshMock.mockReset(); upsertMock.mockReset().mockResolvedValue({}); recordSuccessMock.mockReset().mockResolvedValue(undefined); diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 35ca9c241..aebebbbb0 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -226,6 +226,19 @@ export interface OuraVo2Max { 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; +} + export interface OuraDailyActivity { id: string; day: string; @@ -375,6 +388,26 @@ export function fetchVo2Max( ); } +export function fetchCardiovascularAge( + accessToken: string, + query: DateRangeQuery, +): Promise { + return fetchCollection( + "/v2/usercollection/daily_cardiovascular_age", + accessToken, + "fetchCardiovascularAge", + query, + ); +} + +// Oura `daily_resilience` → no fitting enum. Resilience is a categorical level +// (limited / adequate / solid / strong / exceptional), not a numeric metric; +// HealthLog has no categorical enum + column for it (RECOVERY_SCORE / ANS_CHARGE +// are numeric scores, not a resilience band). Capturing it faithfully would +// need a new MeasurementType plus a categorical context column + CHECK +// constraint (a migration). Deferred until a migration lands — see the v1.18.11 +// backlog report. + // ─── Field → Measurement mapping ─────────────────────────────── const SEC_TO_MIN = 1 / 60; @@ -700,3 +733,26 @@ export function mapVo2Max(v: OuraVo2Max): MappedMeasurement[] { }, ]; } + +/** + * 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", + }, + ]; +} diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 64726c859..63df960f6 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -5,15 +5,21 @@ * (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), - * daily SpO2 (→ OXYGEN_SATURATION), and the dedicated vO2_max collection (→ - * VO2_MAX) for one connected user, mapping each into `Measurement` rows tagged - * `source = OURA`. + * daily SpO2 (→ OXYGEN_SATURATION), the dedicated vO2_max collection (→ + * VO2_MAX), and the daily cardiovascular-age estimate (→ VASCULAR_AGE) for one + * connected user, mapping each into `Measurement` rows tagged `source = OURA`. * * daily_stress → STRESS_SCORE is deferred: STRESS_SCORE already has an * HRV-derived COMPUTED producer that is not yet wired into the source-priority * ladder or the weekly graded-series collapse, so a second producer here would * double-count nondeterministically. Re-add once STRESS_SCORE is laddered. * + * daily_resilience is deferred: resilience is a categorical level (limited / + * adequate / solid / strong / exceptional), not a numeric metric, and HealthLog + * has no categorical enum + column for it. Capturing it faithfully needs a new + * MeasurementType plus a categorical context column + CHECK constraint (a + * migration) — out of scope for this migration-free release. + * * Token model: Oura uses refresh tokens. The merged schema has no expiry * column, so the sync refreshes REACTIVELY — the first read that 401s triggers * one refresh (persisting BOTH rotated tokens) and a single retry. A failed @@ -43,12 +49,14 @@ import { } from "@/lib/rollups/measurement-rollups"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { + fetchCardiovascularAge, fetchDailyActivity, fetchDailySleep, fetchDailySpo2, fetchReadiness, fetchSleep, fetchVo2Max, + mapCardiovascularAge, mapDailyActivity, mapDailySleep, mapDailySpo2, @@ -122,7 +130,7 @@ async function fetchAll( const query = { startDate: ymd(start), endDate: ymd(now) }; const run = async (token: string): Promise => { - const [readiness, sleeps, activities, dailySleep, spo2, vo2max] = + const [readiness, sleeps, activities, dailySleep, spo2, vo2max, cardioAge] = await Promise.all([ fetchReadiness(token, query), fetchSleep(token, query), @@ -130,6 +138,7 @@ async function fetchAll( fetchDailySleep(token, query), fetchDailySpo2(token, query), fetchVo2Max(token, query), + fetchCardiovascularAge(token, query), ]); const out: OuraMeasurementUpsert[] = []; for (const r of readiness) @@ -141,6 +150,8 @@ async function fetchAll( out.push(...toUpsert(mapDailySleep(d), "daily_sleep")); for (const s of spo2) out.push(...toUpsert(mapDailySpo2(s), "spo2")); for (const v of vo2max) out.push(...toUpsert(mapVo2Max(v), "vo2max")); + for (const c of cardioAge) + out.push(...toUpsert(mapCardiovascularAge(c), "cardio_age")); return out; };