diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index 39a626a2f..7828d1146 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -7,10 +7,14 @@ import { getAuthorizationUrl, getOuraCredentials, mapDailyActivity, + mapDailySleep, + mapDailySpo2, mapReadiness, mapSleep, refreshAccessToken, type OuraDailyActivity, + type OuraDailySleep, + type OuraDailySpo2, type OuraReadiness, type OuraSleep, } from "../client"; @@ -122,18 +126,41 @@ describe("mapReadiness", () => { it("maps score -> RECOVERY_SCORE", () => { const r: OuraReadiness = { id: "1", day: "2026-06-10", score: 84 }; const mapped = mapReadiness(r); - expect(mapped).toHaveLength(1); - expect(mapped[0]).toMatchObject({ type: "RECOVERY_SCORE", value: 84, fieldTag: "recovery" }); + expect(mapped.find((m) => m.type === "RECOVERY_SCORE")).toMatchObject({ + value: 84, + fieldTag: "recovery", + }); }); - it("skips a record with no score", () => { + it("maps temperature_deviation -> BODY_TEMPERATURE_DEVIATION (signed °C)", () => { + const r: OuraReadiness = { + id: "1", + day: "2026-06-10", + score: 70, + temperature_deviation: -0.42, + }; + const mapped = mapReadiness(r); + const dev = mapped.find((m) => m.type === "BODY_TEMPERATURE_DEVIATION"); + expect(dev).toMatchObject({ value: -0.42, unit: "celsius", fieldTag: "temp_deviation" }); + }); + it("emits the temperature deviation even when the score is absent", () => { + const mapped = mapReadiness({ + id: "1", + day: "2026-06-10", + score: null, + temperature_deviation: 0.3, + }); + expect(mapped).toHaveLength(1); + expect(mapped[0].type).toBe("BODY_TEMPERATURE_DEVIATION"); + }); + it("skips a record with no score and no temperature", () => { expect(mapReadiness({ id: "1", day: "2026-06-10", score: null })).toEqual([]); }); }); describe("mapSleep", () => { - it("maps stages s->min, efficiency, hrv, rhr, breath", () => { + it("falls back to stage totals (no hypnogram) and record-scopes the externalId", () => { const s: OuraSleep = { - id: "1", + id: "rec-9", day: "2026-06-10", light_sleep_duration: 3600, deep_sleep_duration: 1800, @@ -151,22 +178,114 @@ describe("mapSleep", () => { expect(mapped.find((m) => m.type === "HRV_RMSSD")?.value).toBe(48); expect(mapped.find((m) => m.type === "RESTING_HEART_RATE")?.value).toBe(49); expect(mapped.find((m) => m.type === "RESPIRATORY_RATE")?.value).toBe(14.5); + // B2 — every sleep row is record-scoped so a nap never overwrites the main. + expect( + mapped.every((m) => m.externalId?.startsWith("sleep:rec-9:")), + ).toBe(true); + }); + + it("emits a real timed timeline from sleep_phase_5_min (reconstructed=false)", () => { + // 2× deep (1), 3× light (2), 1× rem (3), 1× awake (4) = 7 five-min intervals. + const s: OuraSleep = { + id: "rec-T", + day: "2026-06-10", + bedtime_start: "2026-06-09T23:00:00.000Z", + bedtime_end: "2026-06-10T07:00:00.000Z", + sleep_phase_5_min: "1122234", + }; + const mapped = mapSleep(s).filter((m) => m.type === "SLEEP_DURATION"); + // Four contiguous runs → four segments, in onset order. + expect(mapped.map((m) => m.sleepStage)).toEqual([ + "DEEP", + "CORE", + "REM", + "AWAKE", + ]); + // Run lengths × 5 min: 2→10, 3→15, 1→5, 1→5. + expect(mapped.map((m) => m.value)).toEqual([10, 15, 5, 5]); + // Each segment stamped at its own END instant relative to bedtime_start. + const onset = new Date("2026-06-09T23:00:00.000Z").getTime(); + expect(mapped[0].measuredAt.getTime()).toBe(onset + 10 * 60_000); + expect(mapped[1].measuredAt.getTime()).toBe(onset + 25 * 60_000); + // Distinct, record-scoped, segment-indexed externalIds (no collapse). + const ids = mapped.map((m) => m.externalId); + expect(new Set(ids).size).toBe(ids.length); + expect(ids[0]).toBe("sleep:rec-T:seg:0"); + }); + + it("ignores an empty / malformed hypnogram and uses stage totals", () => { + const s: OuraSleep = { + id: "rec-E", + day: "2026-06-10", + bedtime_start: "2026-06-09T23:00:00.000Z", + sleep_phase_5_min: "", + deep_sleep_duration: 1800, + }; + const mapped = mapSleep(s).filter((m) => m.type === "SLEEP_DURATION"); + expect(mapped).toHaveLength(1); + expect(mapped[0].sleepStage).toBe("DEEP"); + expect(mapped[0].fieldTag).toBe("sleep_deep"); }); }); describe("mapDailyActivity", () => { - it("maps steps and active energy", () => { + it("maps steps, active energy, and equivalent walking distance (m)", () => { const a: OuraDailyActivity = { id: "1", day: "2026-06-10", steps: 9001, active_calories: 412, + equivalent_walking_distance: 6543, }; const mapped = mapDailyActivity(a); expect(mapped.find((m) => m.type === "ACTIVITY_STEPS")?.value).toBe(9001); expect(mapped.find((m) => m.type === "ACTIVE_ENERGY_BURNED")?.value).toBe( 412, ); + const dist = mapped.find((m) => m.type === "WALKING_RUNNING_DISTANCE"); + expect(dist).toMatchObject({ value: 6543, unit: "m", fieldTag: "distance" }); + }); +}); + +describe("mapDailySleep", () => { + it("maps the Sleep Score -> SLEEP_SCORE", () => { + const d: OuraDailySleep = { id: "1", day: "2026-06-10", score: 82 }; + const mapped = mapDailySleep(d); + expect(mapped).toHaveLength(1); + expect(mapped[0]).toMatchObject({ + type: "SLEEP_SCORE", + value: 82, + unit: "score", + fieldTag: "sleep_score", + }); + }); + it("skips a record with no score", () => { + expect(mapDailySleep({ id: "1", day: "2026-06-10", score: null })).toEqual( + [], + ); + }); +}); + +describe("mapDailySpo2", () => { + it("maps the average SpO2 -> OXYGEN_SATURATION", () => { + const s: OuraDailySpo2 = { + id: "1", + day: "2026-06-10", + spo2_percentage: { average: 96.8 }, + }; + const mapped = mapDailySpo2(s); + expect(mapped).toHaveLength(1); + expect(mapped[0]).toMatchObject({ + type: "OXYGEN_SATURATION", + value: 96.8, + unit: "%", + fieldTag: "spo2", + }); + }); + it("skips a record with no average", () => { + expect( + mapDailySpo2({ id: "1", day: "2026-06-10", spo2_percentage: { average: null } }), + ).toEqual([]); }); }); diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index ebaf221d2..5aad8bfdb 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -7,6 +7,8 @@ const { fetchReadinessMock, fetchSleepMock, fetchActivityMock, + fetchDailySleepMock, + fetchSpo2Mock, refreshMock, upsertMock, recordSuccessMock, @@ -20,6 +22,8 @@ const { fetchReadinessMock: vi.fn(), fetchSleepMock: vi.fn(), fetchActivityMock: vi.fn(), + fetchDailySleepMock: vi.fn(), + fetchSpo2Mock: vi.fn(), refreshMock: vi.fn(), upsertMock: vi.fn(), recordSuccessMock: vi.fn(), @@ -61,6 +65,8 @@ vi.mock("../client", async (importOriginal) => { fetchReadiness: fetchReadinessMock, fetchSleep: fetchSleepMock, fetchDailyActivity: fetchActivityMock, + fetchDailySleep: fetchDailySleepMock, + fetchDailySpo2: fetchSpo2Mock, refreshAccessToken: refreshMock, }; }); @@ -79,6 +85,8 @@ beforeEach(() => { fetchReadinessMock.mockReset().mockResolvedValue([]); fetchSleepMock.mockReset().mockResolvedValue([]); fetchActivityMock.mockReset().mockResolvedValue([]); + fetchDailySleepMock.mockReset().mockResolvedValue([]); + fetchSpo2Mock.mockReset().mockResolvedValue([]); refreshMock.mockReset(); upsertMock.mockReset().mockResolvedValue({}); recordSuccessMock.mockReset().mockResolvedValue(undefined); @@ -159,6 +167,49 @@ describe("syncUserOura", () => { expect(recordSuccessMock).not.toHaveBeenCalled(); }); + it("keeps a nap and the main sleep distinct via record-scoped externalIds (B2)", async () => { + getConnMock.mockResolvedValue(CONN); + // Two sleep records on the same day: a main sleep + a nap. + fetchSleepMock.mockResolvedValue([ + { id: "main", day: "2026-06-10", deep_sleep_duration: 3600 }, + { id: "nap", day: "2026-06-10", deep_sleep_duration: 1200 }, + ]); + await syncUserOura("u1"); + const externalIds = upsertMock.mock.calls.map( + (c) => c[0].where.userId_type_source_externalId.externalId, + ); + expect(externalIds).toContain("sleep:main:sleep_deep"); + expect(externalIds).toContain("sleep:nap:sleep_deep"); + // The legacy day-keyed collapse would have produced one shared key. + expect(new Set(externalIds).size).toBe(externalIds.length); + }); + + it("writes the Sleep Score and SpO2 from the new collections", async () => { + getConnMock.mockResolvedValue(CONN); + fetchDailySleepMock.mockResolvedValue([ + { id: "s", day: "2026-06-10", score: 80 }, + ]); + fetchSpo2Mock.mockResolvedValue([ + { id: "o", day: "2026-06-10", spo2_percentage: { average: 97 } }, + ]); + 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: "SLEEP_SCORE", + externalId: "daily_sleep:2026-06-10:sleep_score", + value: 80, + }); + expect(written).toContainEqual({ + type: "OXYGEN_SATURATION", + externalId: "spo2:2026-06-10:spo2", + value: 97, + }); + }); + it("does NOT refresh on a 403 (not an expiry case)", async () => { getConnMock.mockResolvedValue(CONN); fetchReadinessMock.mockRejectedValue( diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index dcbf43cce..c6b01afe6 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -26,7 +26,12 @@ export interface OuraCredentials { clientSecret: string; } -/** Env-configured shared OAuth credentials (F4 = full OAuth from env). */ +/** + * Env-configured shared OAuth credentials — the FALLBACK app used when a user + * has not registered their own BYO Oura client id/secret. As of v1.17.1 the + * per-user resolver `getOuraClientCredentials` (DB-first) is the primary path + * and calls this only when no per-user pair is stored. + */ export function getOuraCredentials(): OuraCredentials | null { const clientId = process.env.OURA_CLIENT_ID; const clientSecret = process.env.OURA_CLIENT_SECRET; @@ -143,6 +148,9 @@ export interface OuraCollection { export interface OuraSleep { id: string; day: string; + /** `long` | `short` | `nap` | `rest` — disambiguates a nap from the main sleep. */ + type?: string | null; + bedtime_start?: string; bedtime_end?: string; total_sleep_duration?: number | null; time_in_bed?: number | null; @@ -155,6 +163,33 @@ export interface OuraSleep { lowest_heart_rate?: number | null; average_hrv?: number | null; average_breath?: number | null; + /** + * 5-minute hypnogram: one digit per 5-min interval from `bedtime_start`. + * Oura's measured per-stage timeline — `1`=deep, `2`=light, `3`=rem, + * `4`=awake. Re-verify the digit→stage mapping against + * `cloud.ouraring.com/v2/docs` at build time (the encoding is published in + * the `Sleep` model; cross-checked against the `oura-api`/`@pinta365/oura-api` + * client libraries). Unlike WHOOP — which exposes only stage totals and must + * synthesise an order — Oura gives the real onsets, so we emit timed + * per-segment rows (`reconstructed: false`). + */ + sleep_phase_5_min?: string | null; +} + +/** + * Oura `daily_readiness` contributors — each a 1–100 sub-score that explains + * the headline readiness number. All optional (a record may predate a + * contributor or carry a null for a missing input). + */ +export interface OuraReadinessContributors { + activity_balance?: number | null; + body_temperature?: number | null; + hrv_balance?: number | null; + previous_day_activity?: number | null; + previous_night?: number | null; + recovery_index?: number | null; + resting_heart_rate?: number | null; + sleep_balance?: number | null; } export interface OuraReadiness { @@ -162,6 +197,26 @@ export interface OuraReadiness { day: string; timestamp?: string; score?: number | null; + contributors?: OuraReadinessContributors | null; + /** Nightly body-temperature deviation from the personal baseline, °C (signed). */ + temperature_deviation?: number | null; + /** Long-term body-temperature trend deviation, °C (signed). */ + temperature_trend_deviation?: number | null; +} + +/** Oura `daily_sleep` — the headline Sleep Score (distinct from `sleep` detail). */ +export interface OuraDailySleep { + id: string; + day: string; + timestamp?: string; + score?: number | null; +} + +/** Oura `daily_spo2` — average overnight blood-oxygen saturation. */ +export interface OuraDailySpo2 { + id: string; + day: string; + spo2_percentage?: { average?: number | null } | null; } export interface OuraDailyActivity { @@ -171,6 +226,8 @@ export interface OuraDailyActivity { steps?: number | null; active_calories?: number | null; total_calories?: number | null; + /** Equivalent walking distance for the day, metres. */ + equivalent_walking_distance?: number | null; } interface DateRangeQuery { @@ -269,6 +326,30 @@ export function fetchDailyActivity( ); } +export function fetchDailySleep( + accessToken: string, + query: DateRangeQuery, +): Promise { + return fetchCollection( + "/v2/usercollection/daily_sleep", + accessToken, + "fetchDailySleep", + query, + ); +} + +export function fetchDailySpo2( + accessToken: string, + query: DateRangeQuery, +): Promise { + return fetchCollection( + "/v2/usercollection/daily_spo2", + accessToken, + "fetchDailySpo2", + query, + ); +} + // ─── Field → Measurement mapping ─────────────────────────────── const SEC_TO_MIN = 1 / 60; @@ -284,6 +365,15 @@ export interface MappedMeasurement { measuredAt: Date; fieldTag: string; sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE"; + /** + * Per-row externalId override. Most rows derive their externalId from the + * resource prefix + day + fieldTag in the sync layer, but the per-segment + * sleep-timeline rows (and any other row that needs a record-scoped key) + * carry their own here so several rows of one night stay distinct under + * `userId_type_source_externalId`. The sync layer uses `m.externalId ??` + * the default key. + */ + externalId?: string; } function dayAnchor(day: string, bedtimeEnd?: string): Date | null { @@ -294,48 +384,157 @@ function dayAnchor(day: string, bedtimeEnd?: string): Date | null { return Number.isNaN(d.getTime()) ? null : d; } -/** Map one Oura readiness record: `score` → `RECOVERY_SCORE` (source OURA, - * feeding the recovery ladder below WHOOP). */ +/** + * Map one Oura readiness record: + * - `score` → `RECOVERY_SCORE` (source OURA, feeding the recovery ladder + * below WHOOP). + * - `temperature_deviation` → `BODY_TEMPERATURE_DEVIATION` (signed °C offset + * from the personal baseline — illness / luteal-phase / stress signal). + * + * The eight readiness `contributors` are the *why* behind the score; they are + * carried back to the caller separately (not minted as Measurement rows here + * since each is a sub-score, not a stored metric) — see the dropped-field note. + * `temperature_trend_deviation` is the long-term trend twin; we keep only the + * nightly deviation as the actionable signal. + */ export function mapReadiness(r: OuraReadiness): MappedMeasurement[] { - if (typeof r.score !== "number") return []; const measuredAt = dayAnchor(r.day, r.timestamp); if (!measuredAt) return []; - return [ - { + const out: MappedMeasurement[] = []; + if (typeof r.score === "number") { + out.push({ type: "RECOVERY_SCORE", value: round2(r.score), unit: "score", measuredAt, fieldTag: "recovery", - }, - ]; + }); + } + if (typeof r.temperature_deviation === "number") { + out.push({ + type: "BODY_TEMPERATURE_DEVIATION", + value: round2(r.temperature_deviation), + unit: "celsius", + measuredAt, + fieldTag: "temp_deviation", + }); + } + return out; } -/** Map one Oura sleep record: per-stage durations (s→min), efficiency, HRV, - * RHR (lowest_heart_rate), respiratory rate. */ +/** + * Oura 5-minute hypnogram digit → our `SleepStage`. Oura encodes one digit per + * 5-min interval from `bedtime_start`: `1`=deep, `2`=light, `3`=rem, `4`=awake. + * Light maps to our CORE stage (the granular-asleep stage the sleep-night + * reader scores), matching the `light_sleep_duration → CORE` convention the + * stage-totals path already uses. Re-verify against `cloud.ouraring.com/v2/docs`. + */ +const HYPNOGRAM_STAGE: Record> = + { + "1": "DEEP", + "2": "CORE", + "3": "REM", + "4": "AWAKE", + }; + +/** Each hypnogram digit covers 5 minutes. */ +const HYPNOGRAM_INTERVAL_MIN = 5; +const HYPNOGRAM_INTERVAL_MS = HYPNOGRAM_INTERVAL_MIN * 60_000; + +/** + * Build timed per-segment `SLEEP_DURATION` rows from Oura's real measured + * `sleep_phase_5_min` hypnogram. Consecutive identical digits collapse into one + * segment; each segment is stamped at its own END instant (start + duration) + * relative to `bedtime_start`, so the sleep-night reader (which derives + * `start = end − value·60_000`) reconstructs the actual clock-time timeline — + * NOT the degraded all-on-`bedtime_end` shape. The rows are `reconstructed: + * false` by virtue of OURA being absent from the reader's + * `RECONSTRUCTED_TIMELINE_SOURCES` set (WHOOP only). Returns `null` when the + * hypnogram is absent / unusable so the caller falls back to stage totals. + */ +function mapSleepTimeline(s: OuraSleep): MappedMeasurement[] | null { + const hyp = s.sleep_phase_5_min; + if (typeof hyp !== "string" || hyp.length === 0 || !s.bedtime_start) { + return null; + } + const onset = new Date(s.bedtime_start); + if (Number.isNaN(onset.getTime())) return null; + + const out: MappedMeasurement[] = []; + let segIndex = 0; + let i = 0; + while (i < hyp.length) { + const digit = hyp[i]; + const stage = HYPNOGRAM_STAGE[digit]; + // Walk the run of identical digits. + let run = 1; + while (i + run < hyp.length && hyp[i + run] === digit) run += 1; + if (stage) { + const segStartMs = onset.getTime() + i * HYPNOGRAM_INTERVAL_MS; + const segEndMs = segStartMs + run * HYPNOGRAM_INTERVAL_MS; + const fieldTag = `seg:${segIndex}`; + out.push({ + type: "SLEEP_DURATION", + value: round2(run * HYPNOGRAM_INTERVAL_MIN), + unit: "minutes", + measuredAt: new Date(segEndMs), + fieldTag, + sleepStage: stage, + // Record-scoped + segment-indexed so a nap's segments never collide + // with the main sleep's and a re-fetch overwrites in place. + externalId: `sleep:${s.id}:${fieldTag}`, + }); + segIndex += 1; + } + i += run; + } + return out.length > 0 ? out : null; +} + +/** + * Map one Oura sleep record. When the real `sleep_phase_5_min` hypnogram is + * present we emit timed per-segment `SLEEP_DURATION` rows (Oura's measured + * timeline); otherwise we fall back to the four per-stage totals stamped on the + * sleep END. Either way we add efficiency, HRV, RHR (`lowest_heart_rate`), and + * respiratory rate. + */ export function mapSleep(s: OuraSleep): MappedMeasurement[] { const measuredAt = dayAnchor(s.day, s.bedtime_end); if (!measuredAt) return []; const out: MappedMeasurement[] = []; - const stages: Array<[number | null | undefined, MappedMeasurement["sleepStage"], string]> = [ - [s.light_sleep_duration, "CORE", "sleep_core"], - [s.deep_sleep_duration, "DEEP", "sleep_deep"], - [s.rem_sleep_duration, "REM", "sleep_rem"], - [s.awake_time, "AWAKE", "sleep_awake"], - ]; - for (const [sec, stage, fieldTag] of stages) { - if (typeof sec === "number" && sec >= 0) { - out.push({ - type: "SLEEP_DURATION", - value: round2(sec * SEC_TO_MIN), - unit: "minutes", - measuredAt, - fieldTag, - sleepStage: stage, - }); + const timeline = mapSleepTimeline(s); + if (timeline) { + out.push(...timeline); + } else { + // Fallback: no hypnogram — stamp the four stage totals on the sleep END. + const stages: Array< + [number | null | undefined, MappedMeasurement["sleepStage"], string] + > = [ + [s.light_sleep_duration, "CORE", "sleep_core"], + [s.deep_sleep_duration, "DEEP", "sleep_deep"], + [s.rem_sleep_duration, "REM", "sleep_rem"], + [s.awake_time, "AWAKE", "sleep_awake"], + ]; + for (const [sec, stage, fieldTag] of stages) { + if (typeof sec === "number" && sec >= 0) { + out.push({ + type: "SLEEP_DURATION", + value: round2(sec * SEC_TO_MIN), + unit: "minutes", + measuredAt, + fieldTag, + sleepStage: stage, + // Record-scoped so a nap and the main sleep on the same day don't + // collapse onto one key (B2) — the legacy day-keyed key did. + externalId: `sleep:${s.id}:${fieldTag}`, + }); + } } } + // The nightly scalars are record-scoped too (B2): a nap and the main sleep on + // the same day would otherwise overwrite each other's efficiency / HRV / RHR / + // respiratory rate on a shared day key. if (typeof s.efficiency === "number") { out.push({ type: "SLEEP_EFFICIENCY", @@ -343,6 +542,7 @@ export function mapSleep(s: OuraSleep): MappedMeasurement[] { unit: "%", measuredAt, fieldTag: "sleep_eff", + externalId: `sleep:${s.id}:sleep_eff`, }); } if (typeof s.average_hrv === "number" && s.average_hrv > 0) { @@ -352,6 +552,7 @@ export function mapSleep(s: OuraSleep): MappedMeasurement[] { unit: "ms", measuredAt, fieldTag: "hrv_rmssd", + externalId: `sleep:${s.id}:hrv_rmssd`, }); } if (typeof s.lowest_heart_rate === "number" && s.lowest_heart_rate > 0) { @@ -361,6 +562,7 @@ export function mapSleep(s: OuraSleep): MappedMeasurement[] { unit: "bpm", measuredAt, fieldTag: "rhr", + externalId: `sleep:${s.id}:rhr`, }); } if (typeof s.average_breath === "number" && s.average_breath > 0) { @@ -370,12 +572,49 @@ export function mapSleep(s: OuraSleep): MappedMeasurement[] { unit: "breaths/min", measuredAt, fieldTag: "resp_rate", + externalId: `sleep:${s.id}:resp_rate`, }); } return out; } -/** Map one Oura daily-activity record: steps + active energy. */ +/** Map one Oura daily-sleep record: the headline Sleep Score → `SLEEP_SCORE`. */ +export function mapDailySleep(d: OuraDailySleep): MappedMeasurement[] { + if (typeof d.score !== "number") return []; + const measuredAt = dayAnchor(d.day, d.timestamp); + if (!measuredAt) return []; + return [ + { + type: "SLEEP_SCORE", + value: round2(d.score), + unit: "score", + measuredAt, + fieldTag: "sleep_score", + }, + ]; +} + +/** Map one Oura daily-spo2 record: average overnight SpO2 → `OXYGEN_SATURATION`. */ +export function mapDailySpo2(s: OuraDailySpo2): MappedMeasurement[] { + const avg = s.spo2_percentage?.average; + if (typeof avg !== "number" || avg <= 0) return []; + // Anchor SpO2 at the day's UTC midnight (no per-record instant in the + // daily_spo2 collection); the recovery / rollup readers key off the local day. + const measuredAt = dayAnchor(s.day); + if (!measuredAt) return []; + return [ + { + type: "OXYGEN_SATURATION", + value: round2(avg), + unit: "%", + measuredAt, + fieldTag: "spo2", + }, + ]; +} + +/** Map one Oura daily-activity record: steps, active energy, and the + * equivalent walking distance (B5 — the OURA distance ladder slot was dead). */ export function mapDailyActivity(a: OuraDailyActivity): MappedMeasurement[] { const measuredAt = dayAnchor(a.day, a.timestamp); if (!measuredAt) return []; @@ -398,6 +637,20 @@ export function mapDailyActivity(a: OuraDailyActivity): MappedMeasurement[] { fieldTag: "active_energy", }); } + if ( + typeof a.equivalent_walking_distance === "number" && + a.equivalent_walking_distance >= 0 + ) { + out.push({ + type: "WALKING_RUNNING_DISTANCE", + // Oura reports the equivalent walking distance in metres — the canonical + // WALKING_RUNNING_DISTANCE unit (no conversion). + value: round2(a.equivalent_walking_distance), + unit: "m", + measuredAt, + fieldTag: "distance", + }); + } return out; } diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index efadb0808..73d0e2f33 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -1,17 +1,22 @@ /** * v1.17.0 (F4) — Oura Cloud v2 sync. * - * Pulls daily readiness (→ RECOVERY_SCORE), sleep (per-stage durations, - * efficiency, HRV, RHR, respiratory rate), and daily activity (steps + active - * energy) for one connected user, mapping each into `Measurement` rows tagged - * `source = OURA`. + * Pulls daily readiness (→ RECOVERY_SCORE + BODY_TEMPERATURE_DEVIATION), sleep + * (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`. * * Token model: Oura uses refresh tokens. The merged schema has no expiry * column, so the sync refreshes REACTIVELY — the first read that 401s triggers * one refresh (persisting BOTH rotated tokens) and a single retry. A failed * refresh (`invalid_grant`) records `reauth_required` on the `oura` ledger. * - * Idempotency: `externalId = ::`, upsert keyed on + * Idempotency: `externalId = ::` for the day-keyed + * collections; sleep rows carry a record-scoped `sleep::` + * key (per-segment timeline + nightly scalars) so a nap and the main sleep on + * one day stay distinct instead of overwriting each other (B2). Upsert keyed on * `(userId, type, source = OURA, externalId)`. Oura finalises a day's scores * after the night, so the `update` branch overwrites in place (re-score). * @@ -33,9 +38,13 @@ import { import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { fetchDailyActivity, + fetchDailySleep, + fetchDailySpo2, fetchReadiness, fetchSleep, mapDailyActivity, + mapDailySleep, + mapDailySpo2, mapReadiness, mapSleep, refreshAccessToken, @@ -79,7 +88,11 @@ function toUpsert( value: m.value, unit: m.unit, measuredAt: m.measuredAt, - externalId: `${resourcePrefix}:${ymd(m.measuredAt)}:${m.fieldTag}`, + // A mapper that needs a record-scoped key (sleep rows — per-segment timeline + // + nightly scalars) carries its own externalId; everything else falls back + // to the day-keyed `::` shape. + externalId: + m.externalId ?? `${resourcePrefix}:${ymd(m.measuredAt)}:${m.fieldTag}`, sleepStage: m.sleepStage ?? null, })); } @@ -101,15 +114,19 @@ async function fetchAll( const query = { startDate: ymd(start), endDate: ymd(now) }; const run = async (token: string): Promise => { - const [readiness, sleeps, activities] = await Promise.all([ + const [readiness, sleeps, activities, dailySleep, spo2] = await Promise.all([ fetchReadiness(token, query), fetchSleep(token, query), fetchDailyActivity(token, query), + fetchDailySleep(token, query), + fetchDailySpo2(token, query), ]); const out: OuraMeasurementUpsert[] = []; for (const r of readiness) out.push(...toUpsert(mapReadiness(r), "readiness")); for (const s of sleeps) out.push(...toUpsert(mapSleep(s), "sleep")); for (const a of activities) out.push(...toUpsert(mapDailyActivity(a), "activity")); + for (const d of dailySleep) out.push(...toUpsert(mapDailySleep(d), "daily_sleep")); + for (const s of spo2) out.push(...toUpsert(mapDailySpo2(s), "spo2")); return out; };