From c245a3f2af0103815ab80847b4fc5300a488f8f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 15:09:47 +0200 Subject: [PATCH] fix(sleep): list only the stages the window shows in the composition legend The legend lists every mounted bar, filled or not, so a week of staged nights still showed the green "Asleep" swatch for a band that appears in no column. Each stage band now mounts only when the visible window holds minutes for it, as the nap band already did. Columns are unchanged: an empty band drew nothing. --- .../sleep-stage-stacked-bar.test.tsx | 47 +++++++++++++++++++ .../insights/sleep-stage-stacked-bar.tsx | 44 ++++++++++------- 2 files changed, 75 insertions(+), 16 deletions(-) diff --git a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx index 59fd59051..c38bf6bbd 100644 --- a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx +++ b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx @@ -8,6 +8,7 @@ import { STAGE_ORDER, buildCompositionRows, compositionTotals, + stagesInWindow, windowHasNap, type SleepStageBreakdown, type SleepStageNight, @@ -343,3 +344,49 @@ describe("nap band", () => { ); }); }); + +describe("legend follows the visible stages", () => { + const label = (dayKey: string) => dayKey; + + it("leaves a stage the window never holds out of the mounted bands", () => { + // A staged week: deep, REM, core and wake bouts, no unspecified "asleep" + // minutes. Every mounted band is a legend entry, so ASLEEP must not be one. + const perNight: SleepStageNight[] = [ + { + dayKey: "2026-06-03", + stages: { DEEP: 60, REM: 90, CORE: 250, AWAKE: 15 }, + }, + { dayKey: "2026-06-04", stages: { DEEP: 55, REM: 80, CORE: 260 } }, + ]; + const { rows } = buildCompositionRows(perNight, 7, label); + expect(stagesInWindow(rows)).toEqual(["DEEP", "REM", "CORE", "AWAKE"]); + }); + + it("keeps the unspecified stage when an older source sends it", () => { + const perNight: SleepStageNight[] = [ + { dayKey: "2026-06-03", stages: { ASLEEP: 410, AWAKE: 20 } }, + ]; + const { rows } = buildCompositionRows(perNight, 7, label); + expect(stagesInWindow(rows)).toEqual(["ASLEEP", "AWAKE"]); + }); + + it("keeps the stack order whatever order the payload uses", () => { + const perNight: SleepStageNight[] = [ + { dayKey: "2026-06-03", stages: { AWAKE: 10, CORE: 200, DEEP: 40 } }, + ]; + const { rows } = buildCompositionRows(perNight, 7, label); + expect(stagesInWindow(rows)).toEqual(["DEEP", "CORE", "AWAKE"]); + }); + + it("follows the window, not the whole series", () => { + const perNight: SleepStageNight[] = [ + { dayKey: "2026-05-01", stages: { ASLEEP: 400 } }, + ...Array.from({ length: 7 }, (_, i) => ({ + dayKey: `2026-06-0${i + 1}`, + stages: { CORE: 250, DEEP: 50 }, + })), + ]; + const { rows } = buildCompositionRows(perNight, 7, label); + expect(stagesInWindow(rows)).toEqual(["DEEP", "CORE"]); + }); +}); diff --git a/src/components/insights/sleep-stage-stacked-bar.tsx b/src/components/insights/sleep-stage-stacked-bar.tsx index 6e9e0ef86..362fd6cc6 100644 --- a/src/components/insights/sleep-stage-stacked-bar.tsx +++ b/src/components/insights/sleep-stage-stacked-bar.tsx @@ -241,6 +241,26 @@ export function windowHasNap(rows: readonly CompositionRow[]): boolean { ); } +/** + * The stages the visible window actually holds, in stack order. + * + * Recharts lists every mounted `` in the legend, filled or not. With all + * five stages mounted, a week of staged nights (deep, core, REM, awake) still + * showed a green "Asleep" swatch for a band that appears nowhere in the + * columns: ASLEEP is the unspecified stage only older sources send. A stage + * with no minutes in the window draws nothing, so leaving it unmounted + * changes no column and keeps the legend to what is on screen. + */ +export function stagesInWindow( + rows: readonly CompositionRow[], +): Array<(typeof STAGE_ORDER)[number]> { + return STAGE_ORDER.filter((stage) => + rows.some( + (row) => typeof row[stage] === "number" && (row[stage] as number) > 0, + ), + ); +} + /** * Totals for one hovered column, split the way the tooltip reports them. * @@ -313,17 +333,13 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { ).rows; }, [breakdown, windowDays, locale, t]); - // Mount the nap band only when the VISIBLE window holds one. + // Mount the nap band only when the VISIBLE window holds one, and each + // stage band likewise: the legend lists whatever is mounted. const hasNap = useMemo(() => windowHasNap(data), [data]); + const visibleStages = useMemo(() => stagesInWindow(data), [data]); // Empty-state guard — no perNight rows AND no aggregate. - const hasData = - data.length > 0 && - data.some((row) => - STAGE_ORDER.some( - (stage) => typeof row[stage] === "number" && (row[stage] as number) > 0, - ), - ); + const hasData = visibleStages.length > 0; // v1.42 — each night's column opens the day it ended on (the day the // per-night key names, as `/api/sleep/night` does), through the same doors @@ -347,13 +363,9 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { : null; }, }); - const tableColumns = [...STAGE_ORDER, ...(hasNap ? [NAP_KEY] : [])] - .filter((stage) => - data.some( - (row) => typeof row[stage] === "number" && (row[stage] as number) > 0, - ), - ) - .map((stage) => ({ key: stage, label: stageLabels[stage] ?? stage })); + const tableColumns = [...visibleStages, ...(hasNap ? [NAP_KEY] : [])].map( + (stage) => ({ key: stage, label: stageLabels[stage] ?? stage }), + ); const ariaLabel = t("insights.sleep.compositionAriaLabel", { nights: breakdown.nights, @@ -563,7 +575,7 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { wrapperStyle={{ fontSize: 11 }} formatter={(value: string) => stageLabels[value] ?? value} /> - {STAGE_ORDER.map((stage) => ( + {visibleStages.map((stage) => (