Merge branch 'fix/v1436-oura' into release/v1.43.6

This commit is contained in:
Marc-André Bombeck committed 2026-10-19 17:04:29 +02:00
commit 09c93cd547
16 files changed
+707 -6

No files matched your search

+7
View File
@@ -4389,6 +4389,10 @@
"consistency": "Konstanz",
"timing": "Timing",
"composition": "Zusammensetzung"
},
"gap": {
"fewNights": "noch nicht genug Nächte ({count} von 3)",
"noMainSleep": "letzte Nacht ohne Hauptschlaf"
}
},
"READINESS": {
@@ -4657,6 +4661,9 @@
"scoreCrossLink": "Den zusammengesetzten Erholungs-Score ansehen",
"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.",
"emptyAction": "Integrationen öffnen",
"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.",
+7
View File
@@ -4389,6 +4389,10 @@
"consistency": "Consistency",
"timing": "Timing",
"composition": "Composition"
},
"gap": {
"fewNights": "not enough nights yet ({count} of 3)",
"noMainSleep": "latest night had no main sleep"
}
},
"READINESS": {
@@ -4657,6 +4661,9 @@
"scoreCrossLink": "See the composite recovery score",
"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.",
"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.",
"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.",
+7
View File
@@ -4389,6 +4389,10 @@
"consistency": "Consistencia",
"timing": "Horario",
"composition": "Composición"
},
"gap": {
"fewNights": "aún no hay suficientes noches ({count} de 3)",
"noMainSleep": "la última noche no tuvo sueño principal"
}
},
"READINESS": {
@@ -4657,6 +4661,9 @@
"scoreCrossLink": "Ver la puntuación de recuperación combinada",
"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.",
"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.",
"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í.",
+7
View File
@@ -4389,6 +4389,10 @@
"consistency": "Régularité",
"timing": "Timing",
"composition": "Composition"
},
"gap": {
"fewNights": "pas encore assez de nuits ({count} sur 3)",
"noMainSleep": "pas de sommeil principal la dernière nuit"
}
},
"READINESS": {
@@ -4657,6 +4661,9 @@
"scoreCrossLink": "Voir le score de récupération composite",
"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.",
"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.",
"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.",
+7
View File
@@ -4389,6 +4389,10 @@
"consistency": "Costanza",
"timing": "Orario",
"composition": "Composizione"
},
"gap": {
"fewNights": "notti ancora insufficienti ({count} su 3)",
"noMainSleep": "nessun sonno principale l'ultima notte"
}
},
"READINESS": {
@@ -4657,6 +4661,9 @@
"scoreCrossLink": "Vedi il punteggio di recupero composito",
"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.",
"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.",
"dayStrainExplainer": "Il carico cardiovascolare della giornata sulla scala di sforzo del tuo dispositivo. Leggilo in rapporto al tuo recupero, non come obiettivo a sé.",
+7
View File
@@ -4389,6 +4389,10 @@
"consistency": "일관성",
"timing": "타이밍",
"composition": "구성"
},
"gap": {
"fewNights": "아직 밤이 부족함 ({count}/3)",
"noMainSleep": "최근 밤에 주 수면 없음"
}
},
"READINESS": {
@@ -4657,6 +4661,9 @@
"scoreCrossLink": "종합 회복 점수 보기",
"emptyTitle": "아직 회복 데이터가 없어요",
"empty": "아직 여기에 표시할 내용이 없어요. 이 페이지는 WHOOP과 Polar가 기록하는 충전, 부하, 운동량 값과 Oura의 회복 탄력성 단계를 보여줘요. Oura의 준비도(Readiness)는 위에 연결된 회복 점수에 표시돼요.",
"emptyOuraPermission": "Oura는 연결되어 있지만 허용된 권한에 심장 건강 데이터가 빠져 있어요. Oura의 회복 탄력성 단계는 바로 이 데이터에 속해요. 설정, 통합에서 Oura를 한 번 다시 연결하고 심장 건강 접근을 허용하면 다음 동기화 때 여기에 단계가 표시돼요. Oura의 준비도(Readiness)는 이미 위에 연결된 회복 점수에 표시돼요.",
"emptyOuraNoResilience": "Oura가 연결되어 있고 권한에도 회복 탄력성이 포함되어 있지만, Oura가 아직 이 계정의 회복 탄력성 단계를 보내지 않았어요. HealthLog는 동기화할 때마다 요청하므로 Oura가 보내는 즉시 여기에 표시돼요. Oura의 준비도(Readiness)는 이미 위에 연결된 회복 점수에 표시돼요.",
"emptyAction": "통합 열기",
"block": {
"ansChargeExplainer": "오늘 하루를 맞을 자율신경계의 충전 상태예요. 밤사이 심장·호흡 신호에서 읽어낸 재충전 지표예요.",
"dayStrainExplainer": "기기의 부하 척도로 본 하루 전체의 심혈관 부하예요. 그 자체를 목표로 삼지 말고 회복과 견줘 보세요.",
+7
View File
@@ -4389,6 +4389,10 @@
"consistency": "Regularność",
"timing": "Pora",
"composition": "Struktura"
},
"gap": {
"fewNights": "za mało nocy ({count} z 3)",
"noMainSleep": "ostatniej nocy brak głównego snu"
}
},
"READINESS": {
@@ -4657,6 +4661,9 @@
"scoreCrossLink": "Zobacz łączny wynik regeneracji",
"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.",
"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.",
"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.",
@@ -0,0 +1,122 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
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";
/**
* 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.
*/
vi.mock("next/dynamic", () => ({
default: () => {
const Stub = () => <div data-slot="mini-chart-stub" />;
Stub.displayName = "MiniChartStub";
return Stub;
},
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: { timezone: "UTC" }, isAuthenticated: true }),
}));
const analyticsMock = vi.fn();
vi.mock("@/hooks/use-insights-analytics", () => ({
useInsightsAnalytics: () => analyticsMock(),
}));
import { RecoverySection } from "../recovery/recovery-section";
function summary(count: number): DataSummary {
return {
count,
latest: null,
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
slope30: null,
anomalyCount: 0,
};
}
/** An Oura-only account: readiness, HRV and sleep, no resilience level. */
const OURA_SUMMARIES = {
SLEEP_DURATION: summary(30),
RECOVERY_SCORE: summary(30),
HRV_RMSSD: summary(30),
};
function render(oura: Record<string, unknown> | null) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
queryClient.setQueryData(queryKeys.integrationsStatus(), {
threshold: 3,
integrations: oura ? [{ integration: "oura", ...oura }] : [],
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<RecoverySection />
</I18nProvider>
</QueryClientProvider>,
);
}
function health(unavailable: Array<{ item: string; cause: string }>) {
return {
state: unavailable.length > 0 ? "limited" : "ok",
reason: unavailable.length > 0 ? "permission_limited" : "data_arriving",
unavailable,
};
}
beforeEach(() => {
analyticsMock.mockReset();
analyticsMock.mockReturnValue({
data: { summaries: OURA_SUMMARIES },
isLoading: false,
isEmpty: false,
error: null,
refetch: () => {},
});
});
describe("<RecoverySection> empty note", () => {
it("asks for the heart-health permission when the grant withholds resilience", () => {
const html = render({
connected: true,
health: health([{ item: "resilience", cause: "permission" }]),
});
expect(html).toContain('data-empty-case="oura-permission"');
expect(html).toContain("allow heart-health access");
expect(html).toContain('href="/settings/integrations"');
});
it("says Oura has sent no level yet when the grant covers it", () => {
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).not.toContain('href="/settings/integrations"');
});
it("keeps the general note for an account without Oura", () => {
const html = render(null);
expect(html).toContain('data-empty-case="general"');
expect(html).toContain("WHOOP and Polar record");
});
});
@@ -67,6 +67,35 @@ describe("<ScoreAnatomyView>", () => {
expect(html).not.toContain("absolute top-3 right-3");
});
it("names why a contributor is missing when the caller knows", () => {
const html = render(
<ScoreAnatomyView
title="Sleep score"
score={70}
contributors={[
{ key: "sufficiency", label: "Sufficiency", value: 90, weight: 1 },
{
key: "consistency",
label: "Consistency",
value: null,
weight: 0,
missingLabel: "not enough nights yet (2 of 3)",
},
{ key: "composition", label: "Composition", value: null, weight: 0 },
]}
coverage={COVERAGE}
confidence={CONFIDENCE}
provenance={PROVENANCE}
method="m"
/>,
);
expect(html).toContain("not enough nights yet (2 of 3)");
// A missing contributor without a reason keeps the general note.
expect(html).toContain("no data");
// Neither reads as a 0 score.
expect(html).not.toMatch(/>0, weighted/);
});
it("tints the card + leans the ring hue when a hue is passed (F1)", () => {
const html = render(
<ScoreAnatomyView
@@ -238,6 +238,14 @@ export function CompositeScoreAnatomy({
label: t(`insights.derived.composite.SLEEP_SCORE.sub.${s.key}`),
value: s.value,
weight: s.weight,
missingLabel:
s.gap === "few_nights"
? t("insights.derived.composite.SLEEP_SCORE.gap.fewNights", {
count: v.rhythmNights ?? 0,
})
: s.gap === "no_main_sleep"
? t("insights.derived.composite.SLEEP_SCORE.gap.noMainSleep")
: undefined,
}))
.sort(rankByImpact);
} else if (metric === "READINESS") {
@@ -58,6 +58,12 @@ export interface AnatomyContributor {
value: number | null;
/** Effective weight share after null-redistribution, 0..1. */
weight: number;
/**
* What a missing contributor still needs, when the server says why it is
* missing (the sleep score's rhythm sub-scores). Shown in place of the
* generic "no data".
*/
missingLabel?: string;
}
export interface ScoreAnatomyViewProps {
@@ -238,7 +244,8 @@ function ContributorRow({ contributor }: { contributor: AnatomyContributor }) {
value: Math.round(clamped),
weight: weightPct,
})
: t("insights.derived.anatomy.contributorMissing")}
: (contributor.missingLabel ??
t("insights.derived.anatomy.contributorMissing"))}
</span>
</div>
<div
@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import {
Activity,
Battery,
@@ -14,6 +15,12 @@ import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useTranslations } from "@/lib/i18n/context";
import { EmptyState } from "@/components/ui/empty-state";
import { Button } from "@/components/ui/button";
import {
pickStatus,
useIntegrationStatuses,
type IntegrationStatusViewModel,
} from "@/components/settings/integrations/shared";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { RestModeBanner } from "@/components/insights/rest-mode-banner";
import type { RestModeAnnotation } from "@/lib/analytics/score/types";
@@ -39,6 +46,33 @@ interface BlockGroup {
blocks: BlockSpec[];
}
/**
* 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.
*/
type RecoveryEmptyCase = "general" | "oura-permission" | "oura-no-resilience";
const EMPTY_COPY = {
general: "insights.recovery.empty",
"oura-permission": "insights.recovery.emptyOuraPermission",
"oura-no-resilience": "insights.recovery.emptyOuraNoResilience",
} as const satisfies Record<RecoveryEmptyCase, string>;
function recoveryEmptyCase(
oura: IntegrationStatusViewModel | undefined,
): RecoveryEmptyCase {
if (!oura?.connected) return "general";
const withheld = oura.health?.unavailable.some(
(u) => u.item === "resilience" && u.cause === "permission",
);
return withheld ? "oura-permission" : "oura-no-resilience";
}
/**
* v1.17.1 → rebuilt v1.18.1 (B1–B3) — body of the `/insights/recovery`
* sub-page.
@@ -181,6 +215,13 @@ export function RecoverySection() {
group.blocks.some((block) => (summaries[block.type]?.count ?? 0) > 0),
));
// Only an empty page asks which connection could fill it, so a page with
// data never pays for the integrations read.
const statuses = useIntegrationStatuses(
!isLoading && summaries != null && !hasAny,
);
const emptyCase = recoveryEmptyCase(pickStatus(statuses.data, "oura"));
if (isLoading || summaries == null) {
return (
<div className="space-y-3" data-slot="recovery-loading">
@@ -193,9 +234,19 @@ export function RecoverySection() {
return (
<EmptyState
data-slot="recovery-empty"
data-empty-case={emptyCase}
icon={<Heart className="size-6" />}
title={t("insights.recovery.emptyTitle")}
description={t("insights.recovery.empty")}
description={t(EMPTY_COPY[emptyCase])}
action={
emptyCase === "oura-permission" ? (
<Button size="sm" variant="outline" asChild>
<Link href="/settings/integrations">
{t("insights.recovery.emptyAction")}
</Link>
</Button>
) : undefined
}
/>
);
}
@@ -75,6 +75,7 @@ const sleepScore: SleepScoreValue = {
{ key: "composition", value: 88, weight: 0.15 },
],
windowNights: 20,
rhythmNights: 20,
series: [78, 82],
};
@@ -0,0 +1,202 @@
/**
* The Sleep Score's rhythm sub-scores (consistency, timing) for a ring wearer
* whose recent nights come from Oura while the phone's Apple Health history is
* still being read from years back. The Oura month carries the periods Oura
* really returns: a `long_sleep` most nights, short `sleep` naps, a `late_nap`
* in the evening, a `rest` period the person rejected, and two days on which
* the ring recorded no night at all and only a daytime nap. Rows go through
* the real Oura mapper, so the fixture has the shape the sync writes.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
user: { findUnique: vi.fn().mockResolvedValue(null) },
},
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: vi.fn().mockResolvedValue("Europe/Berlin"),
}));
import { prisma } from "@/lib/db";
import { mapSleep, type OuraSleep } from "@/lib/oura/client";
import {
computeSleepScore,
reconstructNights,
rhythmGap,
scoreConsistency,
} from "../sleep-score";
const TZ = "Europe/Berlin";
const NOW = new Date("2026-10-01T09:00:00Z");
const PROFILE = { ageYears: 40, sex: "MALE" as const };
const findMany = prisma.measurement.findMany as ReturnType<typeof vi.fn>;
interface Row {
value: number;
measuredAt: Date;
sleepStage: string | null;
source: string;
deviceType: string | null;
}
/** A 5-minute hypnogram: awake at both ends, light / deep / REM between. */
function hypnogram(minutes: number): string {
const n = Math.round(minutes / 5);
const body = "2221133322224222113332";
let s = "444";
for (let i = 0; i < n - 5; i += 1) s += body[i % body.length];
return s + "44";
}
function period(
id: string,
type: string,
start: Date,
minutes: number,
): OuraSleep {
const end = new Date(start.getTime() + minutes * 60_000);
return {
id,
type,
day: end.toISOString().slice(0, 10),
bedtime_start: start.toISOString(),
bedtime_end: end.toISOString(),
sleep_phase_5_min: hypnogram(minutes),
efficiency: 90,
};
}
/** Thirty Oura days ending the morning of NOW, plus Apple Health from 2024. */
function fixture(): Row[] {
const periods: OuraSleep[] = [];
for (let d = 1; d <= 30; d += 1) {
// Bedtime 23:15 to 00:05 Berlin (21:15 to 22:05 UTC), eight hours.
const bed = new Date(Date.UTC(2026, 8, d, 21, 15 + ((d * 17) % 50)));
const noon = (h: number, m = 0) => new Date(Date.UTC(2026, 8, d + 1, h, m));
if (d === 9 || d === 21) {
// The ring spent the night on the charger: the day has only a nap.
periods.push(period(`only-${d}`, "sleep", noon(11), 60));
} else {
periods.push(period(`night-${d}`, "long_sleep", bed, 480));
}
if (d % 5 === 0) periods.push(period(`nap-${d}`, "sleep", noon(10), 40));
if (d % 7 === 0)
periods.push(period(`late-${d}`, "late_nap", noon(15, 30), 45));
if (d % 6 === 0) periods.push(period(`rest-${d}`, "rest", noon(11), 90));
}
const oura = periods.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,
})),
);
// The Watch history the phone is still working through, two years back.
const apple: Row[] = [];
for (let d = 1; d <= 20; d += 1) {
apple.push({
value: 420,
measuredAt: new Date(Date.UTC(2024, 8, d, 5, 0)),
sleepStage: "CORE",
source: "APPLE_HEALTH",
deviceType: "watch",
});
}
return [...apple, ...oura];
}
beforeEach(() => {
vi.clearAllMocks();
const rows = fixture();
// Honour the window the way the database would.
findMany.mockImplementation(
async (args: { where: { measuredAt: { gte: Date } } }) =>
rows
.filter((r) => r.measuredAt >= args.where.measuredAt.gte)
.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()),
);
});
describe("Sleep Score rhythm on an Oura month", () => {
it("keeps a nap-only day off the clock", () => {
const nights = reconstructNights(fixture() as never, TZ).filter(
(n) => n.night >= "2026-09-02",
);
const napOnly = nights.find((n) => n.night === "2026-09-10")!;
// The day still counts its nap...
expect(napOnly.asleepMinutes).toBeGreaterThan(0);
// ...but a nap is no point on the sleep rhythm.
expect(napOnly.midpoint).toBeNull();
const regular = nights.find((n) => n.night === "2026-09-11")!;
expect(regular.midpoint).not.toBeNull();
});
it("scores the consistency of a steady sleeper, not 0", async () => {
const result = await computeSleepScore("u1", PROFILE, { now: NOW });
expect(result.status).toBe("ok");
if (result.status !== "ok") return;
const sub = (k: string) =>
result.value.subScores.find((s) => s.key === k)!.value;
// Bedtimes wander within 50 minutes: a regular rhythm.
expect(sub("consistency")).toBeGreaterThanOrEqual(70);
expect(sub("timing")).toBeGreaterThanOrEqual(60);
// The Apple Health rows from 2024 never enter the window.
for (const call of findMany.mock.calls) {
expect(call[0].where.measuredAt.gte.getTime()).toBeGreaterThan(
Date.UTC(2026, 7, 31),
);
}
});
it("names too few nights as a gap, never as 0", async () => {
// Two nights on the ring so far: no rhythm yet.
const two = fixture().filter(
(r) =>
r.source === "OURA" &&
r.measuredAt >= new Date("2026-09-29T12:00:00Z") &&
r.measuredAt < new Date("2026-10-01T06:00:00Z"),
);
findMany.mockResolvedValue(two);
const result = await computeSleepScore("u1", PROFILE, { now: NOW });
expect(result.status).toBe("ok");
if (result.status !== "ok") return;
expect(result.value.rhythmNights).toBe(2);
for (const key of ["consistency", "timing"]) {
const s = result.value.subScores.find((x) => x.key === key)!;
expect(s.value).toBeNull();
expect(s.weight).toBe(0);
expect(s.gap).toBe("few_nights");
}
// A sub-score with a value carries no gap.
const sufficiency = result.value.subScores.find(
(x) => x.key === "sufficiency",
)!;
expect(sufficiency.value).not.toBeNull();
expect(sufficiency.gap).toBeUndefined();
});
it("names a latest night without a main sleep for timing only", () => {
expect(rhythmGap({ key: "timing", value: null }, 20, null)).toBe(
"no_main_sleep",
);
expect(
rhythmGap({ key: "consistency", value: null }, 20, null),
).toBeUndefined();
expect(rhythmGap({ key: "timing", value: 80 }, 20, 180)).toBeUndefined();
expect(
rhythmGap({ key: "efficiency", value: null }, 1, null),
).toBeUndefined();
});
it("still reads a truly irregular rhythm as irregular", () => {
// Midpoints spread across the night: 01:00, 04:00, 07:00, repeated.
const spread = Array.from({ length: 12 }, (_, i) => 60 + (i % 3) * 180);
expect(scoreConsistency(spread)).toBe(0);
});
});
+69 -4
View File
@@ -78,8 +78,22 @@ export interface SleepSubScore {
value: number | null;
/** Effective weight after null-redistribution, 0..1. */
weight: number;
/**
* Why a rhythm sub-score (consistency, timing) has no value, set only when
* `value` is null. `few_nights`: fewer than `RHYTHM_MIN_NIGHTS` nights in
* the window have a main sleep to place on the clock. `no_main_sleep`: the
* window has enough, but the latest night has none (a nap-only day). A
* client names the gap instead of reading it as "no data", and never as 0.
*/
gap?: SleepSubScoreGap;
}
/** The two reasons a rhythm sub-score is withheld. See `SleepSubScore.gap`. */
export type SleepSubScoreGap = "few_nights" | "no_main_sleep";
/** Nights with a main sleep the rhythm sub-scores need before they score. */
export const RHYTHM_MIN_NIGHTS = 3;
export interface SleepScoreValue {
/** The composite 0..100. */
score: number;
@@ -95,6 +109,12 @@ export interface SleepScoreValue {
subScores: SleepSubScore[];
/** Nights in the window that backed the consistency/timing baseline. */
windowNights: number;
/**
* Nights in the window whose main sleep places them on the clock — the
* nights consistency and timing are computed from. Below
* `RHYTHM_MIN_NIGHTS` both are withheld with `gap: "few_nights"`.
*/
rhythmNights: number;
/**
* v1.42 — the score of every scorable night in the window (oldest → newest,
* capped to `SPARKLINE_MAX_POINTS`), so a surface can show the score's
@@ -232,7 +252,7 @@ export function circularMeanMinutes(minutes: number[]): number | null {
* not penalised for a spurious ~24-hour spread.
*/
export function scoreConsistency(midpointMinutes: number[]): number | null {
if (midpointMinutes.length < 3) return null;
if (midpointMinutes.length < RHYTHM_MIN_NIGHTS) return null;
const mean = circularMeanMinutes(midpointMinutes);
if (mean == null) return null;
const variance =
@@ -256,7 +276,11 @@ export function scoreTiming(
habitualMidpoint: number | null,
windowNights: number,
): number | null {
if (nightMidpoint == null || habitualMidpoint == null || windowNights < 3) {
if (
nightMidpoint == null ||
habitualMidpoint == null ||
windowNights < RHYTHM_MIN_NIGHTS
) {
return null;
}
const FULL_SCALE = 90;
@@ -304,6 +328,24 @@ export function blendSleepSubScores(
return { score: clamp100(composite), subScores };
}
/**
* The reason a rhythm sub-score came out null, or undefined when it has a
* value or is not a rhythm sub-score. Consistency needs `RHYTHM_MIN_NIGHTS`
* nights on the clock; timing needs those and a main sleep on the latest
* night. Pure.
*/
export function rhythmGap(
sub: Pick<SleepSubScore, "key" | "value">,
rhythmNights: number,
latestMidpoint: number | null,
): SleepSubScoreGap | undefined {
if (sub.value !== null) return undefined;
if (sub.key !== "consistency" && sub.key !== "timing") return undefined;
if (rhythmNights < RHYTHM_MIN_NIGHTS) return "few_nights";
if (sub.key === "timing" && latestMidpoint == null) return "no_main_sleep";
return undefined;
}
// ── night reconstruction ───────────────────────────────────────────────
interface SleepRow {
@@ -398,9 +440,17 @@ export function reconstructNights(
// 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.
//
// 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.
const main = n.mainSession;
const midpoint =
main && main.asleepMinutes > 0
main && main.asleepMinutes >= MAIN_SLEEP_MIN_MINUTES
? minutesOfDay(
new Date(main.end.getTime() - (main.asleepMinutes / 2) * 60_000),
tz,
@@ -419,6 +469,15 @@ 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.
*/
const MAIN_SLEEP_MIN_MINUTES = 180;
function minutesOfDay(d: Date, tz: string): number {
if (tz === "UTC") return d.getUTCHours() * 60 + d.getUTCMinutes();
const { hour, minute } = wallClockInTz(d, tz);
@@ -554,7 +613,12 @@ export async function computeSleepScore(
),
};
const { score, subScores } = blendSleepSubScores(raw);
const blended = blendSleepSubScores(raw);
const score = blended.score;
const subScores = blended.subScores.map((s) => {
const gap = rhythmGap(s, midpoints.length, latest.midpoint);
return gap ? { ...s, gap } : s;
});
// The same blend over every scorable night in the window. Consistency is a
// window-level figure and timing reads against the window's habitual
@@ -602,6 +666,7 @@ export async function computeSleepScore(
latest.inBedMinutes === null ? null : Math.round(latest.inBedMinutes),
subScores,
windowNights: scorableNights.length,
rhythmNights: midpoints.length,
series,
},
coverage,
@@ -0,0 +1,167 @@
/**
* Oura's resilience level from the API to what the recovery page reads,
* against the real schema, with the Oura API mocked at the network edge.
*
* The recovery page charts device-native recovery signals; for an Oura ring
* the only one is `daily_resilience`. Two paths decide whether that page can
* fill: a grant that covers the collection imports the level and the
* summaries the page gates on carry it; a grant that leaves the collection
* out is recorded, and the integrations read names resilience as withheld by
* permission, which is the fact the page's empty note is chosen from.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { encrypt } from "@/lib/crypto";
import { getPrismaClient, truncateAllTables } from "./setup";
const USER = "user-oura-resilience";
function today(offsetDays = 0): string {
return new Date(Date.now() + offsetDays * 86_400_000)
.toISOString()
.slice(0, 10);
}
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
await prisma.user.create({
data: {
id: USER,
username: "oura-resilience",
email: "oura-resilience@example.test",
timezone: "UTC",
ouraAccessTokenEncrypted: encrypt("acc"),
ouraRefreshTokenEncrypted: encrypt("ref"),
ouraClientIdEncrypted: encrypt("client"),
ouraClientSecretEncrypted: encrypt("secret"),
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function json(status: number, body: unknown) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
/**
* Stub Oura: every collection answers an empty page except readiness (one
* day) and resilience, which either answers two days or refuses the grant.
*/
function stubOura(resilience: "granted" | "refused") {
vi.stubGlobal(
"fetch",
vi.fn(async (input: string | URL) => {
const url = new URL(String(input));
if (url.pathname.includes("oauth")) {
return json(200, {
access_token: "acc2",
refresh_token: "ref2",
token_type: "bearer",
expires_in: 86_400,
});
}
if (url.pathname.endsWith("/daily_resilience")) {
if (resilience === "refused") return json(401, { detail: "scope" });
return json(200, {
data: [
{
id: "r1",
day: today(-2),
level: "solid",
contributors: {
sleep_recovery: 70,
daytime_recovery: 60,
stress: 50,
},
},
{
id: "r2",
day: today(-1),
level: "strong",
contributors: {
sleep_recovery: 80,
daytime_recovery: 65,
stress: 55,
},
},
],
next_token: null,
});
}
if (url.pathname.endsWith("/daily_readiness")) {
return json(200, {
data: [{ id: "d1", day: today(-1), score: 81 }],
next_token: null,
});
}
return json(200, { data: [], next_token: null });
}),
);
}
describe("Oura resilience reaches the recovery page", () => {
it("imports the level and the page's summaries carry it", async () => {
stubOura("granted");
const { syncUserOura } = await import("@/lib/oura/sync");
const result = await syncUserOura(USER, { lookbackDays: 7 });
expect(result.failed).toBe(false);
const prisma = getPrismaClient();
const rows = await prisma.measurement.findMany({
where: { userId: USER, type: "RESILIENCE", deletedAt: null },
orderBy: { measuredAt: "asc" },
select: { value: true, source: true },
});
expect(rows).toEqual([
{ value: 3, source: "OURA" },
{ value: 4, source: "OURA" },
]);
const { computeSummariesSlice } =
await import("@/lib/analytics/summaries-slice");
const slice = await computeSummariesSlice(USER);
expect(slice.summaries.RESILIENCE?.count).toBe(2);
expect(slice.summaries.RESILIENCE?.latest).toBe(4);
});
it("names resilience as withheld by permission when the grant leaves it out", async () => {
stubOura("refused");
const { syncUserOura } = await import("@/lib/oura/sync");
const result = await syncUserOura(USER, { lookbackDays: 7 });
// A scope gap is not a sync failure.
expect(result.failed).toBe(false);
const prisma = getPrismaClient();
expect(
await prisma.measurement.count({
where: { userId: USER, type: "RESILIENCE" },
}),
).toBe(0);
// Readiness still lands: the ring's recovery score, linked from the page.
expect(
await prisma.measurement.count({
where: { userId: USER, type: "RECOVERY_SCORE" },
}),
).toBe(1);
const { loadConnectionHealth } =
await import("@/lib/integrations/connection-health-load");
const health = await loadConnectionHealth(USER, ["oura"]);
expect(health.oura?.unavailable).toContainEqual({
item: "resilience",
cause: "permission",
});
});
});