From d38ceb4bebab5689aa569a7d6f387af0648597f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 19 Oct 2026 15:15:46 +0200 Subject: [PATCH] fix(sleep): say why consistency or timing has no value A rhythm sub-score below three nights with a main sleep was dropped from the blend and its row read "no data", although the nights were there; since a nap-only day no longer has a place on the clock, a ring worn for only a few nights reaches that state more often. Each withheld consistency or timing sub-score now carries the reason (`few_nights`, `no_main_sleep`) and the score value carries how many nights the rhythm is built from, so the row reads "not enough nights yet (2 of 3)" or "latest night had no main sleep" instead. Neither is ever shown as 0. Refs #1204 --- messages/de.json | 4 ++ messages/en.json | 4 ++ messages/es.json | 4 ++ messages/fr.json | 4 ++ messages/it.json | 4 ++ messages/ko.json | 4 ++ messages/pl.json | 4 ++ .../__tests__/score-anatomy-view.test.tsx | 29 ++++++++++ .../derived/composite-score-anatomy.tsx | 8 +++ .../insights/derived/score-anatomy-view.tsx | 9 +++- .../__tests__/derived-assessment.test.ts | 1 + .../__tests__/sleep-score-oura-rhythm.test.ts | 41 ++++++++++++++ src/lib/insights/derived/sleep-score.ts | 54 +++++++++++++++++-- 13 files changed, 166 insertions(+), 4 deletions(-) diff --git a/messages/de.json b/messages/de.json index 5fcb1ca53..944bc9638 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index e23268c10..261eed209 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 0184b95e1..5b42d2099 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 292a6e172..46c71f58b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 2f29204bb..dd8413a22 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/ko.json b/messages/ko.json index 2586b44e9..e3f8d67a8 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4389,6 +4389,10 @@ "consistency": "일관성", "timing": "타이밍", "composition": "구성" + }, + "gap": { + "fewNights": "아직 밤이 부족함 ({count}/3)", + "noMainSleep": "최근 밤에 주 수면 없음" } }, "READINESS": { diff --git a/messages/pl.json b/messages/pl.json index 65b66e844..36cead91e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx b/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx index 2923a6172..54b488789 100644 --- a/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx +++ b/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx @@ -67,6 +67,35 @@ describe("", () => { expect(html).not.toContain("absolute top-3 right-3"); }); + it("names why a contributor is missing when the caller knows", () => { + const html = render( + , + ); + 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(
{ } }); + 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); diff --git a/src/lib/insights/derived/sleep-score.ts b/src/lib/insights/derived/sleep-score.ts index fa24e526d..b0ebd4f39 100644 --- a/src/lib/insights/derived/sleep-score.ts +++ b/src/lib/insights/derived/sleep-score.ts @@ -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, + 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 { @@ -571,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 @@ -619,6 +666,7 @@ export async function computeSleepScore( latest.inBedMinutes === null ? null : Math.round(latest.inBedMinutes), subScores, windowNights: scorableNights.length, + rhythmNights: midpoints.length, series, }, coverage,