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
This commit is contained in:
Marc-André Bombeck committed 2026-10-19 15:15:46 +02:00
1 parent 40b1389970
commit d38ceb4beb
13 files changed
+166 -4

No files matched your search

+4
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": {
+4
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": {
+4
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": {
+4
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": {
+4
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": {
+4
View File
@@ -4389,6 +4389,10 @@
"consistency": "일관성",
"timing": "타이밍",
"composition": "구성"
},
"gap": {
"fewNights": "아직 밤이 부족함 ({count}/3)",
"noMainSleep": "최근 밤에 주 수면 없음"
}
},
"READINESS": {
+4
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": {
@@ -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
@@ -75,6 +75,7 @@ const sleepScore: SleepScoreValue = {
{ key: "composition", value: 88, weight: 0.15 },
],
windowNights: 20,
rhythmNights: 20,
series: [78, 82],
};
@@ -24,6 +24,7 @@ import { mapSleep, type OuraSleep } from "@/lib/oura/client";
import {
computeSleepScore,
reconstructNights,
rhythmGap,
scoreConsistency,
} from "../sleep-score";
@@ -153,6 +154,46 @@ describe("Sleep Score rhythm on an Oura month", () => {
}
});
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);
+51 -3
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 {
@@ -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,