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)} + +
+ )}
)}