mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(oura): ingest the vO2_max and daily_stress collections
The Oura v2 client fetched sleep, readiness, activity, daily sleep, and daily SpO2 but skipped the dedicated vO2_max collection and daily_stress. An Oura user got no VO2max from any path even though the enum, the cardio-fitness page, fitness-age, norms, and the LOINC code already exist. Add fetchVo2Max + mapVo2Max (vo2_max -> VO2_MAX, mL/(kg·min)) and fetchDailyStress + mapDailyStress. Oura exposes no headline stress score, only minutes in high stress vs high recovery, so derive a bounded 0-100 STRESS_SCORE from the high-stress share of the day's charged time. Both collections wire into the existing reactive-refresh sync tail; no migration, no schema change, no iOS-contract impact.
This commit is contained in:
1 parent
d9ed7961bb
commit
72b1d1ef8a
4 files changed
+232
-6
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<OuraDailyStress[]> {
|
||||
return fetchCollection<OuraDailyStress>(
|
||||
"/v2/usercollection/daily_stress",
|
||||
accessToken,
|
||||
"fetchDailyStress",
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchVo2Max(
|
||||
accessToken: string,
|
||||
query: DateRangeQuery,
|
||||
): Promise<OuraVo2Max[]> {
|
||||
// Oura camel-cases this path segment (`vO2_max`); every other collection is
|
||||
// snake_case. Matched verbatim against cloud.ouraring.com/v2/docs.
|
||||
return fetchCollection<OuraVo2Max>(
|
||||
"/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",
|
||||
},
|
||||
];
|
||||
}
|
||||
+14
-6
@@ -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<OuraMeasurementUpsert[]> => {
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user