From 248b5dfd6bda4181579143ad5da94e803a33207e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 05:14:03 +0200 Subject: [PATCH] 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. --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/ko.json | 2 +- messages/pl.json | 2 +- .../composite-score-anatomy-gap.test.tsx | 62 +++++++++++++++++++ .../derived/composite-score-anatomy.tsx | 1 + src/lib/insights/derived/sleep-score.ts | 6 ++ src/lib/openapi/routes/insights/schemas.ts | 8 ++- 11 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 src/components/insights/derived/__tests__/composite-score-anatomy-gap.test.tsx diff --git a/messages/de.json b/messages/de.json index 4647bb6f3..3e82f0da8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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" } }, diff --git a/messages/en.json b/messages/en.json index fcd8a18f4..46aedde31 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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" } }, diff --git a/messages/es.json b/messages/es.json index d561a693d..a8365a6c9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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" } }, diff --git a/messages/fr.json b/messages/fr.json index cca3e02ab..b54c61cb8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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" } }, diff --git a/messages/it.json b/messages/it.json index a53890b65..9480087f8 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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" } }, diff --git a/messages/ko.json b/messages/ko.json index 24fecff40..adbc3dd04 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4397,7 +4397,7 @@ "composition": "구성" }, "gap": { - "fewNights": "아직 밤이 부족함 ({count}/3)", + "fewNights": "아직 밤이 부족함 ({count}/{needed})", "noMainSleep": "최근 하루는 낮잠만 있음" } }, diff --git a/messages/pl.json b/messages/pl.json index 17e130e90..392c9f424 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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ą" } }, diff --git a/src/components/insights/derived/__tests__/composite-score-anatomy-gap.test.tsx b/src/components/insights/derived/__tests__/composite-score-anatomy-gap.test.tsx new file mode 100644 index 000000000..60c3a089e --- /dev/null +++ b/src/components/insights/derived/__tests__/composite-score-anatomy-gap.test.tsx @@ -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) => { + 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) => + 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(); + 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'); + }); +}); diff --git a/src/components/insights/derived/composite-score-anatomy.tsx b/src/components/insights/derived/composite-score-anatomy.tsx index 1abb147d5..51b8819ea 100644 --- a/src/components/insights/derived/composite-score-anatomy.tsx +++ b/src/components/insights/derived/composite-score-anatomy.tsx @@ -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") diff --git a/src/lib/insights/derived/sleep-score.ts b/src/lib/insights/derived/sleep-score.ts index aed680970..15db6a577 100644 --- a/src/lib/insights/derived/sleep-score.ts +++ b/src/lib/insights/derived/sleep-score.ts @@ -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, diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 61bbf1870..d9f79fc7f 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -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(