fix(sleep-score): publish the rhythm floor and name it in the few-nights note

The note hard-coded "of 3" in every locale. SleepScoreValue now carries
rhythmMinNights, and the sheet reads the floor from it.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 05:14:03 +02:00
1 parent 9fe6f98fdb
commit 248b5dfd6b
11 files changed
+83 -8

No files matched your search

+1 -1
View File
@@ -4397,7 +4397,7 @@
"composition": "Zusammensetzung"
},
"gap": {
"fewNights": "noch nicht genug Nächte ({count} von 3)",
"fewNights": "noch nicht genug Nächte ({count} von {needed})",
"noMainSleep": "letzter Tag nur mit Nickerchen"
}
},
+1 -1
View File
@@ -4397,7 +4397,7 @@
"composition": "Composition"
},
"gap": {
"fewNights": "not enough nights yet ({count} of 3)",
"fewNights": "not enough nights yet ({count} of {needed})",
"noMainSleep": "latest day had only a nap"
}
},
+1 -1
View File
@@ -4397,7 +4397,7 @@
"composition": "Composición"
},
"gap": {
"fewNights": "aún no hay suficientes noches ({count} de 3)",
"fewNights": "aún no hay suficientes noches ({count} de {needed})",
"noMainSleep": "el último día solo tuvo una siesta"
}
},
+1 -1
View File
@@ -4397,7 +4397,7 @@
"composition": "Composition"
},
"gap": {
"fewNights": "pas encore assez de nuits ({count} sur 3)",
"fewNights": "pas encore assez de nuits ({count} sur {needed})",
"noMainSleep": "le dernier jour n'a eu qu'une sieste"
}
},
+1 -1
View File
@@ -4397,7 +4397,7 @@
"composition": "Composizione"
},
"gap": {
"fewNights": "notti ancora insufficienti ({count} su 3)",
"fewNights": "notti ancora insufficienti ({count} su {needed})",
"noMainSleep": "l'ultimo giorno solo un pisolino"
}
},
+1 -1
View File
@@ -4397,7 +4397,7 @@
"composition": "구성"
},
"gap": {
"fewNights": "아직 밤이 부족함 ({count}/3)",
"fewNights": "아직 밤이 부족함 ({count}/{needed})",
"noMainSleep": "최근 하루는 낮잠만 있음"
}
},
+1 -1
View File
@@ -4397,7 +4397,7 @@
"composition": "Struktura"
},
"gap": {
"fewNights": "za mało nocy ({count} z 3)",
"fewNights": "za mało nocy ({count} z {needed})",
"noMainSleep": "ostatni dzień tylko z drzemką"
}
},
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* A rhythm sub-score withheld for too few nights names the floor the server
* published (`rhythmMinNights`), not a number the sheet carries itself.
*/
const anatomy = vi.fn();
vi.mock("../score-anatomy-view", () => ({
ScoreAnatomyView: (props: Record<string, unknown>) => {
anatomy(props);
return null;
},
}));
vi.mock("@/components/insights/insight-status-card", () => ({
InsightStatusCard: () => null,
}));
vi.mock("../score-history", () => ({
ScoreHistoryChart: () => null,
ScoreTrendChartDynamic: () => null,
}));
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({
t: (key: string, vars?: Record<string, string | number>) =>
vars ? `${key}:${JSON.stringify(vars)}` : key,
}),
}));
vi.mock("../use-derived-metric", () => ({
useDerivedMetric: () => ({
isLoading: false,
data: {
status: "ok",
value: {
score: 70,
band: "yellow",
subScores: [
{ key: "consistency", value: null, weight: 0.2, gap: "few_nights" },
],
rhythmNights: 2,
rhythmMinNights: 5,
},
coverage: 1,
confidence: "high",
provenance: {},
},
}),
}));
import { CompositeScoreAnatomy } from "../composite-score-anatomy";
describe("sleep score sheet — the few-nights gap", () => {
it("names the published floor", () => {
renderToStaticMarkup(<CompositeScoreAnatomy metric="SLEEP_SCORE" />);
const props = anatomy.mock.calls.at(-1)![0] as {
contributors: Array<{ key: string; missingLabel?: string }>;
};
const consistency = props.contributors.find((c) => c.key === "consistency");
expect(consistency?.missingLabel).toContain('"count":2');
expect(consistency?.missingLabel).toContain('"needed":5');
});
});
@@ -243,6 +243,7 @@ export function CompositeScoreAnatomy({
s.gap === "few_nights"
? t("insights.derived.composite.SLEEP_SCORE.gap.fewNights", {
count: v.rhythmNights ?? 0,
needed: v.rhythmMinNights,
})
: s.gap === "no_main_sleep"
? t("insights.derived.composite.SLEEP_SCORE.gap.noMainSleep")
+6
View File
@@ -115,6 +115,11 @@ export interface SleepScoreValue {
* `RHYTHM_MIN_NIGHTS` both are withheld with `gap: "few_nights"`.
*/
rhythmNights: number;
/**
* `RHYTHM_MIN_NIGHTS`, published so a client names the floor it shows
* ("2 of 3 nights") from the server instead of carrying its own copy.
*/
rhythmMinNights: 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
@@ -640,6 +645,7 @@ export async function computeSleepScore(
subScores,
windowNights: scorableNights.length,
rhythmNights: midpoints.length,
rhythmMinNights: RHYTHM_MIN_NIGHTS,
series,
},
coverage,
+7 -1
View File
@@ -325,7 +325,7 @@ export const sleepScoreValue = 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.",
"Only on a null `consistency` / `timing`: why it is withheld. `few_nights` — fewer than `rhythmMinNights` 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.",
),
}),
)
@@ -337,6 +337,12 @@ export const sleepScoreValue = z
.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.",
),
rhythmMinNights: z
.number()
.int()
.describe(
'How many `rhythmNights` consistency and timing need before they are scored; below it they carry `gap: "few_nights"`.',
),
series: z
.array(z.number())
.describe(