mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Merge v189/coverage into release/v1.18.9
Make the VO2max dashboard tile visible by default; fetch and map the Oura vO2_max collection to VO2_MAX; derive a stress score from Oura daily_stress.
This commit is contained in:
commit
73b4037a1a
6 files changed
+261
-16
No files matched your search
@@ -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
|
||||
|
||||
+14
-10
@@ -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
|
||||
|
||||
@@ -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