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,