From 1f8c31d29efb433d333262e463a1acb947157a21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 08:13:58 +0200 Subject: [PATCH 1/2] Keep a daytime nap out of the night in the sleep stage composition The stage-composition chart totalled each wake day, and a nap ends on the same wake day as that morning's night. An afternoon nap was added straight into the night's stage buckets, so the column read longer and shallower than the night actually was. The composition now asks the same question the night view already asks: which session was the night. The night's stages are the night's, and the nap sits on its own band above them, in its own colour, with its own line in the tooltip. Days without a nap look exactly as they did. --- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/pl.json | 2 + src/app/api/analytics/route.ts | 57 +++------- .../insights/sleep-stage-stacked-bar.tsx | 76 ++++++++++++- src/lib/analytics/sleep-night.ts | 40 +++++++ src/lib/analytics/sleep-stage-composition.ts | 103 ++++++++++++++++++ 10 files changed, 244 insertions(+), 44 deletions(-) create mode 100644 src/lib/analytics/sleep-stage-composition.ts diff --git a/messages/de.json b/messages/de.json index 510ee0524..0c8bfabec 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2968,6 +2968,8 @@ "asleep": "Schlafend", "awake": "Wach", "inBed": "Im Bett", + "nap": "Nickerchen", + "napCount": "{count} Nickerchen", "unavailable": "Noch keine Phasen-Daten – Schlafphasen erscheinen hier, sobald eine verbundene Quelle sie liefert." }, "empty": { diff --git a/messages/en.json b/messages/en.json index 8c12b07f3..1b6b973a2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2968,6 +2968,8 @@ "asleep": "Asleep", "awake": "Awake", "inBed": "In bed", + "nap": "Nap", + "napCount": "{count} naps", "unavailable": "No stage data yet — sleep stages appear here as soon as a connected source delivers them." }, "empty": { diff --git a/messages/es.json b/messages/es.json index e4d6838cd..9d382527b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2968,6 +2968,8 @@ "asleep": "Dormido", "awake": "Despierto", "inBed": "En la cama", + "nap": "Siesta", + "napCount": "{count} siestas", "unavailable": "Aún no hay datos de fases — las fases de sueño aparecerán aquí en cuanto una fuente conectada las proporcione." }, "empty": { diff --git a/messages/fr.json b/messages/fr.json index 3310a4e0d..6b0933ebc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2968,6 +2968,8 @@ "asleep": "Endormi", "awake": "Éveillé", "inBed": "Au lit", + "nap": "Sieste", + "napCount": "{count} siestes", "unavailable": "Pas encore de données de phases — les phases de sommeil apparaîtront ici dès qu’une source connectée les fournira." }, "empty": { diff --git a/messages/it.json b/messages/it.json index 752f960dd..000b94bdc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2968,6 +2968,8 @@ "asleep": "Addormentato", "awake": "Sveglio", "inBed": "A letto", + "nap": "Pisolino", + "napCount": "{count} pisolini", "unavailable": "Ancora nessun dato sulle fasi — le fasi del sonno appariranno qui non appena una fonte collegata le fornirà." }, "empty": { diff --git a/messages/pl.json b/messages/pl.json index a997b9966..d604acabe 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2968,6 +2968,8 @@ "asleep": "Śpi", "awake": "Czuwa", "inBed": "W łóżku", + "nap": "Drzemka", + "napCount": "{count} drzemki/ek", "unavailable": "Brak danych o fazach — fazy snu pojawią się tutaj, gdy tylko dostarczy je połączone źródło." }, "empty": { diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index b765ba158..aebd44534 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -9,7 +9,10 @@ import { computeSummariesSlice } from "@/lib/analytics/summaries-slice"; import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range"; import { bpTargetsEqual, getBpTargets } from "@/lib/analytics/bp-targets"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; -import { reconstructSleepNights } from "@/lib/analytics/sleep-night"; +import { + buildSleepStageComposition, + type SleepStageComposition, +} from "@/lib/analytics/sleep-stage-composition"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups"; import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; @@ -620,28 +623,30 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { * Berlin-tz day inside the window, with minutes-per-stage for the * stacked-bar chart. Days with zero stage-tagged rows are omitted * from `perNight` (the chart treats them as gaps). + * + * The per-night stages are the MAIN NIGHT's stages. A nap ends on the + * same wake day as that morning's night, and the per-wake-day totals + * folded it into the night's buckets — an afternoon nap added CORE to + * the night and the column read longer and shallower than the night + * was. The nap's minutes now ride alongside on `napMinutes` / + * `napCount`, both absent on a day without one. */ async function computeSleepStageBreakdown( userId: string, userTz: string, -): Promise<{ - windowDays: number; - nights: number; - totalMinutes: number; - stages: Record; - perNight: Array<{ dayKey: string; stages: Record }>; -} | null> { +): Promise { const DAY_MS = 24 * 60 * 60 * 1000; - const since = new Date(Date.now() - 30 * DAY_MS); + const WINDOW_DAYS = 30; + const since = new Date(Date.now() - WINDOW_DAYS * DAY_MS); // v1.11.5 — read the FULL per-stage row set (not `sleepStage: { not: null }`) - // and route it through the canonical `reconstructSleepNights` so the + // and route it through the canonical session reconstruction so the // stacked-bar agrees with the dashboard tile + chart series. The old path // keyed each stage row by its OWN calendar day (splitting a midnight- // spanning night across two buckets) and never collapsed multi-source // (double-counting a WHOOP + Apple Health night). Routing through the // helper fixes both: it session-clusters, keys by the wake day, and picks // one canonical source per night. The bare-vs-granular de-dup is applied - // per-night to the per-stage breakdown too. The `source` is selected so + // per-session to the per-stage breakdown too. The `source` is selected so // the helper's source-collapse can run. const rows = await prisma.measurement.findMany({ where: { @@ -662,35 +667,7 @@ async function computeSleepStageBreakdown( if (rows.length === 0) return null; const priorityJson = await loadUserSourcePriority(userId); - const nights = reconstructSleepNights(rows, userTz, priorityJson).filter( - (n) => Object.keys(n.stages).length > 0, - ); - if (nights.length === 0) return null; - - // Aggregate the per-night stage breakdowns into the window totals + the - // per-night series the chart slices for its 7 / 14 / 30 toggle. - const stages: Record = {}; - let totalMinutes = 0; - const perNight = nights - .map((n) => { - const nightStages: Record = {}; - for (const [stage, mins] of Object.entries(n.stages)) { - if (mins == null) continue; - nightStages[stage] = mins; - stages[stage] = (stages[stage] ?? 0) + mins; - totalMinutes += mins; - } - return { dayKey: n.night, stages: nightStages }; - }) - .sort((a, b) => a.dayKey.localeCompare(b.dayKey)); - - return { - windowDays: 30, - nights: nights.length, - totalMinutes, - stages, - perNight, - }; + return buildSleepStageComposition(rows, userTz, priorityJson, WINDOW_DAYS); } // v1.4.37 W2 — `computeCorrelationHypotheses` body relocated to diff --git a/src/components/insights/sleep-stage-stacked-bar.tsx b/src/components/insights/sleep-stage-stacked-bar.tsx index 0203dc2d8..615c5d8c9 100644 --- a/src/components/insights/sleep-stage-stacked-bar.tsx +++ b/src/components/insights/sleep-stage-stacked-bar.tsx @@ -44,7 +44,16 @@ import type { Locale } from "@/lib/i18n/config"; export interface SleepStageNight { /** Berlin-tz day key (YYYY-MM-DD). */ dayKey: string; + /** Per-stage minutes of the MAIN night. Naps are not in here. */ stages: Record; + /** + * Time asleep across this day's naps, in minutes. Absent on a day with no + * nap, which is most days — the chart draws nothing for it and the legend + * does not carry a nap entry. + */ + napMinutes?: number; + /** How many naps that is. Absent alongside `napMinutes`. */ + napCount?: number; } export interface SleepStageBreakdown { @@ -87,6 +96,16 @@ type WindowSize = (typeof WINDOW_DAYS)[number]; */ export const STAGE_ORDER = ["DEEP", "REM", "CORE", "ASLEEP", "AWAKE"] as const; +/** + * The nap band. Not a sleep stage — it is the day's daytime sleep, kept out + * of the night's stage buckets and drawn as its own block on top of the + * column so the night below it reads as the night. + * + * The band is only rendered when the visible window actually holds a nap. On + * a week without one there is no bar, no legend entry, and no tooltip row. + */ +const NAP_KEY = "NAP"; + /** * Dracula stage palette. Exported so the last-night hypnogram * (`sleep-hypnogram.tsx`) reuses the exact same tokens — per the @@ -99,6 +118,7 @@ export const STAGE_COLORS: Record = { ASLEEP: "var(--success)", // dracula-green — legacy iOS 15- unspecified AWAKE: "var(--dracula-yellow)", // dracula-yellow — wake bouts IN_BED: "var(--chart-inbed)", // muted blue-grey — pre-asleep + [NAP_KEY]: "var(--dracula-orange)", // daytime sleep, not a night stage }; /** @@ -155,6 +175,7 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { ASLEEP: t("insights.sleep.stages.asleep"), AWAKE: t("insights.sleep.stages.awake"), IN_BED: t("insights.sleep.stages.inBed"), + [NAP_KEY]: t("insights.sleep.stages.nap"), }; // v1.4.25 W3f — per-night dataset. Slice the trailing N nights and @@ -185,10 +206,25 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { 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; }); }, [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], + ); + // Empty-state guard — no perNight rows AND no aggregate. const hasData = data.length > 0 && @@ -287,10 +323,19 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { content={({ active, payload, label }) => { if (!active || !payload || payload.length === 0) return null; + const row = payload[0]?.payload as + 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) => - sum + - (typeof entry.value === "number" ? entry.value : 0), + entry.dataKey === NAP_KEY + ? sum + : sum + + (typeof entry.value === "number" ? entry.value : 0), 0, ); return ( @@ -303,6 +348,14 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { const minutes = typeof entry.value === "number" ? entry.value : 0; if (minutes === 0) return null; + const isNap = stage === NAP_KEY; + const label = isNap + ? napCount > 1 + ? t("insights.sleep.stages.napCount", { + count: String(napCount), + }) + : stageLabels[NAP_KEY] + : (stageLabels[stage] ?? stage); const pct = totalNight > 0 ? Math.round((minutes / totalNight) * 100) @@ -318,10 +371,14 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { className="inline-block h-2 w-2 rounded-sm" style={{ background: STAGE_COLORS[stage] }} /> - {stageLabels[stage] ?? stage} + {label} - {formatDurationMinutes(minutes, t)} · {pct}% + {/* The nap is not part of the night, so it + gets no share-of-night percentage. */} + {isNap + ? formatDurationMinutes(minutes, t) + : `${formatDurationMinutes(minutes, t)} · ${pct}%`} ); @@ -351,6 +408,17 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { ))} + {hasNap && ( + + + + )} diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index a1c41d7b4..3d5997189 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -1117,6 +1117,46 @@ export function pickMainNightAndNaps(sessions: readonly SleepSession[]): { return { main, naps }; } +/** One wake day resolved into its main night and that day's naps. */ +export interface SleepDaySplit { + /** Wake-day key (YYYY-MM-DD) in the user's timezone. */ + night: string; + /** The overnight block — the session with the most asleep minutes. */ + main: SleepSession; + /** Every other scorable session on the same wake day, sorted by start. */ + naps: SleepSession[]; +} + +/** + * Run the NAP convention across a whole span of sessions: group them by wake + * day and apply `pickMainNightAndNaps` to each day. + * + * This exists so every surface that needs "which session was the night?" over + * more than one day asks the SAME question the single-day surfaces ask, rather + * than growing its own idea of a night. There is exactly one nap classifier in + * this codebase and it is `pickMainNightAndNaps`; this is its multi-day form. + * + * Days whose sessions carry no asleep minutes are dropped (no main night to + * report). The result is sorted ascending by wake day. + */ +export function separateNightsAndNaps( + sessions: readonly SleepSession[], +): SleepDaySplit[] { + const byDay = new Map(); + for (const session of sessions) { + const list = byDay.get(session.night); + if (list) list.push(session); + else byDay.set(session.night, [session]); + } + const days: SleepDaySplit[] = []; + for (const [night, daySessions] of byDay) { + const { main, naps } = pickMainNightAndNaps(daySessions); + if (!main) continue; + days.push({ night, main, naps }); + } + return days.sort((a, b) => a.night.localeCompare(b.night)); +} + export interface SleepNightSummary { /** * A `DataSummary` whose every value is a per-night TIME-ASLEEP total diff --git a/src/lib/analytics/sleep-stage-composition.ts b/src/lib/analytics/sleep-stage-composition.ts new file mode 100644 index 000000000..bf9489172 --- /dev/null +++ b/src/lib/analytics/sleep-stage-composition.ts @@ -0,0 +1,103 @@ +/** + * Stage composition for the insights sleep chart — per-night stage minutes + * over a trailing window, with the day's naps kept OUT of the night. + * + * A nap ends on the same wake day as that morning's overnight block, so the + * per-wake-day totals that back the headline number fold the two together. + * That is right for "how much did I sleep on this day" and wrong for "what was + * my night made of": an 80-minute afternoon nap lands as CORE on top of the + * night and the column reads longer and shallower than the night actually was. + * + * So the composition is built from SESSIONS, split by the one nap classifier + * this codebase has (`pickMainNightAndNaps`, via `separateNightsAndNaps`). The + * night's stages are the main session's stages. The naps ride along as their + * own minutes on the same day, never inside the stage buckets. + * + * Pure — the caller does the bounded DB read. + */ +import { + reconstructSleepSessions, + separateNightsAndNaps, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; + +export interface SleepStageCompositionNight { + /** Wake-day key (YYYY-MM-DD) in the user's timezone. */ + dayKey: string; + /** + * Per-stage minutes of the MAIN night only. Keys mirror the Prisma + * `SleepStage` enum; `IN_BED` is the session envelope, carried as it was + * before so the chart's own stage order decides what it stacks. + */ + stages: Record; + /** + * Total time asleep across this day's naps, in minutes. Absent when the day + * has no nap — there is no zero to render and nothing to caption. + */ + napMinutes?: number; + /** How many naps that is. Absent alongside `napMinutes`. */ + napCount?: number; +} + +export interface SleepStageComposition { + windowDays: number; + /** Nights carrying at least one stage entry inside the window. */ + nights: number; + /** Sum of every value in `stages` — nights only, naps excluded. */ + totalMinutes: number; + /** Window totals per stage, naps excluded. */ + stages: Record; + /** One entry per night with stage data, sorted ascending by day. */ + perNight: SleepStageCompositionNight[]; +} + +/** + * Build the trailing-window stage composition from raw per-stage rows. + * + * Returns null when no session in the window carries stage data — the + * analytics consumer renders the plain duration totals without a stage card + * in that case. + */ +export function buildSleepStageComposition( + rows: SleepStageRow[], + tz: string, + priorityJson: unknown, + windowDays: number, +): SleepStageComposition | null { + const sessions = reconstructSleepSessions(rows, tz, priorityJson); + const days = separateNightsAndNaps(sessions); + if (days.length === 0) return null; + + const stages: Record = {}; + let totalMinutes = 0; + const perNight: SleepStageCompositionNight[] = []; + + for (const { night, main, naps } of days) { + const nightStages: Record = {}; + for (const [stage, mins] of Object.entries(main.stages)) { + if (mins == null) continue; + nightStages[stage] = mins; + stages[stage] = (stages[stage] ?? 0) + mins; + totalMinutes += mins; + } + if (Object.keys(nightStages).length === 0) continue; + // The nap total is the very `asleepMinutes` `/api/sleep/night` publishes + // for the same session, so the two surfaces report one number, not two. + const napMinutes = naps.reduce((sum, nap) => sum + nap.asleepMinutes, 0); + perNight.push({ + dayKey: night, + stages: nightStages, + ...(napMinutes > 0 ? { napMinutes, napCount: naps.length } : {}), + }); + } + + if (perNight.length === 0) return null; + + return { + windowDays, + nights: perNight.length, + totalMinutes, + stages, + perNight, + }; +} From a99e98332c7979e3a7a64aa0b8f85c357cfaf0e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 08:20:05 +0200 Subject: [PATCH 2/2] 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. --- .../sleep-stage-stacked-bar.test.tsx | 61 +++++ .../insights/sleep-stage-stacked-bar.tsx | 70 ++++-- .../__tests__/sleep-stage-composition.test.ts | 217 ++++++++++++++++++ 3 files changed, 324 insertions(+), 24 deletions(-) create mode 100644 src/lib/analytics/__tests__/sleep-stage-composition.test.ts 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 b8e48baf1..afe9fd8a1 100644 --- a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx +++ b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx @@ -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("", () => { }); }); }); + +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, + ); + }); +}); diff --git a/src/components/insights/sleep-stage-stacked-bar.tsx b/src/components/insights/sleep-stage-stacked-bar.tsx index 615c5d8c9..fc2569782 100644 --- a/src/components/insights/sleep-stage-stacked-bar.tsx +++ b/src/components/insights/sleep-stage-stacked-bar.tsx @@ -159,6 +159,48 @@ function formatDayTick( }); } +/** One Recharts row: the day's label plus a numeric key per drawn band. */ +export type CompositionRow = Record; + +/** + * 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 = { - 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 = diff --git a/src/lib/analytics/__tests__/sleep-stage-composition.test.ts b/src/lib/analytics/__tests__/sleep-stage-composition.test.ts new file mode 100644 index 000000000..bb6db6fb2 --- /dev/null +++ b/src/lib/analytics/__tests__/sleep-stage-composition.test.ts @@ -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(); + 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(); + 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; 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); + }); +});