From cd12b3886e31c9e08b9c6015332a36a091a5fd52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 09:23:39 +0200 Subject: [PATCH] Say what the stage tooltip's total actually is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #611. The nap separation itself shipped in v1.34.4; what remained of the report was the tooltip footer labelling a single night's total with the orphaned 'Average per night' key (the headline card that owned it was dropped in v1.22.0, so hovering one column claimed an average that was that night's sum). The footer now names the main session's sleep, and on a day with an inferred nap adds the whole-day total beside it — the Main sleep / Naps / Total triple the report asked for. Percentages keep dividing by the night alone. --- .../sleep-stage-stacked-bar.test.tsx | 41 +++++++++++ .../insights/sleep-stage-stacked-bar.tsx | 71 ++++++++++++++----- 2 files changed, 95 insertions(+), 17 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 afe9fd8a1..3ff6b6baa 100644 --- a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx +++ b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx @@ -6,6 +6,7 @@ import { SleepStageStackedBar, STAGE_ORDER, buildCompositionRows, + compositionTotals, windowHasNap, type SleepStageBreakdown, type SleepStageNight, @@ -270,6 +271,46 @@ describe("nap band", () => { expect(windowHasNap(rows)).toBe(false); }); + /** + * The tooltip footer used to label a single night's total "Average per + * night" — the orphaned title of a headline card dropped in v1.22.0. The + * footer now says what the figure is: the main sleep session, with a + * second "total" line on days a nap rides on top (issue #611's + * Main sleep / Naps / Total sleep shape). Recharts renders no tooltip + * under SSR, so the split is pinned through the pure totals helper the + * tooltip reads. + */ + it("totals the main session and the whole day separately when a nap rides on the column", () => { + const perNight: SleepStageNight[] = [ + { + dayKey: "2026-06-04", + stages: { CORE: 250, DEEP: 50, AWAKE: 20 }, + napMinutes: 80, + napCount: 1, + }, + ]; + const { rows } = buildCompositionRows(perNight, 7, label); + const totals = compositionTotals(rows[0]); + // The main figure is the night column alone — the nap never leaks in, + // so the per-stage percentages keep meaning "of this night". + expect(totals.nightMinutes).toBe(320); + expect(totals.napMinutes).toBe(80); + // The day line is the whole drawn column: night plus nap. + expect(totals.dayMinutes).toBe(400); + }); + + it("collapses the totals to the night alone on a day without a nap", () => { + const perNight: SleepStageNight[] = [ + { dayKey: "2026-06-03", stages: { CORE: 250, DEEP: 50 } }, + ]; + const { rows } = buildCompositionRows(perNight, 7, label); + const totals = compositionTotals(rows[0]); + expect(totals.nightMinutes).toBe(300); + expect(totals.napMinutes).toBe(0); + // No second line to render — the day IS the night. + expect(totals.dayMinutes).toBe(300); + }); + it("drops the band when the nap falls outside the visible window", () => { // The nap is on the oldest day; a 2-day window scrolls past it, so the // legend must not keep a nap entry the user cannot see. diff --git a/src/components/insights/sleep-stage-stacked-bar.tsx b/src/components/insights/sleep-stage-stacked-bar.tsx index 15e50bb0b..b5b6ae800 100644 --- a/src/components/insights/sleep-stage-stacked-bar.tsx +++ b/src/components/insights/sleep-stage-stacked-bar.tsx @@ -231,6 +231,33 @@ export function windowHasNap(rows: readonly CompositionRow[]): boolean { ); } +/** + * Totals for one hovered column, split the way the tooltip reports them. + * + * `nightMinutes` is the sum of the stacked stage bands — the main session + * only, the same figure the column draws and the per-stage percentages + * divide by. `napMinutes` is the day's inferred naps. `dayMinutes` is the + * whole drawn column, night plus naps; it equals `nightMinutes` on a day + * without one, and the tooltip renders its line only when it differs. + * + * Pulled out of the tooltip body because Recharts renders no tooltip under + * SSR — this is the only place the footer split can be pinned by a test. + */ +export function compositionTotals(row: CompositionRow | undefined): { + nightMinutes: number; + napMinutes: number; + dayMinutes: number; +} { + let nightMinutes = 0; + for (const stage of STAGE_ORDER) { + const minutes = row?.[stage]; + if (typeof minutes === "number") nightMinutes += minutes; + } + const nap = row?.[NAP_KEY]; + const napMinutes = typeof nap === "number" ? nap : 0; + return { nightMinutes, napMinutes, dayMinutes: nightMinutes + napMinutes }; +} + export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { const { t, locale } = useTranslations(); @@ -379,17 +406,13 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { Record | undefined; const napCount = typeof row?.napCount === "number" ? row.napCount : 0; - // The footer total is the NIGHT. The nap is listed above - // it as its own line and stays out of the sum, so the - // per-stage percentages keep meaning "of this night". - const totalNight = payload.reduce( - (sum, entry) => - entry.dataKey === NAP_KEY - ? sum - : sum + - (typeof entry.value === "number" ? entry.value : 0), - 0, - ); + // The footer's first line is the NIGHT. The nap is listed + // above it as its own line and stays out of that sum, so + // the per-stage percentages keep meaning "of this night". + // On a day with a nap a second line carries the whole + // column — main sleep plus naps — the Main sleep / Naps / + // Total split issue #611 asked for. + const totals = compositionTotals(row); return (
@@ -409,8 +432,10 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { : stageLabels[NAP_KEY] : (stageLabels[stage] ?? stage); const pct = - totalNight > 0 - ? Math.round((minutes / totalNight) * 100) + totals.nightMinutes > 0 + ? Math.round( + (minutes / totals.nightMinutes) * 100, + ) : 0; return (
); })} - {totalNight > 0 && ( -
- {t("insights.sleep.headlineTitle")} - {formatDurationMinutes(totalNight, t)} + {totals.nightMinutes > 0 && ( +
+
+ {t("insights.sleep.mainSleep")} + + {formatDurationMinutes(totals.nightMinutes, t)} + +
+ {totals.napMinutes > 0 && ( +
+ {t("insights.sleep.totalSleep")} + + {formatDurationMinutes(totals.dayMinutes, t)} + +
+ )}
)}