From 8c745fca8d529d18724474077a745efd32cd6198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 19 Oct 2026 18:30:06 +0200 Subject: [PATCH 1/4] fix(sleep): treat a short night as the night, only a daytime nap leaves the clock Keeping a nap-only day off the sleep rhythm used three hours asleep as the line, which also took every shorter night off the clock: someone who sleeps two and a half hours a night (insomnia, a newborn, split sleep) lost consistency, timing, chronotype and the health score's regularity altogether. A session now leaves the clock only when it is short AND centred in the daytime, outside 20:00 to 10:00 local time. A short night keeps its place, a long daytime sleep after a night shift stays a main sleep, and the withheld-timing note now reads that the latest day had only a nap. Refs #1204 --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/ko.json | 2 +- messages/pl.json | 2 +- .../__tests__/sleep-score-oura-rhythm.test.ts | 49 +++++++++++++++ src/lib/insights/derived/sleep-score.ts | 63 ++++++++++++++----- 9 files changed, 102 insertions(+), 24 deletions(-) diff --git a/messages/de.json b/messages/de.json index 944bc9638..dbbebfa63 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4392,7 +4392,7 @@ }, "gap": { "fewNights": "noch nicht genug Nächte ({count} von 3)", - "noMainSleep": "letzte Nacht ohne Hauptschlaf" + "noMainSleep": "letzter Tag nur mit Nickerchen" } }, "READINESS": { diff --git a/messages/en.json b/messages/en.json index 261eed209..b2f276002 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4392,7 +4392,7 @@ }, "gap": { "fewNights": "not enough nights yet ({count} of 3)", - "noMainSleep": "latest night had no main sleep" + "noMainSleep": "latest day had only a nap" } }, "READINESS": { diff --git a/messages/es.json b/messages/es.json index 5b42d2099..54a3ebd85 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4392,7 +4392,7 @@ }, "gap": { "fewNights": "aún no hay suficientes noches ({count} de 3)", - "noMainSleep": "la última noche no tuvo sueño principal" + "noMainSleep": "el último día solo tuvo una siesta" } }, "READINESS": { diff --git a/messages/fr.json b/messages/fr.json index 46c71f58b..4bf94e642 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4392,7 +4392,7 @@ }, "gap": { "fewNights": "pas encore assez de nuits ({count} sur 3)", - "noMainSleep": "pas de sommeil principal la dernière nuit" + "noMainSleep": "le dernier jour n'a eu qu'une sieste" } }, "READINESS": { diff --git a/messages/it.json b/messages/it.json index dd8413a22..5656b9413 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4392,7 +4392,7 @@ }, "gap": { "fewNights": "notti ancora insufficienti ({count} su 3)", - "noMainSleep": "nessun sonno principale l'ultima notte" + "noMainSleep": "l'ultimo giorno solo un pisolino" } }, "READINESS": { diff --git a/messages/ko.json b/messages/ko.json index e3f8d67a8..ae222e3f6 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4392,7 +4392,7 @@ }, "gap": { "fewNights": "아직 밤이 부족함 ({count}/3)", - "noMainSleep": "최근 밤에 주 수면 없음" + "noMainSleep": "최근 하루는 낮잠만 있음" } }, "READINESS": { diff --git a/messages/pl.json b/messages/pl.json index 36cead91e..a8c699fd4 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4392,7 +4392,7 @@ }, "gap": { "fewNights": "za mało nocy ({count} z 3)", - "noMainSleep": "ostatniej nocy brak głównego snu" + "noMainSleep": "ostatni dzień tylko z drzemką" } }, "READINESS": { diff --git a/src/lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts b/src/lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts index 8137d42ae..76dc6704c 100644 --- a/src/lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts +++ b/src/lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts @@ -24,6 +24,7 @@ import { mapSleep, type OuraSleep } from "@/lib/oura/client"; import { computeSleepScore, reconstructNights, + isDaytimeNap, rhythmGap, scoreConsistency, } from "../sleep-score"; @@ -137,6 +138,54 @@ describe("Sleep Score rhythm on an Oura month", () => { expect(regular.midpoint).not.toBeNull(); }); + it("keeps a short night on the clock: 2.5 hours asleep is still the night", async () => { + // A month of short nights, 01:30 to 04:00 Berlin (23:30 to 02:00 UTC), + // the shape of a newborn's first weeks or of insomnia. + const rows = Array.from({ length: 20 }, (_, i) => { + const d = i + 10; + const bed = new Date(Date.UTC(2026, 8, d, 23, 30 + ((d * 7) % 20))); + return period(`short-${d}`, "sleep", bed, 150); + }).flatMap((p) => + mapSleep(p, TZ) + .filter((m) => m.type === "SLEEP_DURATION") + .map((m) => ({ + value: m.value, + measuredAt: m.measuredAt, + sleepStage: m.sleepStage ?? null, + source: "OURA", + deviceType: null, + })), + ); + const nights = reconstructNights(rows as never, TZ); + expect(nights.length).toBe(20); + for (const n of nights) { + expect(n.asleepMinutes).toBeLessThan(180); + expect(n.midpoint).not.toBeNull(); + } + findMany.mockResolvedValue(rows); + const result = await computeSleepScore("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("ok"); + if (result.status !== "ok") return; + expect(result.value.rhythmNights).toBe(20); + const sub = (k: string) => result.value.subScores.find((s) => s.key === k)!; + expect(sub("consistency").value).toBeGreaterThanOrEqual(70); + expect(sub("consistency").gap).toBeUndefined(); + expect(sub("timing").value).not.toBeNull(); + expect(sub("timing").gap).toBeUndefined(); + }); + + it("tells a nap from a night by length and time of day together", () => { + // Short and in the afternoon: a nap. + expect(isDaytimeNap(60, 14 * 60)).toBe(true); + // Short but at night: the night. + expect(isDaytimeNap(150, 2 * 60)).toBe(false); + expect(isDaytimeNap(150, 22 * 60)).toBe(false); + // A late sleeper's short night, centred at 09:00. + expect(isDaytimeNap(150, 9 * 60)).toBe(false); + // Long in the daytime (after a night shift): a main sleep. + expect(isDaytimeNap(420, 13 * 60)).toBe(false); + }); + it("scores the consistency of a steady sleeper, not 0", async () => { const result = await computeSleepScore("u1", PROFILE, { now: NOW }); expect(result.status).toBe("ok"); diff --git a/src/lib/insights/derived/sleep-score.ts b/src/lib/insights/derived/sleep-score.ts index b0ebd4f39..c9a7a6bf1 100644 --- a/src/lib/insights/derived/sleep-score.ts +++ b/src/lib/insights/derived/sleep-score.ts @@ -441,20 +441,26 @@ export function reconstructNights( // 14:00 nap at about 11:00 and read every such day as a ~7-hour shift in // consistency and timing. // - // And only when that main session is a main sleep. On a day the ring or - // watch recorded no night (left on the charger, taken off), the day's - // largest session is an afternoon nap, and its 14:00 midpoint sat ten - // hours from every other night: a single such day in a month pushed the - // midpoint spread past the 90-minute full scale and read the whole - // rhythm as Consistency 0. Such a day still counts its minutes; it just - // has no place on the clock. + // And only when that main session is a main sleep, not a daytime nap. On + // a day the ring or watch recorded no night (left on the charger, taken + // off), the day's largest session is an afternoon nap, and its 14:00 + // midpoint sat ten hours from every other night: a single such day in a + // month pushed the midpoint spread past the 90-minute full scale and read + // the whole rhythm as Consistency 0. Such a day still counts its minutes; + // it just has no place on the clock. A short session at night is still + // the night (insomnia, a newborn, split sleep), so it keeps its place. const main = n.mainSession; + const mainMidpoint = main + ? minutesOfDay( + new Date(main.end.getTime() - (main.asleepMinutes / 2) * 60_000), + tz, + ) + : null; const midpoint = - main && main.asleepMinutes >= MAIN_SLEEP_MIN_MINUTES - ? minutesOfDay( - new Date(main.end.getTime() - (main.asleepMinutes / 2) * 60_000), - tz, - ) + main && + mainMidpoint != null && + !isDaytimeNap(main.asleepMinutes, mainMidpoint) + ? mainMidpoint : null; return { night: n.night, @@ -470,14 +476,37 @@ export function reconstructNights( } /** - * The least asleep time a day's main session needs before its midpoint is a - * point on the sleep rhythm (consistency, timing, chronotype, regularity). - * Three hours is where wearables draw the line between a nap and a main - * sleep (Oura types a period `long_sleep` from three hours on), and well - * above any nap, while a short night still clears it. + * Below this much asleep time a session can be a nap. Three hours is where + * wearables draw the line between a nap and a main sleep (Oura types a + * period `long_sleep` from three hours on). */ const MAIN_SLEEP_MIN_MINUTES = 180; +/** + * The night window, in local minutes-of-day: a session whose midpoint falls + * from 20:00 to 10:00 is night sleep however short it is. Wide enough for a + * late chronotype (asleep 04:00 to 12:00 has its midpoint at 08:00) and for + * an early one, and clear of the afternoon where naps sit. + */ +const NIGHT_WINDOW_START = 20 * 60; +const NIGHT_WINDOW_END = 10 * 60; + +/** + * True for a session that is a nap rather than a night: short (under + * `MAIN_SLEEP_MIN_MINUTES` asleep) AND centred in the daytime. A short night + * is still the night; a long daytime sleep (after a night shift) is still a + * main sleep. Pure. + */ +export function isDaytimeNap( + asleepMinutes: number, + midpointMinutes: number, +): boolean { + if (asleepMinutes >= MAIN_SLEEP_MIN_MINUTES) return false; + const atNight = + midpointMinutes >= NIGHT_WINDOW_START || midpointMinutes < NIGHT_WINDOW_END; + return !atNight; +} + function minutesOfDay(d: Date, tz: string): number { if (tz === "UTC") return d.getUTCHours() * 60 + d.getUTCMinutes(); const { hour, minute } = wallClockInTz(d, tz); From c016ec8aecced0dd3e86d6c5bef93513cd561337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 19 Oct 2026 18:31:45 +0200 Subject: [PATCH 2/4] fix(recovery): claim no Oura permission the server cannot prove The empty recovery note told every connected Oura account without a recorded permission gap that its permission covers resilience. The server cannot know that: the scope note is cleared on each reconnect and written only by a sync that reached Oura, and a missing health block says nothing either way. The note now asks for a reconnect only when the gap is recorded and otherwise says only that no level has arrived, pointing at the Oura card. It also waits for the connection status instead of showing the general note first and swapping it. Refs #1204 --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/ko.json | 2 +- messages/pl.json | 2 +- .../__tests__/recovery-empty-state.test.tsx | 45 ++++++++++++++----- .../insights/recovery/recovery-section.tsx | 15 ++++--- 9 files changed, 50 insertions(+), 24 deletions(-) diff --git a/messages/de.json b/messages/de.json index dbbebfa63..d99c1821b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4662,7 +4662,7 @@ "emptyTitle": "Noch keine Erholungsdaten", "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.", "emptyOuraPermission": "Oura ist verbunden, aber die erteilte Berechtigung umfasst keine Herzgesundheitsdaten, und genau dahinter liegt Ouras Resilienz-Stufe. Verbinde Oura unter Einstellungen, Integrationen einmal neu und erlaube den Zugriff auf Herzgesundheit; die Stufe erscheint dann mit der nächsten Synchronisierung hier. Ouras Readiness ist bereits dein Erholungs-Score, oben verlinkt.", - "emptyOuraNoResilience": "Oura ist verbunden und die Berechtigung umfasst die Resilienz, aber Oura hat für dieses Konto noch keine Resilienz-Stufe geschickt. HealthLog fragt bei jeder Synchronisierung danach, sie erscheint hier, sobald Oura eine liefert. Ouras Readiness ist bereits dein Erholungs-Score, oben verlinkt.", + "emptyOuraNoResilience": "Oura ist verbunden, aber für dieses Konto ist noch keine Resilienz-Stufe angekommen. HealthLog fragt bei jeder Synchronisierung danach, sie erscheint hier, sobald Oura eine liefert. Zeigt die Oura-App eine Stufe, nennt die Oura-Karte unter Einstellungen, Integrationen alles, was die Verbindung nicht liefern kann. Ouras Readiness ist bereits dein Erholungs-Score, oben verlinkt.", "emptyAction": "Integrationen öffnen", "block": { "ansChargeExplainer": "Wie aufgeladen dein autonomes Nervensystem in den Tag startet, ein Recharge-Wert aus deinen nächtlichen Herz- und Atemsignalen.", diff --git a/messages/en.json b/messages/en.json index b2f276002..5eb6c6f94 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4662,7 +4662,7 @@ "emptyTitle": "No recovery data yet", "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.", "emptyOuraPermission": "Oura is connected, but the permission it was given leaves out heart-health data, and Oura's resilience level sits behind it. Reconnect Oura once under Settings, Integrations and allow heart-health access; the level then appears here with the next sync. Oura's readiness is already your recovery score, linked above.", - "emptyOuraNoResilience": "Oura is connected and the permission covers resilience, but Oura has not sent a resilience level for this account yet. HealthLog asks for it on every sync, so it appears here as soon as Oura provides one. Oura's readiness is already your recovery score, linked above.", + "emptyOuraNoResilience": "Oura is connected, but no resilience level has arrived for this account yet. HealthLog asks Oura for it on every sync, so it appears here as soon as Oura provides one. If the Oura app shows a level, the Oura card under Settings, Integrations names anything the connection cannot deliver. Oura's readiness is already your recovery score, linked above.", "emptyAction": "Open integrations", "block": { "ansChargeExplainer": "How charged your autonomic nervous system is for the day ahead, a recharge reading drawn from your overnight heart and breathing signals.", diff --git a/messages/es.json b/messages/es.json index 54a3ebd85..6427ccd2f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4662,7 +4662,7 @@ "emptyTitle": "Aún no hay datos de recuperación", "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.", "emptyOuraPermission": "Oura está conectado, pero el permiso concedido no incluye los datos de salud cardíaca, y el nivel de resiliencia de Oura depende de ellos. Vuelve a conectar Oura una vez en Ajustes, Integraciones y permite el acceso a la salud cardíaca; el nivel aparecerá aquí con la próxima sincronización. La preparación (readiness) de Oura ya es tu puntuación de recuperación, enlazada arriba.", - "emptyOuraNoResilience": "Oura está conectado y el permiso incluye la resiliencia, pero Oura todavía no ha enviado un nivel de resiliencia para esta cuenta. HealthLog lo pide en cada sincronización, así que aparecerá aquí en cuanto Oura lo proporcione. La preparación (readiness) de Oura ya es tu puntuación de recuperación, enlazada arriba.", + "emptyOuraNoResilience": "Oura está conectado, pero todavía no ha llegado ningún nivel de resiliencia para esta cuenta. HealthLog lo pide en cada sincronización, así que aparecerá aquí en cuanto Oura lo proporcione. Si la app de Oura muestra un nivel, la tarjeta de Oura en Ajustes, Integraciones indica todo lo que la conexión no puede entregar. La preparación (readiness) de Oura ya es tu puntuación de recuperación, enlazada arriba.", "emptyAction": "Abrir integraciones", "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.", diff --git a/messages/fr.json b/messages/fr.json index 4bf94e642..50ee3f224 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4662,7 +4662,7 @@ "emptyTitle": "Pas encore de données de récupération", "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.", "emptyOuraPermission": "Oura est connecté, mais l'autorisation accordée n'inclut pas les données de santé cardiaque, dont dépend le niveau de résilience d'Oura. Reconnectez Oura une fois dans Réglages, Intégrations et autorisez l'accès à la santé cardiaque ; le niveau apparaîtra ici à la prochaine synchronisation. La disponibilité (readiness) d'Oura est déjà votre score de récupération, en lien ci-dessus.", - "emptyOuraNoResilience": "Oura est connecté et l'autorisation couvre la résilience, mais Oura n'a pas encore envoyé de niveau de résilience pour ce compte. HealthLog le demande à chaque synchronisation, il apparaîtra ici dès qu'Oura en fournira un. La disponibilité (readiness) d'Oura est déjà votre score de récupération, en lien ci-dessus.", + "emptyOuraNoResilience": "Oura est connecté, mais aucun niveau de résilience n'est encore arrivé pour ce compte. HealthLog le demande à chaque synchronisation, il apparaîtra ici dès qu'Oura en fournira un. Si l'app Oura affiche un niveau, la carte Oura dans Réglages, Intégrations indique tout ce que la connexion ne peut pas fournir. La disponibilité (readiness) d'Oura est déjà votre score de récupération, en lien ci-dessus.", "emptyAction": "Ouvrir les intégrations", "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.", diff --git a/messages/it.json b/messages/it.json index 5656b9413..009452201 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4662,7 +4662,7 @@ "emptyTitle": "Ancora nessun dato di recupero", "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.", "emptyOuraPermission": "Oura è collegato, ma l'autorizzazione concessa non include i dati sulla salute cardiaca, da cui dipende il livello di resilienza di Oura. Ricollega Oura una volta in Impostazioni, Integrazioni e consenti l'accesso alla salute cardiaca; il livello comparirà qui con la prossima sincronizzazione. La prontezza (readiness) di Oura è già il tuo punteggio di recupero, collegato qui sopra.", - "emptyOuraNoResilience": "Oura è collegato e l'autorizzazione copre la resilienza, ma Oura non ha ancora inviato un livello di resilienza per questo account. HealthLog lo richiede a ogni sincronizzazione, quindi comparirà qui non appena Oura ne fornirà uno. La prontezza (readiness) di Oura è già il tuo punteggio di recupero, collegato qui sopra.", + "emptyOuraNoResilience": "Oura è collegato, ma per questo account non è ancora arrivato alcun livello di resilienza. HealthLog lo richiede a ogni sincronizzazione, quindi comparirà qui non appena Oura ne fornirà uno. Se l'app Oura mostra un livello, la scheda Oura in Impostazioni, Integrazioni indica tutto ciò che la connessione non può fornire. La prontezza (readiness) di Oura è già il tuo punteggio di recupero, collegato qui sopra.", "emptyAction": "Apri integrazioni", "block": { "ansChargeExplainer": "Quanto è carico il tuo sistema nervoso autonomo per la giornata: una lettura di ricarica ricavata dai segnali notturni di cuore e respiro.", diff --git a/messages/ko.json b/messages/ko.json index ae222e3f6..d900582ff 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4662,7 +4662,7 @@ "emptyTitle": "아직 회복 데이터가 없어요", "empty": "아직 여기에 표시할 내용이 없어요. 이 페이지는 WHOOP과 Polar가 기록하는 충전, 부하, 운동량 값과 Oura의 회복 탄력성 단계를 보여줘요. Oura의 준비도(Readiness)는 위에 연결된 회복 점수에 표시돼요.", "emptyOuraPermission": "Oura는 연결되어 있지만 허용된 권한에 심장 건강 데이터가 빠져 있어요. Oura의 회복 탄력성 단계는 바로 이 데이터에 속해요. 설정, 통합에서 Oura를 한 번 다시 연결하고 심장 건강 접근을 허용하면 다음 동기화 때 여기에 단계가 표시돼요. Oura의 준비도(Readiness)는 이미 위에 연결된 회복 점수에 표시돼요.", - "emptyOuraNoResilience": "Oura가 연결되어 있고 권한에도 회복 탄력성이 포함되어 있지만, Oura가 아직 이 계정의 회복 탄력성 단계를 보내지 않았어요. HealthLog는 동기화할 때마다 요청하므로 Oura가 보내는 즉시 여기에 표시돼요. Oura의 준비도(Readiness)는 이미 위에 연결된 회복 점수에 표시돼요.", + "emptyOuraNoResilience": "Oura가 연결되어 있지만 이 계정의 회복 탄력성 단계가 아직 도착하지 않았어요. HealthLog는 동기화할 때마다 요청하므로 Oura가 보내는 즉시 여기에 표시돼요. Oura 앱에 단계가 보인다면 설정, 통합의 Oura 카드에서 이 연결이 가져올 수 없는 항목을 확인할 수 있어요. Oura의 준비도(Readiness)는 이미 위에 연결된 회복 점수에 표시돼요.", "emptyAction": "통합 열기", "block": { "ansChargeExplainer": "오늘 하루를 맞을 자율신경계의 충전 상태예요. 밤사이 심장·호흡 신호에서 읽어낸 재충전 지표예요.", diff --git a/messages/pl.json b/messages/pl.json index a8c699fd4..1c83b7121 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4662,7 +4662,7 @@ "emptyTitle": "Brak danych o regeneracji", "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.", "emptyOuraPermission": "Oura jest połączona, ale udzielone uprawnienie nie obejmuje danych o zdrowiu serca, od których zależy poziom odporności z Oura. Połącz Oura ponownie raz w Ustawieniach, Integracjach i zezwól na dostęp do zdrowia serca; poziom pojawi się tutaj przy następnej synchronizacji. Gotowość (readiness) z Oura jest już Twoim wynikiem regeneracji, link powyżej.", - "emptyOuraNoResilience": "Oura jest połączona, a uprawnienie obejmuje odporność, ale Oura nie przesłała jeszcze poziomu odporności dla tego konta. HealthLog pyta o niego przy każdej synchronizacji, więc pojawi się tutaj, gdy tylko Oura go dostarczy. Gotowość (readiness) z Oura jest już Twoim wynikiem regeneracji, link powyżej.", + "emptyOuraNoResilience": "Oura jest połączona, ale dla tego konta nie dotarł jeszcze żaden poziom odporności. HealthLog pyta o niego przy każdej synchronizacji, więc pojawi się tutaj, gdy tylko Oura go dostarczy. Jeśli aplikacja Oura pokazuje poziom, karta Oura w Ustawieniach, Integracjach wymienia wszystko, czego połączenie nie może dostarczyć. Gotowość (readiness) z Oura jest już Twoim wynikiem regeneracji, link powyżej.", "emptyAction": "Otwórz integracje", "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.", diff --git a/src/components/insights/__tests__/recovery-empty-state.test.tsx b/src/components/insights/__tests__/recovery-empty-state.test.tsx index 2fb6d9358..06f4f5240 100644 --- a/src/components/insights/__tests__/recovery-empty-state.test.tsx +++ b/src/components/insights/__tests__/recovery-empty-state.test.tsx @@ -9,10 +9,10 @@ import type { DataSummary } from "@/lib/analytics/trends"; /** * The recovery page's empty note says what is missing for THIS account. An * Oura ring fills the page through its resilience level alone, so a connected - * ring with an empty page is either a grant that leaves out the heart-health - * data the level sits behind, or Oura not having sent a level yet. The - * connection facts come from the server's integrations read; the page only - * picks the sentence. + * ring with an empty page asks for a reconnect only when the server recorded + * that the grant leaves resilience out, and otherwise says only that no + * level has arrived. The connection facts come from the server's + * integrations read; the page only picks the sentence, and waits for it. */ vi.mock("next/dynamic", () => ({ @@ -57,14 +57,19 @@ const OURA_SUMMARIES = { HRV_RMSSD: summary(30), }; -function render(oura: Record | null) { +function render( + oura: Record | null, + opts: { statusLoaded?: boolean } = {}, +) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } }, }); - queryClient.setQueryData(queryKeys.integrationsStatus(), { - threshold: 3, - integrations: oura ? [{ integration: "oura", ...oura }] : [], - }); + if (opts.statusLoaded !== false) { + queryClient.setQueryData(queryKeys.integrationsStatus(), { + threshold: 3, + integrations: oura ? [{ integration: "oura", ...oura }] : [], + }); + } return renderToStaticMarkup( @@ -104,16 +109,34 @@ describe(" empty note", () => { expect(html).toContain('href="/settings/integrations"'); }); - it("says Oura has sent no level yet when the grant covers it", () => { + it("says only that no level has arrived when no withheld permission is recorded", () => { const html = render({ connected: true, health: health([{ item: "spo2", cause: "permission" }]), }); expect(html).toContain('data-empty-case="oura-no-resilience"'); - expect(html).toContain("has not sent a resilience level"); + expect(html).toContain("no resilience level has arrived"); + // The server cannot prove the grant covers resilience, so the note + // never says it does. + expect(html).not.toContain("permission covers"); expect(html).not.toContain('href="/settings/integrations"'); }); + it("stays neutral when the connection carries no health block", () => { + const html = render({ connected: true }); + expect(html).toContain('data-empty-case="oura-no-resilience"'); + expect(html).not.toContain("permission covers"); + }); + + it("shows no note until the connection status has loaded", () => { + const html = render( + { connected: true, health: health([]) }, + { statusLoaded: false }, + ); + expect(html).toContain('data-slot="recovery-loading"'); + expect(html).not.toContain("data-empty-case"); + }); + it("keeps the general note for an account without Oura", () => { const html = render(null); expect(html).toContain('data-empty-case="general"'); diff --git a/src/components/insights/recovery/recovery-section.tsx b/src/components/insights/recovery/recovery-section.tsx index f916fd1df..4a2718ade 100644 --- a/src/components/insights/recovery/recovery-section.tsx +++ b/src/components/insights/recovery/recovery-section.tsx @@ -49,11 +49,12 @@ interface BlockGroup { /** * Why this account's recovery page is empty, from the server's view of its * Oura connection. An Oura ring feeds this page through its resilience level - * alone, so a connected ring with an empty page has exactly two causes: the - * grant does not cover the heart-health data the level sits behind (the - * server records that per collection and publishes it as an unavailable - * item), or Oura has not sent a level for this account. Everyone else gets - * the general note naming what fills the page. + * alone. When the server records that the grant leaves resilience out, the + * note asks for one reconnect. Otherwise it says only that no level has + * arrived: the server cannot tell "the grant covers it" from "not recorded + * yet" (the scope note is cleared on every reconnect and written only by a + * sync that reached Oura), so the note never claims coverage. Everyone else + * gets the general note naming what fills the page. */ type RecoveryEmptyCase = "general" | "oura-permission" | "oura-no-resilience"; @@ -222,7 +223,9 @@ export function RecoverySection() { ); const emptyCase = recoveryEmptyCase(pickStatus(statuses.data, "oura")); - if (isLoading || summaries == null) { + // The empty note waits for the connection status, so it never reads as + // the general note first and then changes under the reader. + if (isLoading || summaries == null || (!hasAny && statuses.isPending)) { return (
From 44d8181cb4b25211e1a4203dab96207c92497282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 19 Oct 2026 18:56:06 +0200 Subject: [PATCH 3/4] docs(openapi): publish the sleep score value The derived envelope types `value` as an open record, so the sleep score's `rhythmNights` and per-sub-score `gap` were on the wire but in no schema. `SleepScoreValue` joins `WellnessScoreValue` in the forced components slot, the envelope's `value` description names it, and a contract test holds the published field set to the one the engine emits, for a scored rhythm and for one withheld for too few nights. Refs #1204 --- docs/api/openapi.yaml | 96 +++++++++++++++++- .../derived-sleep-value-contract.test.ts | 99 +++++++++++++++++++ src/lib/openapi/routes/index.ts | 5 +- src/lib/openapi/routes/insights/index.ts | 2 +- src/lib/openapi/routes/insights/schemas.ts | 74 +++++++++++++- 5 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/derived-sleep-value-contract.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 626d0e6b9..6b374462f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -64561,7 +64561,8 @@ components: `metric`, and the record stays open because one route serves eighteen of them. `RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in `components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to - discover by inspecting a live payload. `VITALS_BASELINE` returns { type, center, low, high, spread, + discover by inspecting a live payload. `SLEEP_SCORE` returns the `SleepScoreValue` schema, including + `rhythmNights` and the per-sub-score `gap`. `VITALS_BASELINE` returns { type, center, low, high, spread, sampleDays, k, series, direction }, where `series` is the trailing per-day means for the inline sparkline and `direction` (v1.43, a `TrendVerdict`) says which way a change of the vital counts for this person, with `center` placed against their own target or a profile default. The remaining metrics' value objects are not @@ -88135,6 +88136,99 @@ components: - asOf - series additionalProperties: false + SleepScoreValue: + type: object + properties: + score: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: The composite 0–100 sleep score. + band: + type: string + enum: + - green + - yellow + - red + description: Server's verdict on the score; never re-band it client-side. + night: + type: string + description: Wake day (YYYY-MM-DD, user's zone) of the night scored. + asleepMinutes: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Whole minutes asleep that night. + inBedMinutes: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Whole minutes in bed, null when no in-bed signal exists. + subScores: + type: array + items: + type: object + properties: + key: + type: string + enum: + - sufficiency + - efficiency + - consistency + - timing + - composition + description: Sub-score id. + value: + description: 0–100 sub-score; null when the data cannot support it (dropped from the blend, never 0). + type: + - number + - "null" + weight: + type: number + description: Effective weight after redistributing missing sub-scores. + gap: + description: "Only on a null `consistency` / `timing`: why it is withheld. `few_nights` — fewer than three nights in the + window have a main sleep on the clock (count in `rhythmNights`); `no_main_sleep` — the latest day had + only a daytime nap. Absent otherwise." + type: string + enum: + - few_nights + - no_main_sleep + required: + - key + - value + - weight + additionalProperties: false + description: All five sub-scores, present and dropped. + windowNights: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Scorable nights in the window. + rhythmNights: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Nights in the window with a main sleep placed on the clock — what consistency and timing are computed from. + A daytime nap is not one; a short night is. + series: + type: array + items: + type: number + description: Score of every scorable night in the window, oldest → newest, capped at 30 points; the last point is `score`. + required: + - score + - band + - night + - asleepMinutes + - inBedMinutes + - subScores + - windowNights + - rhythmNights + - series + additionalProperties: false parameters: AccountSelector: name: X-HealthLog-Account diff --git a/src/__tests__/derived-sleep-value-contract.test.ts b/src/__tests__/derived-sleep-value-contract.test.ts new file mode 100644 index 000000000..f2fa79766 --- /dev/null +++ b/src/__tests__/derived-sleep-value-contract.test.ts @@ -0,0 +1,99 @@ +/** + * The published `SleepScoreValue` schema against the value the sleep score + * engine actually returns, in both shapes a client meets: a scored rhythm, + * and a rhythm withheld for too few nights (where `gap` appears). A field + * added to the engine and not to the schema, or the reverse, fails here + * rather than shipping as a spec that describes a payload nobody sends. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn() }, + user: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); + +import { prisma } from "@/lib/db"; +import { computeSleepScore } from "@/lib/insights/derived/sleep-score"; +import { sleepScoreValue } from "@/lib/openapi/routes/insights/schemas"; +import { openApiComponents } from "@/lib/openapi/routes"; + +const findMany = prisma.measurement.findMany as ReturnType; +const NOW = new Date("2026-10-01T09:00:00Z"); +const PROFILE = { ageYears: 40, sex: "MALE" as const }; + +/** `nights` overnight sessions with stages, the newest ending this morning. */ +function rows(nights: number) { + const out = []; + for (let i = 0; i < nights; i += 1) { + const wake = Date.UTC(2026, 9, 1 - i, 6, 0); + const segs: Array<[string, number]> = [ + ["CORE", 180], + ["DEEP", 90], + ["REM", 120], + ["AWAKE", 20], + ["CORE", 60], + ]; + let end = wake - segs.reduce((s, [, m]) => s + m, 0) * 60_000; + for (const [stage, minutes] of segs) { + end += minutes * 60_000; + out.push({ + value: minutes, + measuredAt: new Date(end), + sleepStage: stage, + source: "OURA", + deviceType: null, + }); + } + } + return out.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()); +} + +function expectExactShape(value: unknown) { + const parsed = sleepScoreValue.safeParse(value); + expect(parsed.error?.issues ?? []).toEqual([]); + expect(Object.keys(sleepScoreValue.shape).sort()).toEqual( + Object.keys(value as Record).sort(), + ); +} + +beforeEach(() => { + findMany.mockReset(); +}); + +describe("published SleepScoreValue", () => { + it("is registered as a component even though nothing $refs it", () => { + expect(openApiComponents.schemas?.SleepScoreValue).toBe(sleepScoreValue); + }); + + it("matches a scored night field for field", async () => { + findMany.mockResolvedValue(rows(10)); + const derived = await computeSleepScore("u1", PROFILE, { + now: NOW, + tz: "UTC", + priorityJson: null, + }); + expect(derived.status).toBe("ok"); + if (derived.status !== "ok") return; + expectExactShape(derived.value); + expect(derived.value.subScores.every((s) => s.gap === undefined)).toBe( + true, + ); + }); + + it("matches a withheld rhythm, gap included", async () => { + findMany.mockResolvedValue(rows(2)); + const derived = await computeSleepScore("u1", PROFILE, { + now: NOW, + tz: "UTC", + priorityJson: null, + }); + expect(derived.status).toBe("ok"); + if (derived.status !== "ok") return; + expectExactShape(derived.value); + expect( + derived.value.subScores.find((s) => s.key === "consistency")?.gap, + ).toBe("few_nights"); + }); +}); diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 0ab861c6b..d2bfd1a3b 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -65,7 +65,7 @@ import { encounterPaths } from "./encounters"; import { vaccinationPaths } from "./vaccinations"; import { illnessPaths } from "./illness"; import { importPaths } from "./import"; -import { insightsPaths, wellnessScoreValue } from "./insights"; +import { insightsPaths, sleepScoreValue, wellnessScoreValue } from "./insights"; import { integrationPaths } from "./integrations"; import { insightsSignalPaths } from "./insights-signals"; import { labsPaths } from "./labs"; @@ -245,7 +245,7 @@ export const openApiComponents: NonNullable = { // the DTO and reported two fields as missing that were already on the wire. // Publishing the shape a `$ref` cannot reach is exactly what this slot is // for; the `value` description names it as the shape the three score ids - // return. + // return. `SleepScoreValue` rides the same slot for `SLEEP_SCORE`. schemas: { Medication: medicationResource, MedicationDoseHistoryImportFatalReason: @@ -253,6 +253,7 @@ export const openApiComponents: NonNullable = { CoachPrefs: coachPrefsSchema, MoodTagLayout: moodTagLayout, WellnessScoreValue: wellnessScoreValue, + SleepScoreValue: sleepScoreValue, }, // v1.36.0 — the per-request account selector, defined in the sharing route // module so that `src/__tests__/acting-account-boundary-guard.test.ts` keeps diff --git a/src/lib/openapi/routes/insights/index.ts b/src/lib/openapi/routes/insights/index.ts index ad2739082..f4ad3bc4b 100644 --- a/src/lib/openapi/routes/insights/index.ts +++ b/src/lib/openapi/routes/insights/index.ts @@ -4,4 +4,4 @@ * Part of the OpenAPI route table; aggregated in `../index.ts`. */ export { insightsPaths } from "./paths"; -export { wellnessScoreValue } from "./schemas"; +export { sleepScoreValue, wellnessScoreValue } from "./schemas"; diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index a07dc42c8..c18ba6769 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -199,7 +199,7 @@ export const derivedMetricQuery = z * * STILL OPAQUE, and known to be: the value shapes of `VITALS_BASELINE` (prose * only, in the `value` description), `FITNESS_AGE`, `VASCULAR_AGE_DELTA`, - * `HRV_BALANCE`, `BMI`, `SLEEP_SCORE`, `READINESS`, `COINCIDENT_DEVIATION`, + * `HRV_BALANCE`, `BMI`, `READINESS`, `COINCIDENT_DEVIATION`, * `TRAJECTORY`, `SAME_TIME_BASELINE`, `SIX_MINUTE_WALK_BAND`, `HEALTH_SCORE` * and the three fixed-type baselines. Each is a TypeScript interface next to * its engine under `src/lib/insights/derived/`; modelling them is a schema per @@ -274,6 +274,76 @@ export const wellnessScoreValue = z }) .meta({ id: "WellnessScoreValue" }); +/** + * The `SLEEP_SCORE` value, published beside `WellnessScoreValue` for the same + * reason (the derived envelope's `value` is an open record). Held to the + * engine's emitted object by `derived-sleep-value-contract.test.ts`. + */ +export const sleepScoreValue = z + .object({ + score: z.number().int().describe("The composite 0–100 sleep score."), + band: z + .enum(["green", "yellow", "red"]) + .describe("Server's verdict on the score; never re-band it client-side."), + night: z + .string() + .describe("Wake day (YYYY-MM-DD, user's zone) of the night scored."), + asleepMinutes: z + .number() + .int() + .describe("Whole minutes asleep that night."), + inBedMinutes: z + .number() + .int() + .nullable() + .describe("Whole minutes in bed, null when no in-bed signal exists."), + subScores: z + .array( + z.object({ + key: z + .enum([ + "sufficiency", + "efficiency", + "consistency", + "timing", + "composition", + ]) + .describe("Sub-score id."), + value: z + .number() + .nullable() + .describe( + "0–100 sub-score; null when the data cannot support it (dropped from the blend, never 0).", + ), + weight: z + .number() + .describe( + "Effective weight after redistributing missing sub-scores.", + ), + gap: z + .enum(["few_nights", "no_main_sleep"]) + .optional() + .describe( + "Only on a null `consistency` / `timing`: why it is withheld. `few_nights` — fewer than three nights in the window have a main sleep on the clock (count in `rhythmNights`); `no_main_sleep` — the latest day had only a daytime nap. Absent otherwise.", + ), + }), + ) + .describe("All five sub-scores, present and dropped."), + windowNights: z.number().int().describe("Scorable nights in the window."), + rhythmNights: z + .number() + .int() + .describe( + "Nights in the window with a main sleep placed on the clock — what consistency and timing are computed from. A daytime nap is not one; a short night is.", + ), + series: z + .array(z.number()) + .describe( + `Score of every scorable night in the window, oldest → newest, capped at ${SPARKLINE_MAX_POINTS} points; the last point is \`score\`.`, + ), + }) + .meta({ id: "SleepScoreValue" }); + export const derivedCoverage = z .object({ requiredInputs: z @@ -410,7 +480,7 @@ export const derivedMetricResponse = z .record(z.string(), z.unknown()) .nullable() .describe( - "Metric-specific value object when status is 'ok'; null when 'insufficient'. The shape is chosen by `metric`, and the record stays open because one route serves eighteen of them. `RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in `components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to discover by inspecting a live payload. `VITALS_BASELINE` returns { type, center, low, high, spread, sampleDays, k, series, direction }, where `series` is the trailing per-day means for the inline sparkline and `direction` (v1.43, a `TrendVerdict`) says which way a change of the vital counts for this person, with `center` placed against their own target or a profile default. The remaining metrics' value objects are not modelled here yet; their shapes are TypeScript interfaces beside their engines under `src/lib/insights/derived/`.", + "Metric-specific value object when status is 'ok'; null when 'insufficient'. The shape is chosen by `metric`, and the record stays open because one route serves eighteen of them. `RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in `components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to discover by inspecting a live payload. `SLEEP_SCORE` returns the `SleepScoreValue` schema, including `rhythmNights` and the per-sub-score `gap`. `VITALS_BASELINE` returns { type, center, low, high, spread, sampleDays, k, series, direction }, where `series` is the trailing per-day means for the inline sparkline and `direction` (v1.43, a `TrendVerdict`) says which way a change of the vital counts for this person, with `center` placed against their own target or a profile default. The remaining metrics' value objects are not modelled here yet; their shapes are TypeScript interfaces beside their engines under `src/lib/insights/derived/`.", ), coverage: derivedCoverage, confidence: derivedConfidence From b5285f0f65a90dacaf2780d541d189c64e1e1864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 19 Oct 2026 19:46:03 +0200 Subject: [PATCH 4/4] test(recovery): settle the connection status before reading the empty note The recovery page now holds its empty note until the integrations status has loaded. The data-gating test rendered without that status and saw the loading placeholder; it now seeds an account with no integrations, the settled answer the general note belongs to. Refs #1204 --- .../insights/__tests__/device-score-surfaces.test.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/insights/__tests__/device-score-surfaces.test.tsx b/src/components/insights/__tests__/device-score-surfaces.test.tsx index 5b26ba04a..e36b88076 100644 --- a/src/components/insights/__tests__/device-score-surfaces.test.tsx +++ b/src/components/insights/__tests__/device-score-surfaces.test.tsx @@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import type { DataSummary } from "@/lib/analytics/trends"; /** @@ -45,6 +46,12 @@ function render(node: React.ReactNode) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } }, }); + // The recovery page's empty note waits for the connection status; an + // account with no integrations is the settled answer here. + queryClient.setQueryData(queryKeys.integrationsStatus(), { + threshold: 3, + integrations: [], + }); return renderToStaticMarkup( {node}