mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
1 parent
621b6734d0
commit
0f5d67033a
4 files changed
+121
-4
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user