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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 15:09:47 +02:00
1 parent 74244efbe1
commit c245a3f2af
2 files changed
+75 -16

No files matched your search

@@ -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"]);
});
});
@@ -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 `<Bar>` 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) => (
<Bar
key={stage}
dataKey={stage}