fix(insights): stop double-counting IN_BED in the sleep-phase chart

IN_BED is the TOTAL time in bed (≈ CORE + DEEP + REM + AWAKE), not a sleep
phase, but it was a stacked Bar segment alongside the phases. Stacking the
total on top of its own constituents doubled every bar — a 7 h night drew as
~14 h dominated by the in-bed segment — and inflated the tooltip's per-night
total, which sums the stacked series. Drop IN_BED from STAGE_ORDER so the bar
height and the tooltip total are the real night. The STAGE_COLORS.IN_BED
token and the inBed label stay for the last-night hypnogram, which still
renders the in-bed span. STAGE_ORDER is now exported so the test pins that
IN_BED is not a stack series and the total equals CORE + DEEP + REM + AWAKE.
This commit is contained in:
Marc-André Bombeck committed 2026-06-08 18:38:57 +02:00
1 parent 6f5f035b78
commit 830c847d3b
2 files changed
+59 -2

No files matched your search

@@ -4,6 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
SleepStageStackedBar,
STAGE_ORDER,
type SleepStageBreakdown,
} from "../sleep-stage-stacked-bar";
@@ -92,6 +93,55 @@ describe("<SleepStageStackedBar>", () => {
).not.toThrow();
});
/**
* v1.15.18 — IN_BED is the TOTAL time in bed (≈ CORE + DEEP + REM +
* AWAKE), not a sleep PHASE. Stacking it doubled every bar and inflated
* the tooltip per-night total. It must not be a stack segment.
*/
describe("IN_BED is not double-counted in the stack (v1.15.18)", () => {
it("excludes IN_BED from the stacked phase order", () => {
expect(STAGE_ORDER).not.toContain("IN_BED");
// The genuine phases (+ the legacy ASLEEP bucket) still stack.
expect(STAGE_ORDER).toEqual(["DEEP", "REM", "CORE", "ASLEEP", "AWAKE"]);
});
it("the per-night stack total is CORE + DEEP + REM + AWAKE, not the inflated IN_BED-inclusive sum", () => {
// The tooltip sums `payload.reduce` over exactly the stacked series.
// With IN_BED out of STAGE_ORDER it is no longer a Bar, so it cannot
// enter the payload and the total self-corrects to the real night.
const night = { CORE: 240.7, DEEP: 70.6, REM: 90.6, AWAKE: 34.9 };
const IN_BED = 436.7; // the reported total in bed
const stackedTotal = STAGE_ORDER.reduce(
(sum, stage) => sum + ((night as Record<string, number>)[stage] ?? 0),
0,
);
// Sums to the true night (~6 h 56 m), close to the real IN_BED total,
// and crucially NOT IN_BED + phases (~14 h) the old stack produced.
expect(stackedTotal).toBeCloseTo(436.8, 1);
expect(stackedTotal).toBeLessThan(IN_BED + 1);
expect(stackedTotal).not.toBeCloseTo(IN_BED + stackedTotal, 1);
});
it("does not stack IN_BED even when the payload carries it", () => {
// A night whose breakdown includes the IN_BED total must render the
// phase bars only; the chart must still mount without IN_BED dominating.
const breakdown: SleepStageBreakdown = {
windowDays: 30,
nights: 1,
totalMinutes: 437,
stages: { DEEP: 71, REM: 91, CORE: 241, AWAKE: 35, IN_BED: 437 },
perNight: [
{
dayKey: "2026-06-08",
stages: { DEEP: 71, REM: 91, CORE: 241, AWAKE: 35, IN_BED: 437 },
},
],
};
const html = render(<SleepStageStackedBar breakdown={breakdown} />);
expect(html).toContain('data-slot="sleep-stage-stacked-bar"');
});
});
/**
* v1.4.25 W3f — per-night surface + window toggle.
*/
@@ -72,14 +72,21 @@ type WindowSize = (typeof WINDOW_DAYS)[number];
/**
* Order on the stack — deepest restorative stages first so a user
* scanning left → right reads quality before context.
*
* IN_BED is deliberately NOT a stack segment. It is the TOTAL time in
* bed (≈ CORE + DEEP + REM + AWAKE), so stacking it on top of those
* phases doubled every bar (~14 h for a 7 h night) and inflated the
* tooltip's per-night total. With it out of the stack the bar height
* and the tooltip total are the real night. The `STAGE_COLORS.IN_BED`
* token + the `insights.sleep.stages.inBed` label survive for the
* last-night hypnogram, which still renders the in-bed span.
*/
const STAGE_ORDER = [
export const STAGE_ORDER = [
"DEEP",
"REM",
"CORE",
"ASLEEP",
"AWAKE",
"IN_BED",
] as const;
/**