Say what the stage tooltip's total actually is

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 09:23:39 +02:00
1 parent 57ec22073c
commit cd12b3886e
2 files changed
+95 -17

No files matched your search

@@ -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.
@@ -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<string, number | string> | 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 (
<div className="bg-popover text-popover-foreground rounded-md border p-2 text-xs shadow-md">
<div className="border-border mb-1 border-b pb-1 font-medium">
@@ -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 (
<div
@@ -435,10 +460,22 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
</div>
);
})}
{totalNight > 0 && (
<div className="border-border mt-1 flex items-center justify-between gap-3 border-t pt-1 font-medium">
<span>{t("insights.sleep.headlineTitle")}</span>
<span>{formatDurationMinutes(totalNight, t)}</span>
{totals.nightMinutes > 0 && (
<div className="border-border mt-1 border-t pt-1 font-medium">
<div className="flex items-center justify-between gap-3">
<span>{t("insights.sleep.mainSleep")}</span>
<span>
{formatDurationMinutes(totals.nightMinutes, t)}
</span>
</div>
{totals.napMinutes > 0 && (
<div className="flex items-center justify-between gap-3">
<span>{t("insights.sleep.totalSleep")}</span>
<span>
{formatDurationMinutes(totals.dayMinutes, t)}
</span>
</div>
)}
</div>
)}
</div>