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:
Marc-André Bombeck committed 2026-07-31 08:20:05 +02:00
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 =
@@ -0,0 +1,217 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// The last test in this file drives the REAL `GET /api/sleep/night` handler so
// the two surfaces can be compared over one input rather than over one fixture
// used twice. The mocks below are the minimum that route needs.
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/tz/resolver", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/tz/resolver")>();
return { ...actual, resolveUserTimezone: vi.fn(async () => "UTC") };
});
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(async () => null),
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: vi.fn() };
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET as sleepNightGET } from "@/app/api/sleep/night/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { buildSleepStageComposition } from "@/lib/analytics/sleep-stage-composition";
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function row(iso: string, stage: string, minutes: number): SleepStageRow {
return {
measuredAt: new Date(iso),
sleepStage: stage as SleepStageRow["sleepStage"],
value: minutes,
source: "APPLE_HEALTH" as SleepStageRow["source"],
};
}
/**
* One overnight block: 23:00 (Jun 3) → 07:00 (Jun 4) UTC, written as the
* per-stage rows Apple Health produces. Wake day = 2026-06-04.
*
* CORE 240 23:00 → 03:00
* DEEP 90 03:00 → 04:30
* REM 80 04:30 → 05:50
* AWAKE 20 05:50 → 06:10
* CORE 50 06:10 → 07:00
*
* Asleep = 460, CORE = 290, in bed = 480.
*/
const NIGHT_ROWS: SleepStageRow[] = [
row("2026-06-04T07:00:00.000Z", "IN_BED", 480),
row("2026-06-04T03:00:00.000Z", "CORE", 240),
row("2026-06-04T04:30:00.000Z", "DEEP", 90),
row("2026-06-04T05:50:00.000Z", "REM", 80),
row("2026-06-04T06:10:00.000Z", "AWAKE", 20),
row("2026-06-04T07:00:00.000Z", "CORE", 50),
];
/**
* A 110-minute afternoon nap on the SAME wake day: 14:00 → 15:50 UTC. Seven
* hours clear of the night's 07:00 wake, so it clusters as its own session.
*
* Deliberately large and deliberately CORE — folded into the night it moves
* that night's CORE from 290 to 400, which no rounding or tolerance can hide.
*/
const NAP_ROWS: SleepStageRow[] = [
row("2026-06-04T15:50:00.000Z", "CORE", 110),
];
const DAY = "2026-06-04";
describe("buildSleepStageComposition", () => {
it("keeps an afternoon nap out of the night's stage composition", () => {
const rows = [...NIGHT_ROWS, ...NAP_ROWS];
// What the per-wake-day totals say — the shape this surface used to
// publish. The nap's CORE is inside the night.
const folded = reconstructSleepNights(rows, "UTC", null);
expect(folded).toHaveLength(1);
expect(folded[0].stages.CORE).toBe(400);
const composition = buildSleepStageComposition(rows, "UTC", null, 30);
expect(composition).not.toBeNull();
expect(composition!.perNight).toHaveLength(1);
const night = composition!.perNight[0];
expect(night.dayKey).toBe(DAY);
// The night's own CORE, not the night plus the nap.
expect(night.stages.CORE).toBe(290);
expect(night.stages.DEEP).toBe(90);
expect(night.stages.REM).toBe(80);
expect(night.stages.AWAKE).toBe(20);
// The nap is reported, separately, at its full length.
expect(night.napMinutes).toBe(110);
expect(night.napCount).toBe(1);
// The window aggregate follows the nights, so it is not inflated either.
expect(composition!.stages.CORE).toBe(290);
expect(composition!.nights).toBe(1);
});
it("leaves a night without a nap exactly as it was", () => {
// `reconstructSleepNights` is the path this surface used before the
// split. On a day with no nap the new path must reproduce it exactly.
const before = reconstructSleepNights(NIGHT_ROWS, "UTC", null);
expect(before).toHaveLength(1);
const composition = buildSleepStageComposition(NIGHT_ROWS, "UTC", null, 30);
expect(composition).not.toBeNull();
expect(composition!.perNight).toHaveLength(1);
const night = composition!.perNight[0];
expect(night.dayKey).toBe(before[0].night);
expect(night.stages).toEqual(before[0].stages);
expect(composition!.stages).toEqual(before[0].stages);
expect(composition!.totalMinutes).toBe(
Object.values(before[0].stages).reduce((a, b) => a + (b ?? 0), 0),
);
// No nap means no nap fields at all — nothing for the chart to draw and
// nothing to caption.
expect(night).not.toHaveProperty("napMinutes");
expect(night).not.toHaveProperty("napCount");
});
});
describe("night view and stage composition agree on which session is the night", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
it("publishes the same night and the same nap total over one input", async () => {
const rows = [...NIGHT_ROWS, ...NAP_ROWS];
vi.mocked(prisma.measurement.findMany).mockResolvedValue(rows as never);
// Surface 1 — the real hypnogram route, which has always known the
// difference between a night and a nap.
const res = await sleepNightGET(
new NextRequest(`http://localhost/api/sleep/night?date=${DAY}`),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
night: string;
main: { stages: Record<string, number>; asleepMinutes: number };
naps: Array<{ asleepMinutes: number }>;
};
};
// Surface 2 — the stage composition, over the very same rows.
const composition = buildSleepStageComposition(rows, "UTC", null, 30);
expect(composition).not.toBeNull();
const night = composition!.perNight.find(
(n) => n.dayKey === body.data.night,
);
expect(night).toBeDefined();
// The composition's night IS the route's main session: same wake day,
// same per-stage map, minute for minute.
const routeStages = body.data.main.stages;
const compositionStages = Object.fromEntries(
Object.entries(night!.stages).map(([stage, minutes]) => [
stage,
Math.round(minutes),
]),
);
expect(compositionStages).toEqual(routeStages);
// And the nap the composition reports is the nap the route reports —
// the same count, and the same minutes the route publishes for it.
expect(body.data.naps).toHaveLength(1);
expect(night!.napCount).toBe(body.data.naps.length);
expect(night!.napMinutes).toBe(
body.data.naps.reduce((sum, nap) => sum + nap.asleepMinutes, 0),
);
// Guard against both surfaces agreeing on a wrong answer: the nap is
// real, large, and outside the night on both sides.
expect(body.data.naps[0].asleepMinutes).toBe(110);
expect(compositionStages.CORE).toBe(290);
});
});