mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
40b1389970
commit
d38ceb4beb
13 files changed
+166
-4
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -4389,6 +4389,10 @@
|
||||
"consistency": "일관성",
|
||||
"timing": "타이밍",
|
||||
"composition": "구성"
|
||||
},
|
||||
"gap": {
|
||||
"fewNights": "아직 밤이 부족함 ({count}/3)",
|
||||
"noMainSleep": "최근 밤에 주 수면 없음"
|
||||
}
|
||||
},
|
||||
"READINESS": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user