mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge branch 'fix/v1436-oura' into release/v1.43.6
This commit is contained in:
commit
f05b2f71e7
17 files changed
+429
-54
No files matched your search
+95
-1
@@ -64554,7 +64554,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
|
||||
@@ -88184,6 +88185,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
|
||||
|
||||
+2
-2
@@ -4396,7 +4396,7 @@
|
||||
},
|
||||
"gap": {
|
||||
"fewNights": "noch nicht genug Nächte ({count} von 3)",
|
||||
"noMainSleep": "letzte Nacht ohne Hauptschlaf"
|
||||
"noMainSleep": "letzter Tag nur mit Nickerchen"
|
||||
}
|
||||
},
|
||||
"READINESS": {
|
||||
@@ -4666,7 +4666,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.",
|
||||
|
||||
+2
-2
@@ -4396,7 +4396,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": {
|
||||
@@ -4666,7 +4666,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.",
|
||||
|
||||
+2
-2
@@ -4396,7 +4396,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": {
|
||||
@@ -4666,7 +4666,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.",
|
||||
|
||||
+2
-2
@@ -4396,7 +4396,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": {
|
||||
@@ -4666,7 +4666,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.",
|
||||
|
||||
+2
-2
@@ -4396,7 +4396,7 @@
|
||||
},
|
||||
"gap": {
|
||||
"fewNights": "notti ancora insufficienti ({count} su 3)",
|
||||
"noMainSleep": "nessun sonno principale l'ultima notte"
|
||||
"noMainSleep": "l'ultimo giorno solo un pisolino"
|
||||
}
|
||||
},
|
||||
"READINESS": {
|
||||
@@ -4666,7 +4666,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.",
|
||||
|
||||
+2
-2
@@ -4396,7 +4396,7 @@
|
||||
},
|
||||
"gap": {
|
||||
"fewNights": "아직 밤이 부족함 ({count}/3)",
|
||||
"noMainSleep": "최근 밤에 주 수면 없음"
|
||||
"noMainSleep": "최근 하루는 낮잠만 있음"
|
||||
}
|
||||
},
|
||||
"READINESS": {
|
||||
@@ -4666,7 +4666,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": "오늘 하루를 맞을 자율신경계의 충전 상태예요. 밤사이 심장·호흡 신호에서 읽어낸 재충전 지표예요.",
|
||||
|
||||
+2
-2
@@ -4396,7 +4396,7 @@
|
||||
},
|
||||
"gap": {
|
||||
"fewNights": "za mało nocy ({count} z 3)",
|
||||
"noMainSleep": "ostatniej nocy brak głównego snu"
|
||||
"noMainSleep": "ostatni dzień tylko z drzemką"
|
||||
}
|
||||
},
|
||||
"READINESS": {
|
||||
@@ -4666,7 +4666,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.",
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
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<string, unknown>).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");
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
|
||||
@@ -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<string, unknown> | null) {
|
||||
function render(
|
||||
oura: Record<string, unknown> | 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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
@@ -104,16 +109,34 @@ describe("<RecoverySection> 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"');
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-3" data-slot="recovery-loading">
|
||||
<ChartSkeleton />
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ZodOpenApiObject["components"]> = {
|
||||
// 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<ZodOpenApiObject["components"]> = {
|
||||
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
|
||||
|
||||
@@ -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";
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user