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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 00:54:27 +02:00
1 parent 621b6734d0
commit 0f5d67033a
4 files changed
+121 -4

No files matched your search

+46
View File
@@ -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");
+4
View File
@@ -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);
+56
View File
@@ -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<OuraCardiovascularAge[]> {
return fetchCollection<OuraCardiovascularAge>(
"/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",
},
];
}
+15 -4
View File
@@ -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<OuraMeasurementUpsert[]> => {
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;
};