mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
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}
|
||||
|
||||
Reference in new issue
Block a user