fix(oura): withdraw daily_stress to STRESS_SCORE mapping

STRESS_SCORE already has an HRV-derived COMPUTED producer that is not
wired into the source-priority ladder or the weekly graded-series
collapse, so adding a second Oura producer caused nondeterministic
collapse and double-counting. Drop the daily_stress fetch/map/wiring and
leave a note to re-add once STRESS_SCORE is laddered. The vO2_max
mapping, which is correctly ladder-wired, stays intact.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 12:54:42 +02:00
1 parent 375d5c3ec5
commit d6cb2f756f
4 files changed
+16 -118

No files matched your search

-40
View File
@@ -10,7 +10,6 @@ import {
mapDailyActivity,
mapDailySleep,
mapDailySpo2,
mapDailyStress,
mapReadiness,
mapSleep,
mapVo2Max,
@@ -18,7 +17,6 @@ import {
type OuraDailyActivity,
type OuraDailySleep,
type OuraDailySpo2,
type OuraDailyStress,
type OuraReadiness,
type OuraSleep,
type OuraVo2Max,
@@ -355,44 +353,6 @@ describe("mapVo2Max", () => {
});
});
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");
+3 -13
View File
@@ -9,7 +9,6 @@ const {
fetchActivityMock,
fetchDailySleepMock,
fetchSpo2Mock,
fetchStressMock,
fetchVo2MaxMock,
refreshMock,
upsertMock,
@@ -26,7 +25,6 @@ const {
fetchActivityMock: vi.fn(),
fetchDailySleepMock: vi.fn(),
fetchSpo2Mock: vi.fn(),
fetchStressMock: vi.fn(),
fetchVo2MaxMock: vi.fn(),
refreshMock: vi.fn(),
upsertMock: vi.fn(),
@@ -71,7 +69,6 @@ vi.mock("../client", async (importOriginal) => {
fetchDailyActivity: fetchActivityMock,
fetchDailySleep: fetchDailySleepMock,
fetchDailySpo2: fetchSpo2Mock,
fetchDailyStress: fetchStressMock,
fetchVo2Max: fetchVo2MaxMock,
refreshAccessToken: refreshMock,
};
@@ -93,7 +90,6 @@ beforeEach(() => {
fetchActivityMock.mockReset().mockResolvedValue([]);
fetchDailySleepMock.mockReset().mockResolvedValue([]);
fetchSpo2Mock.mockReset().mockResolvedValue([]);
fetchStressMock.mockReset().mockResolvedValue([]);
fetchVo2MaxMock.mockReset().mockResolvedValue([]);
refreshMock.mockReset();
upsertMock.mockReset().mockResolvedValue({});
@@ -218,14 +214,11 @@ describe("syncUserOura", () => {
});
});
it("writes VO2_MAX and STRESS_SCORE from the new collections", async () => {
it("writes VO2_MAX from the dedicated collection", 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,
@@ -237,11 +230,8 @@ describe("syncUserOura", () => {
externalId: "vo2max:2026-06-10:vo2_max",
value: 47.3,
});
expect(written).toContainEqual({
type: "STRESS_SCORE",
externalId: "stress:2026-06-10:stress",
value: 75,
});
// daily_stress → STRESS_SCORE is withdrawn pending ladder wiring.
expect(written.some((w) => w.type === "STRESS_SCORE")).toBe(false);
});
it("does NOT refresh on a 403 (not an expiry case)", async () => {
+4 -57
View File
@@ -218,24 +218,6 @@ 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;
@@ -374,17 +356,10 @@ export function fetchDailySpo2(
);
}
export function fetchDailyStress(
accessToken: string,
query: DateRangeQuery,
): Promise<OuraDailyStress[]> {
return fetchCollection<OuraDailyStress>(
"/v2/usercollection/daily_stress",
accessToken,
"fetchDailyStress",
query,
);
}
// Oura `daily_stress` → STRESS_SCORE is deferred pending STRESS_SCORE
// source-priority + graded-series wiring (an HRV-derived COMPUTED producer
// already exists; a second producer here would double-count). The mapping is
// withdrawn until the ladder is in place.
export function fetchVo2Max(
accessToken: string,
@@ -725,31 +700,3 @@ export function mapVo2Max(v: OuraVo2Max): MappedMeasurement[] {
},
];
}
/**
* 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",
},
];
}
+9 -8
View File
@@ -5,9 +5,14 @@
* (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), daily stress (→ STRESS_SCORE), and the
* dedicated vO2_max collection (→ VO2_MAX) for one connected user, mapping each
* into `Measurement` rows tagged `source = OURA`.
* 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_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.
*
* Token model: Oura uses refresh tokens. The merged schema has no expiry
* column, so the sync refreshes REACTIVELY — the first read that 401s triggers
@@ -41,14 +46,12 @@ import {
fetchDailyActivity,
fetchDailySleep,
fetchDailySpo2,
fetchDailyStress,
fetchReadiness,
fetchSleep,
fetchVo2Max,
mapDailyActivity,
mapDailySleep,
mapDailySpo2,
mapDailyStress,
mapReadiness,
mapSleep,
mapVo2Max,
@@ -119,14 +122,13 @@ async function fetchAll(
const query = { startDate: ymd(start), endDate: ymd(now) };
const run = async (token: string): Promise<OuraMeasurementUpsert[]> => {
const [readiness, sleeps, activities, dailySleep, spo2, stress, vo2max] =
const [readiness, sleeps, activities, dailySleep, spo2, 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[] = [];
@@ -138,7 +140,6 @@ 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;
};