diff --git a/messages/de.json b/messages/de.json index e7a8a1dd0..f2047ee02 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4569,7 +4569,7 @@ "description": "Wie gut sich dein Körper erholt hat: die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.", "scoreCrossLink": "Den zusammengesetzten Erholungs-Score ansehen", "emptyTitle": "Noch keine Erholungsdaten", - "empty": "Noch keine Erholungsdaten. Verbinde ein WHOOP-, Polar- oder Oura-Konto, dann füllen sich diese Werte, sobald dein Gerät synchronisiert.", + "empty": "Hier ist noch nichts zu sehen. Diese Seite zeigt die Werte zu Aufladung, Belastung und Trainingslast, die WHOOP und Polar aufzeichnen, und Ouras Resilienz-Stufe. Ouras Readiness erscheint als dein Erholungs-Score, oben verlinkt.", "block": { "ansChargeExplainer": "Wie aufgeladen dein autonomes Nervensystem in den Tag startet, ein Recharge-Wert aus deinen nächtlichen Herz- und Atemsignalen.", "dayStrainExplainer": "Die kardiovaskuläre Tagesbelastung auf der Belastungsskala deines Geräts. Lies sie im Verhältnis zu deiner Erholung, nicht als Ziel für sich.", diff --git a/messages/en.json b/messages/en.json index 11a517a80..b747870e1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4569,7 +4569,7 @@ "description": "How well your body has bounced back: the recovery, strain and load signals your wearable records overnight.", "scoreCrossLink": "See the composite recovery score", "emptyTitle": "No recovery data yet", - "empty": "No recovery data yet. Connect a WHOOP, Polar or Oura account and these scores fill in once your device syncs.", + "empty": "Nothing to chart here yet. This page shows the recharge, strain and load readings that WHOOP and Polar record, and Oura's resilience level. Oura's readiness appears as your recovery score, linked above.", "block": { "ansChargeExplainer": "How charged your autonomic nervous system is for the day ahead, a recharge reading drawn from your overnight heart and breathing signals.", "dayStrainExplainer": "The day's whole-body cardiovascular load on your device's strain scale. Read it against your recovery, not as a goal on its own.", diff --git a/messages/es.json b/messages/es.json index 43c808b32..4e32fe8b7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4569,7 +4569,7 @@ "description": "Cómo se ha recuperado tu cuerpo: las señales de recuperación, esfuerzo y carga que tu wearable registra durante la noche.", "scoreCrossLink": "Ver la puntuación de recuperación combinada", "emptyTitle": "Aún no hay datos de recuperación", - "empty": "Aún no hay datos de recuperación. Conecta una cuenta de WHOOP, Polar u Oura y estas puntuaciones se completarán cuando tu dispositivo sincronice.", + "empty": "Aún no hay nada que mostrar aquí. Esta página muestra las lecturas de recarga, esfuerzo y carga que registran WHOOP y Polar, y el nivel de resiliencia de Oura. La preparación (readiness) de Oura aparece como tu puntuación de recuperación, enlazada arriba.", "block": { "ansChargeExplainer": "Cuán cargado está tu sistema nervioso autónomo para el día: una lectura de recarga obtenida de tus señales nocturnas de corazón y respiración.", "dayStrainExplainer": "La carga cardiovascular del día en la escala de esfuerzo de tu dispositivo. Léela en relación con tu recuperación, no como una meta en sí.", diff --git a/messages/fr.json b/messages/fr.json index 3f3de9ecd..f289f27d1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4569,7 +4569,7 @@ "description": "À quel point votre corps a récupéré : les signaux de récupération, de charge et d'effort que votre wearable enregistre la nuit.", "scoreCrossLink": "Voir le score de récupération composite", "emptyTitle": "Pas encore de données de récupération", - "empty": "Pas encore de données de récupération. Connectez un compte WHOOP, Polar ou Oura et ces scores se rempliront dès que votre appareil se synchronise.", + "empty": "Rien à afficher ici pour l'instant. Cette page montre les mesures de recharge, d'effort et de charge qu'enregistrent WHOOP et Polar, ainsi que le niveau de résilience d'Oura. La disponibilité (readiness) d'Oura apparaît comme votre score de récupération, en lien ci-dessus.", "block": { "ansChargeExplainer": "À quel point votre système nerveux autonome est rechargé pour la journée : une mesure de recharge issue de vos signaux nocturnes de cœur et de respiration.", "dayStrainExplainer": "La charge cardiovasculaire de la journée sur l'échelle d'effort de votre appareil. Lisez-la au regard de votre récupération, pas comme un objectif en soi.", diff --git a/messages/it.json b/messages/it.json index 4853184ed..8c7248c57 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4569,7 +4569,7 @@ "description": "Quanto bene il tuo corpo si è ripreso: i segnali di recupero, sforzo e carico che il tuo wearable registra durante la notte.", "scoreCrossLink": "Vedi il punteggio di recupero composito", "emptyTitle": "Ancora nessun dato di recupero", - "empty": "Ancora nessun dato di recupero. Collega un account WHOOP, Polar o Oura e questi punteggi si popoleranno appena il dispositivo si sincronizza.", + "empty": "Qui non c'è ancora nulla da mostrare. Questa pagina mostra i valori di ricarica, sforzo e carico che registrano WHOOP e Polar, e il livello di resilienza di Oura. La prontezza (readiness) di Oura compare come punteggio di recupero, collegato qui sopra.", "block": { "ansChargeExplainer": "Quanto è carico il tuo sistema nervoso autonomo per la giornata: una lettura di ricarica ricavata dai segnali notturni di cuore e respiro.", "dayStrainExplainer": "Il carico cardiovascolare della giornata sulla scala di sforzo del tuo dispositivo. Leggilo in rapporto al tuo recupero, non come obiettivo a sé.", diff --git a/messages/ko.json b/messages/ko.json index 122c25d92..2d8147b90 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4569,7 +4569,7 @@ "description": "몸이 얼마나 회복했는지: 웨어러블이 밤사이 기록한 회복·부하 신호예요.", "scoreCrossLink": "종합 회복 점수 보기", "emptyTitle": "아직 회복 데이터가 없어요", - "empty": "아직 회복 데이터가 없어요. WHOOP, Polar, Oura 계정을 연결하면 기기가 동기화될 때 점수가 채워져요.", + "empty": "아직 여기에 표시할 내용이 없어요. 이 페이지는 WHOOP과 Polar가 기록하는 충전, 부하, 운동량 값과 Oura의 회복 탄력성 단계를 보여줘요. Oura의 준비도(Readiness)는 위에 연결된 회복 점수에 표시돼요.", "block": { "ansChargeExplainer": "오늘 하루를 맞을 자율신경계의 충전 상태예요. 밤사이 심장·호흡 신호에서 읽어낸 재충전 지표예요.", "dayStrainExplainer": "기기의 부하 척도로 본 하루 전체의 심혈관 부하예요. 그 자체를 목표로 삼지 말고 회복과 견줘 보세요.", diff --git a/messages/pl.json b/messages/pl.json index 5907e3c21..db438f05c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4569,7 +4569,7 @@ "description": "Jak dobrze Twój organizm się zregenerował: sygnały regeneracji, obciążenia i load, które Twoje urządzenie rejestruje w nocy.", "scoreCrossLink": "Zobacz łączny wynik regeneracji", "emptyTitle": "Brak danych o regeneracji", - "empty": "Brak danych o regeneracji. Połącz konto WHOOP, Polar lub Oura, a te wyniki uzupełnią się po synchronizacji urządzenia.", + "empty": "Na razie nie ma tu nic do pokazania. Ta strona pokazuje odczyty ładowania, wysiłku i obciążenia, które rejestrują WHOOP i Polar, oraz poziom odporności z Oura. Gotowość (readiness) z Oura pojawia się jako wynik regeneracji, link powyżej.", "block": { "ansChargeExplainer": "Jak naładowany jest Twój autonomiczny układ nerwowy na dany dzień: odczyt regeneracji oparty na nocnych sygnałach serca i oddechu.", "dayStrainExplainer": "Dzienne obciążenie sercowo-naczyniowe na skali wysiłku Twojego urządzenia. Odczytuj je w odniesieniu do regeneracji, nie jako cel sam w sobie.", 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/components/insights/__tests__/device-score-surfaces.test.tsx b/src/components/insights/__tests__/device-score-surfaces.test.tsx index b97208e08..5b26ba04a 100644 --- a/src/components/insights/__tests__/device-score-surfaces.test.tsx +++ b/src/components/insights/__tests__/device-score-surfaces.test.tsx @@ -206,6 +206,12 @@ describe(" data-gating", () => { // old hand-rolled div — the empty title comes from the unified primitive. expect(html).toContain("border-dashed"); expect(html).not.toContain('data-slot="recovery-block-DAY_STRAIN"'); + // An Oura account sees this note too: it must say where Oura's readiness + // lives rather than tell a connected user to connect Oura. + expect(html).not.toContain("Connect a WHOOP, Polar or Oura account"); + expect(html).toContain( + "Oura's readiness appears as your recovery score", + ); }); it("renders a canonical chart block for the present signal", () => { diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index 3d5997189..cbc3dc4a1 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -316,6 +316,15 @@ export interface SleepNight { * "sources disagree" hint. */ sourceDiscrepancy: SleepSourceDiscrepancy | null; + /** + * The night's MAIN session alone: the same-wake-day session with the most + * asleep minutes (the one `sourceDiscrepancy` reads), its wake instant and + * its own asleep minutes. A nap on the same wake day is in the night's + * totals but not here, so a reader that places the night on the clock (the + * sleep midpoint) is not dragged into the afternoon by it. Null when no + * session carries asleep minutes. + */ + mainSession: { end: Date; asleepMinutes: number } | null; } /** Sentinel for rows that carry no source — collapses to one bucket. */ @@ -738,6 +747,13 @@ export function reconstructSleepNights( sourceDiscrepancy: mainSessionRows ? sessionSourceDiscrepancy(mainSessionRows) : null, + mainSession: + mainSessionRows && mainSessionAsleep > 0 + ? { + end: new Date(mainSessionEnd), + asleepMinutes: mainSessionAsleep, + } + : null, }); } return nights.sort((a, b) => (a.night < b.night ? -1 : 1)); diff --git a/src/lib/insights/derived/__tests__/sleep-score.test.ts b/src/lib/insights/derived/__tests__/sleep-score.test.ts index c464868a3..ef12bce2d 100644 --- a/src/lib/insights/derived/__tests__/sleep-score.test.ts +++ b/src/lib/insights/derived/__tests__/sleep-score.test.ts @@ -186,6 +186,21 @@ describe("reconstructNights", () => { expect(nights[0].midpoint).toBe(180); }); + it("places the midpoint on the main session, not on a nap the same wake day", () => { + // Overnight 23:00 to 07:00 (480 asleep minutes, midpoint 03:00) and an + // afternoon nap 14:00 to 15:00 that wakes on the same day. The night's + // totals carry the nap; its place on the clock must not. + const rows = night("2026-06-02", [ + ["CORE", 60, "00:00"], + ["CORE", 420, "07:00"], + ["CORE", 60, "15:00"], + ]); + const nights = reconstructNights(rows); + expect(nights).toHaveLength(1); + expect(nights[0].asleepMinutes).toBe(540); + expect(nights[0].midpoint).toBe(3 * 60); + }); + it("expresses the midpoint in the user's timezone when one is passed", () => { // Same 03:00Z midpoint, read in Asia/Kolkata (UTC+5:30, no DST) → // 08:30 local = 510 min-of-day, not 180. @@ -282,6 +297,39 @@ describe("reconstructNights", () => { }); describe("computeSleepScore", () => { + it("keeps consistency and timing on a steady sleeper who naps some afternoons", async () => { + // Seven nights 23:00 to 07:00, every other wake day with a 14:00 to 15:00 + // nap. The overnight rhythm never moves, so consistency and timing are + // perfect; reading the midpoint off the night's latest instant put the nap + // days about seven hours late and scored both at 0. + const rows: ReturnType = []; + for (let i = 0; i < 7; i += 1) { + const wake = new Date(Date.UTC(2026, 4, 27 + i)); + const day = wake.toISOString().slice(0, 10); + rows.push( + ...night(day, [ + ["IN_BED", 480, "07:00"], + ["CORE", 240, "03:00"], + ["DEEP", 120, "05:00"], + ["REM", 120, "07:00"], + ]), + ); + if (i % 2 === 0) rows.push(...night(day, [["CORE", 60, "15:00"]])); + } + findMany.mockResolvedValue(rows); + const result = await computeSleepScore("u1", PROFILE, { + now: new Date("2026-06-02T16:00:00Z"), + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + const byKey = Object.fromEntries( + result.value.subScores.map((sub) => [sub.key, sub.value]), + ); + expect(byKey.consistency).toBe(100); + expect(byKey.timing).toBe(100); + } + }); + it("returns insufficient when no sleep rows exist", async () => { findMany.mockResolvedValue([]); const result = await computeSleepScore("u1", PROFILE, { now: NOW }); diff --git a/src/lib/insights/derived/sleep-score.ts b/src/lib/insights/derived/sleep-score.ts index 6a00dd7ff..37c8fec6c 100644 --- a/src/lib/insights/derived/sleep-score.ts +++ b/src/lib/insights/derived/sleep-score.ts @@ -392,13 +392,17 @@ export function reconstructNights( : n.asleepMinutes > 0 && awakeMinutes > 0 ? n.asleepMinutes + awakeMinutes : null; - // Midpoint = centre of the asleep span. The canonical night's `measuredAt` - // is the wake instant (latest stage END); the asleep span is - // `asleepMinutes` long ending there, so its centre is wake − asleep/2. + // Midpoint = centre of the MAIN session's asleep span: its wake instant + // minus half its own asleep minutes. Not the whole night's: a nap on the + // same wake day moves the night's latest instant to the end of the nap and + // adds its minutes, which put the midpoint of a 23:00 to 07:00 night with a + // 14:00 nap at about 11:00 and read every such day as a ~7-hour shift in + // consistency and timing. + const main = n.mainSession; const midpoint = - n.asleepMinutes > 0 + main && main.asleepMinutes > 0 ? minutesOfDay( - new Date(n.measuredAt.getTime() - (n.asleepMinutes / 2) * 60_000), + new Date(main.end.getTime() - (main.asleepMinutes / 2) * 60_000), tz, ) : null; 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); + }); +});