From 830c847d3b26a43774d64fec5fadfe20d68c1a4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 18:38:57 +0200 Subject: [PATCH] fix(insights): stop double-counting IN_BED in the sleep-phase chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../sleep-stage-stacked-bar.test.tsx | 50 +++++++++++++++++++ .../insights/sleep-stage-stacked-bar.tsx | 11 +++- 2 files changed, 59 insertions(+), 2 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 41a70be79..9aedff7a4 100644 --- a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx +++ b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx @@ -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("", () => { ).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)[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(); + expect(html).toContain('data-slot="sleep-stage-stacked-bar"'); + }); + }); + /** * v1.4.25 W3f — per-night surface + window toggle. */ diff --git a/src/components/insights/sleep-stage-stacked-bar.tsx b/src/components/insights/sleep-stage-stacked-bar.tsx index fee161957..b70c4a8ba 100644 --- a/src/components/insights/sleep-stage-stacked-bar.tsx +++ b/src/components/insights/sleep-stage-stacked-bar.tsx @@ -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; /**