From 35a33ca173cb80329a3fad4ea1c7425e6a784763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 15:10:18 +0200 Subject: [PATCH] fix(oura): skip rejected and deleted sleep periods Oura returns detected periods the person dismissed (type rest) and ones they deleted in the sleep collection. They were imported as sleep, with their lowest heart rate, HRV and breathing rate, which added an extra session to the day. They now map to nothing, and rows an earlier sync stored for such a period are soft-deleted on the next sync through the shared write tail. --- .../measurement-write-tail-guard.test.ts | 2 + src/lib/oura/__tests__/client.test.ts | 18 +++ src/lib/oura/__tests__/sync.test.ts | 69 +++++++++++ src/lib/oura/client.ts | 30 ++++- src/lib/oura/sync.ts | 63 ++++++++++ tests/integration/oura-retired-sleep.test.ts | 116 ++++++++++++++++++ 6 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 tests/integration/oura-retired-sleep.test.ts diff --git a/src/__tests__/measurement-write-tail-guard.test.ts b/src/__tests__/measurement-write-tail-guard.test.ts index 061f49718..4deacda2d 100644 --- a/src/__tests__/measurement-write-tail-guard.test.ts +++ b/src/__tests__/measurement-write-tail-guard.test.ts @@ -51,6 +51,8 @@ const WRITE_SITES: Record = { "lib/measurements/create-from-telegram.ts": TAIL, "lib/whoop/sync-body.ts": TAIL, "lib/whoop/webhook-handler.ts": TAIL, + // Retiring the rows of Oura sleep periods the person rejected or deleted. + "lib/oura/sync.ts": TAIL, // Integration syncs: each collects the (type, day) pairs it touched and // runs both legs itself at the end of the sync. "lib/fitbit/sync-core.ts": diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index ec294e1f9..b8e6b0893 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -330,6 +330,24 @@ describe("mapReadiness", () => { }); describe("mapSleep", () => { + it("maps a rejected (`rest`) or deleted period to nothing", () => { + const base: OuraSleep = { + id: "rec-r", + day: "2026-06-10", + bedtime_start: "2026-06-10T14:00:00.000Z", + bedtime_end: "2026-06-10T15:00:00.000Z", + sleep_phase_5_min: "222222222222", + lowest_heart_rate: 58, + average_hrv: 40, + }; + expect(mapSleep({ ...base, type: "rest" })).toEqual([]); + expect(mapSleep({ ...base, type: "deleted" })).toEqual([]); + // Periods Oura counts still map, a nap included. + for (const type of ["long_sleep", "sleep", "late_nap", undefined]) { + expect(mapSleep({ ...base, type }).length).toBeGreaterThan(0); + } + }); + it("falls back to stage totals (no hypnogram) and record-scopes the externalId", () => { const s: OuraSleep = { id: "rec-9", diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 691199cee..299f6e892 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -415,6 +415,75 @@ describe("syncUserOura", () => { ); }); + it("imports nothing for a rejected or deleted sleep period and retires what an earlier sync stored for it", async () => { + getConnMock.mockResolvedValue(CONN); + fetchSleepMock.mockResolvedValue([ + { + id: "rec-rest", + type: "rest", + day: "2026-06-10", + bedtime_start: "2026-06-10T14:00:00.000Z", + bedtime_end: "2026-06-10T15:00:00.000Z", + sleep_phase_5_min: "222222222222", + lowest_heart_rate: 58, + }, + { + id: "rec-del", + type: "deleted", + day: "2026-06-09", + bedtime_end: "2026-06-09T16:00:00.000Z", + light_sleep_duration: 1800, + }, + ]); + const stored = [ + { + id: "m1", + type: "SLEEP_DURATION", + measuredAt: new Date("2026-06-10T15:00:00.000Z"), + }, + { + id: "m2", + type: "RESTING_HEART_RATE", + measuredAt: new Date("2026-06-10T15:00:00.000Z"), + }, + ]; + findManyMock.mockResolvedValue(stored); + updateManyMock.mockResolvedValue({ count: 2 }); + + await syncUserOura("u1"); + + // Nothing of either period reaches the write path. + expect( + createdRows().filter((r) => String(r.externalId).startsWith("sleep:")), + ).toEqual([]); + // The rows of both periods are looked up by their record prefix, any type. + const lookup = findManyMock.mock.calls + .map((c) => c[0] as { where: Record }) + .find((a) => Array.isArray(a.where.OR)); + expect(lookup).toBeDefined(); + expect(lookup!.where).toMatchObject({ + userId: "u1", + source: "OURA", + deletedAt: null, + }); + expect(lookup!.where.type).toBeUndefined(); + expect(lookup!.where.OR).toEqual([ + { externalId: { startsWith: "sleep:rec-rest:" } }, + { externalId: { startsWith: "sleep:rec-del:" } }, + ]); + // ...and soft-deleted by id, then their rollup days recomputed. + const retire = updateManyMock.mock.calls + .map((c) => c[0] as { where: { id?: { in: string[] } }; data: unknown }) + .find((a) => a.where.id); + expect(retire?.where.id?.in).toEqual(["m1", "m2"]); + expect(retire?.data).toEqual({ deletedAt: expect.any(Date) }); + expect(recomputeMock).toHaveBeenCalledWith( + "u1", + "RESTING_HEART_RATE", + stored[1]!.measuredAt, + ); + }); + it("never sweeps when the sleep collection returns no records", async () => { getConnMock.mockResolvedValue(CONN); fetchReadinessMock.mockResolvedValue([ diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 44b9e9087..876b8d40b 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -309,7 +309,11 @@ export interface OuraCollection { export interface OuraSleep { id: string; day: string; - /** `long` | `short` | `nap` | `rest` — disambiguates a nap from the main sleep. */ + /** + * `long_sleep` | `sleep` | `late_nap` | `rest` | `deleted`. `rest` is a + * detected period the person rejected and `deleted` one they removed; see + * `isRetiredOuraSleepPeriod`. + */ type?: string | null; bedtime_start?: string; bedtime_end?: string; @@ -796,13 +800,35 @@ function mapSleepTimeline(s: OuraSleep): MappedMeasurement[] | null { } /** - * Map one Oura sleep record. When the real `sleep_phase_5_min` hypnogram is + * Oura sleep periods that are not sleep. Oura's `type` marks a detected period + * the person turned down in the app's confirm prompt as `rest` ("falsely + * detected sleep / nap, rejected by the user") and one they deleted as + * `deleted`; the sleep collection still returns both. Importing them made an + * afternoon on the sofa a second sleep session on that day, which added its + * minutes to the night and pulled the night's midpoint into the afternoon. + * `sleep`, `long_sleep` and `late_nap` are periods Oura counts, and an unknown + * or missing type stays in, as before. + */ +const RETIRED_OURA_SLEEP_TYPES: ReadonlySet = new Set([ + "rest", + "deleted", +]); + +/** True for an Oura sleep period the person rejected or deleted. */ +export function isRetiredOuraSleepPeriod(s: Pick): boolean { + return typeof s.type === "string" && RETIRED_OURA_SLEEP_TYPES.has(s.type); +} + +/** + * Map one Oura sleep record. A rejected or deleted period + * (`isRetiredOuraSleepPeriod`) maps to nothing. 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, tz?: string): MappedMeasurement[] { + if (isRetiredOuraSleepPeriod(s)) return []; const measuredAt = dayAnchor(s.day, tz, s.bedtime_end); if (!measuredAt) return []; const out: MappedMeasurement[] = []; diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index d9d764f53..51a785ef3 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -52,6 +52,7 @@ import { type InsertedMeasurementArrivalRow, } from "@/lib/arrivals/measurement-emit"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import type { SyncWriteResult } from "@/lib/outcome/written-outcome"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; @@ -72,6 +73,7 @@ import { fetchResilience, fetchSleep, fetchVo2Max, + isRetiredOuraSleepPeriod, mapCardiovascularAge, mapDailyActivity, mapDailySleep, @@ -245,6 +247,12 @@ export interface OuraFetchResult { * run-indexed `seg:` row — before the fresh set upserts. */ sleepSweeps: SleepSegmentSweep[]; + /** + * The `sleep::` prefix of every fetched sleep period Oura marks as + * rejected or deleted (`isRetiredOuraSleepPeriod`). Such a period maps to no + * rows, and whatever an earlier sync imported for it is retired. + */ + sleepRetirements: string[]; /** * Collections refused with 401 while a sibling succeeded with the same * token: the token is fine, the grant does not cover them. Not failures; the @@ -335,6 +343,7 @@ async function fetchAll( // upsert. A collection that throws pushes nothing (the fetch is its first // await), so a 401-retry cannot double-enter a record. const sleepSweeps: SleepSegmentSweep[] = []; + const sleepRetirements: string[] = []; const collections: OuraCollectionSpec[] = [ { @@ -354,6 +363,10 @@ async function fetchAll( const records = await fetchSleep(t, query); const ups: OuraMeasurementUpsert[] = []; for (const s of records) { + if (isRetiredOuraSleepPeriod(s)) { + if (s.id) sleepRetirements.push(`sleep:${s.id}:`); + continue; + } const rows = toUpsert( mapSleep(s, tz), "sleep", @@ -480,6 +493,7 @@ async function fetchAll( readings: first.readings, ...splitScopeDenied(first.failures, first.succeeded > 0), sleepSweeps, + sleepRetirements, }; } @@ -492,6 +506,7 @@ async function fetchAll( readings: first.readings, failures: first.failures, sleepSweeps, + sleepRetirements, scopeDenied: null, }; @@ -510,6 +525,7 @@ async function fetchAll( readings: first.readings, failures: first.failures, sleepSweeps, + sleepRetirements, scopeDenied: null, }; @@ -520,9 +536,55 @@ async function fetchAll( // proves the grant as much as one that succeeded after it. ...splitScopeDenied(retry.failures, first.succeeded + retry.succeeded > 0), sleepSweeps, + sleepRetirements, }; } +/** + * Retire what earlier syncs imported for sleep periods Oura now reports as + * rejected or deleted: every live OURA row keyed under the period's + * `sleep::` prefix, whatever its type, because the period's stage + * rows and its nightly scalars (lowest heart rate, HRV, breathing rate, + * efficiency) all came from a period that was not sleep. Soft delete, then the + * shared write tail refolds the touched days and re-warms the touched types. + * Best-effort like the segment sweep: a failure never fails the sync, and the + * record stays inside the lookback window, so the next run retries it. + */ +export async function retireOuraSleepRecords( + userId: string, + prefixes: readonly string[], +): Promise { + // A bare `sleep::` (no record id) would match nothing useful and must never + // widen to the whole source. + const bounded = prefixes.filter((p) => /^sleep:[^:]+:$/.test(p)); + if (bounded.length === 0) return 0; + try { + const rows = await prisma.measurement.findMany({ + where: { + userId, + source: "OURA", + deletedAt: null, + OR: bounded.map((prefix) => ({ externalId: { startsWith: prefix } })), + }, + select: { id: true, type: true, measuredAt: true }, + }); + if (rows.length === 0) return 0; + const res = await prisma.measurement.updateMany({ + where: { userId, id: { in: rows.map((r) => r.id) }, deletedAt: null }, + data: { deletedAt: new Date() }, + }); + await afterMeasurementMutation(userId, rows, "oura"); + invalidateUserMeasurements(userId); + annotate({ meta: { oura_sleep_retired_rows: res.count } }); + return res.count; + } catch (err) { + getEvent()?.addWarning( + `oura: retiring rejected sleep periods failed for ${userId}: ${err}`, + ); + return 0; + } +} + /** * Sync one user's Oura data. * @@ -589,6 +651,7 @@ export async function syncUserOura( // sync; the record stays inside the lookback window, so the next tick // retries it. await sweepStaleSleepSegments(userId, "OURA", result.sleepSweeps); + await retireOuraSleepRecords(userId, result.sleepRetirements); // Import everything the healthy collections returned regardless of whether a // sibling collection failed — one bad collection must not blank the source. diff --git a/tests/integration/oura-retired-sleep.test.ts b/tests/integration/oura-retired-sleep.test.ts new file mode 100644 index 000000000..8dfaf2e39 --- /dev/null +++ b/tests/integration/oura-retired-sleep.test.ts @@ -0,0 +1,116 @@ +/** + * Oura sleep periods the person rejected (`rest`) or deleted: against the + * real schema, retiring one soft-deletes every live OURA row under its record + * prefix, whatever the type, leaves a counted sleep period and the other + * sources alone, and recomputes the rollup day so the day stops counting the + * retired reading. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER = "user-oura-retired-sleep"; +const END = new Date(Date.UTC(2026, 5, 10, 15, 0, 0)); +const NIGHT_END = new Date(Date.UTC(2026, 5, 10, 7, 0, 0)); + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + await prisma.user.create({ + data: { + id: USER, + username: "oura-retired-sleep", + email: "oura-retired-sleep@example.test", + }, + }); + const row = ( + type: "SLEEP_DURATION" | "RESTING_HEART_RATE", + externalId: string, + value: number, + measuredAt: Date, + source: "OURA" | "APPLE_HEALTH" = "OURA", + ) => ({ + userId: USER, + type, + value, + unit: type === "SLEEP_DURATION" ? "minutes" : "bpm", + measuredAt, + source, + externalId, + sleepStage: type === "SLEEP_DURATION" ? ("CORE" as const) : null, + }); + await prisma.measurement.createMany({ + data: [ + // The rejected afternoon period: a stage row and its nightly scalar. + row( + "SLEEP_DURATION", + "sleep:rec-rest:seg:2026-06-10T14:00:00.000Z", + 60, + END, + ), + row("RESTING_HEART_RATE", "sleep:rec-rest:rhr", 58, END), + // The real night before it, same day. + row( + "SLEEP_DURATION", + "sleep:rec-night:seg:2026-06-09T23:00:00.000Z", + 480, + NIGHT_END, + ), + row("RESTING_HEART_RATE", "sleep:rec-night:rhr", 48, NIGHT_END), + // Another source's row whose id happens to share the prefix shape. + row("RESTING_HEART_RATE", "sleep:rec-rest:rhr", 52, END, "APPLE_HEALTH"), + ], + }); + await recomputeBucketsForMeasurement(USER, "RESTING_HEART_RATE", END); +}); + +describe("retireOuraSleepRecords", () => { + it("retires only the rejected period's OURA rows and recomputes the day", async () => { + const prisma = getPrismaClient(); + const { retireOuraSleepRecords } = await import("@/lib/oura/sync"); + + const before = await prisma.measurementRollup.findFirst({ + where: { + userId: USER, + type: "RESTING_HEART_RATE", + granularity: "DAY", + source: "OURA", + }, + }); + expect(before?.count).toBe(2); + + const removed = await retireOuraSleepRecords(USER, ["sleep:rec-rest:"]); + expect(removed).toBe(2); + + const live = await prisma.measurement.findMany({ + where: { userId: USER, deletedAt: null }, + select: { externalId: true, source: true }, + }); + const key = (r: { externalId: string | null; source: string }) => + `${r.source}|${r.externalId}`; + expect(live.sort((a, b) => key(a).localeCompare(key(b)))).toEqual([ + { externalId: "sleep:rec-rest:rhr", source: "APPLE_HEALTH" }, + { externalId: "sleep:rec-night:rhr", source: "OURA" }, + { + externalId: "sleep:rec-night:seg:2026-06-09T23:00:00.000Z", + source: "OURA", + }, + ]); + + const after = await prisma.measurementRollup.findFirst({ + where: { + userId: USER, + type: "RESTING_HEART_RATE", + granularity: "DAY", + source: "OURA", + }, + }); + expect(after?.count).toBe(1); + expect(after?.mean).toBe(48); + + // A malformed prefix never widens to the whole source. + expect(await retireOuraSleepRecords(USER, ["sleep::", "sleep:"])).toBe(0); + }); +});