mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
Pin the nap split against the night view, and the empty case against itself
Three things are worth not regressing: a big afternoon nap must not land in the night's stage buckets, a day without a nap must come out exactly as the old per-wake-day path produced it, and the two surfaces must name the same session as the night for the same rows. The last one drives the real /api/sleep/night handler rather than re-deriving its answer. The chart's row assembly moved into a plain function so the nap band can be tested at all; Recharts draws nothing under server rendering.
This commit is contained in:
1 parent
1f8c31d29e
commit
a99e98332c
3 files changed
+324
-24
No files matched your search
@@ -5,7 +5,10 @@ import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
SleepStageStackedBar,
|
||||
STAGE_ORDER,
|
||||
buildCompositionRows,
|
||||
windowHasNap,
|
||||
type SleepStageBreakdown,
|
||||
type SleepStageNight,
|
||||
} from "../sleep-stage-stacked-bar";
|
||||
|
||||
/**
|
||||
@@ -230,3 +233,61 @@ describe("<SleepStageStackedBar>", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("nap band", () => {
|
||||
/**
|
||||
* Recharts draws nothing under `renderToStaticMarkup`, so the band itself
|
||||
* is pinned through the pure row builder the chart feeds.
|
||||
*/
|
||||
const label = (dayKey: string) => dayKey;
|
||||
|
||||
it("carries the nap minutes on the day that has one", () => {
|
||||
const perNight: SleepStageNight[] = [
|
||||
{ dayKey: "2026-06-03", stages: { CORE: 250, DEEP: 50 } },
|
||||
{
|
||||
dayKey: "2026-06-04",
|
||||
stages: { CORE: 250, DEEP: 50 },
|
||||
napMinutes: 110,
|
||||
napCount: 1,
|
||||
},
|
||||
];
|
||||
const { rows } = buildCompositionRows(perNight, 7, label);
|
||||
expect(rows[1].NAP).toBe(110);
|
||||
expect(rows[1].napCount).toBe(1);
|
||||
// The night's own stages are untouched by the nap sitting beside them.
|
||||
expect(rows[1].CORE).toBe(250);
|
||||
// And the day before, which had no nap, carries nothing.
|
||||
expect(rows[0].NAP).toBe(0);
|
||||
expect(windowHasNap(rows)).toBe(true);
|
||||
});
|
||||
|
||||
it("mounts no band at all on a week without a nap", () => {
|
||||
const perNight: SleepStageNight[] = [
|
||||
{ dayKey: "2026-06-03", stages: { CORE: 250, DEEP: 50 } },
|
||||
{ dayKey: "2026-06-04", stages: { CORE: 260, DEEP: 40 } },
|
||||
];
|
||||
const { rows } = buildCompositionRows(perNight, 7, label);
|
||||
expect(windowHasNap(rows)).toBe(false);
|
||||
});
|
||||
|
||||
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.
|
||||
const perNight: SleepStageNight[] = [
|
||||
{
|
||||
dayKey: "2026-06-02",
|
||||
stages: { CORE: 250 },
|
||||
napMinutes: 110,
|
||||
napCount: 1,
|
||||
},
|
||||
{ dayKey: "2026-06-03", stages: { CORE: 250 } },
|
||||
{ dayKey: "2026-06-04", stages: { CORE: 260 } },
|
||||
];
|
||||
expect(windowHasNap(buildCompositionRows(perNight, 3, label).rows)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(windowHasNap(buildCompositionRows(perNight, 2, label).rows)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -159,6 +159,48 @@ function formatDayTick(
|
||||
});
|
||||
}
|
||||
|
||||
/** One Recharts row: the day's label plus a numeric key per drawn band. */
|
||||
export type CompositionRow = Record<string, number | string>;
|
||||
|
||||
/**
|
||||
* Turn the trailing window of nights into the chart's rows.
|
||||
*
|
||||
* A day without a nap gets a plain `NAP: 0`, which draws as nothing. Pulled
|
||||
* out of the component because Recharts renders no markup under SSR, so this
|
||||
* is the only place the nap decision can actually be pinned by a test.
|
||||
*/
|
||||
export function buildCompositionRows(
|
||||
perNight: readonly SleepStageNight[],
|
||||
windowDays: number,
|
||||
formatLabel: (dayKey: string) => string,
|
||||
): { rows: CompositionRow[] } {
|
||||
const trailing = perNight.slice(-windowDays);
|
||||
const rows = trailing.map((night) => {
|
||||
const row: CompositionRow = {
|
||||
dayKey: night.dayKey,
|
||||
label: formatLabel(night.dayKey),
|
||||
};
|
||||
for (const stage of STAGE_ORDER) {
|
||||
row[stage] = night.stages[stage] ?? 0;
|
||||
}
|
||||
row[NAP_KEY] = night.napMinutes ?? 0;
|
||||
row.napCount = night.napCount ?? 0;
|
||||
return row;
|
||||
});
|
||||
return { rows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the visible window holds a nap at all. When it does not, the nap
|
||||
* band is never mounted — so there is no bar, no legend entry, and no tooltip
|
||||
* row saying a nap did not happen.
|
||||
*/
|
||||
export function windowHasNap(rows: readonly CompositionRow[]): boolean {
|
||||
return rows.some(
|
||||
(row) => typeof row[NAP_KEY] === "number" && (row[NAP_KEY] as number) > 0,
|
||||
);
|
||||
}
|
||||
|
||||
export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
|
||||
@@ -197,33 +239,13 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
|
||||
}
|
||||
return [fallbackRow];
|
||||
}
|
||||
const trailing = perNight.slice(-windowDays);
|
||||
return trailing.map((night) => {
|
||||
const row: Record<string, number | string> = {
|
||||
dayKey: night.dayKey,
|
||||
label: formatDayTick(night.dayKey, windowDays, locale),
|
||||
};
|
||||
for (const stage of STAGE_ORDER) {
|
||||
row[stage] = night.stages[stage] ?? 0;
|
||||
}
|
||||
// A day without a nap carries a plain 0 here, which Recharts draws as
|
||||
// nothing; the band itself is only mounted when some day in the window
|
||||
// has one, so the legend stays clean on a nap-less week.
|
||||
row[NAP_KEY] = night.napMinutes ?? 0;
|
||||
row.napCount = night.napCount ?? 0;
|
||||
return row;
|
||||
});
|
||||
return buildCompositionRows(perNight, windowDays, (dayKey) =>
|
||||
formatDayTick(dayKey, windowDays, locale),
|
||||
).rows;
|
||||
}, [breakdown, windowDays, locale, t]);
|
||||
|
||||
// Mount the nap band only when the VISIBLE window holds one.
|
||||
const hasNap = useMemo(
|
||||
() =>
|
||||
data.some(
|
||||
(row) =>
|
||||
typeof row[NAP_KEY] === "number" && (row[NAP_KEY] as number) > 0,
|
||||
),
|
||||
[data],
|
||||
);
|
||||
const hasNap = useMemo(() => windowHasNap(data), [data]);
|
||||
|
||||
// Empty-state guard — no perNight rows AND no aggregate.
|
||||
const hasData =
|
||||
|
||||
Reference in new issue
Block a user