mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
375d5c3ec5
commit
d6cb2f756f
4 files changed
+16
-118
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user