From d6cb2f756fb4e92ef6212d45c89641e08eb9912b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 12:54:42 +0200 Subject: [PATCH] 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. --- src/lib/oura/__tests__/client.test.ts | 40 ------------------ src/lib/oura/__tests__/sync.test.ts | 16 ++----- src/lib/oura/client.ts | 61 ++------------------------- src/lib/oura/sync.ts | 17 ++++---- 4 files changed, 16 insertions(+), 118 deletions(-) diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index d1a87cbf0..81b86ab6a 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -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"); diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index c345fdcef..6ab30d4ac 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -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 () => { diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 6dece968c..35ca9c241 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -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 { - return fetchCollection( - "/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", - }, - ]; -} diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 3b95d8c5c..64726c859 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -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 => { - 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; };