diff --git a/messages/de.json b/messages/de.json index b4a3ea36c..62e7dfdaa 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 e987f7186..9c9b3db53 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 39e92050f..b9efc2096 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 574d5a491..69258a2dd 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 57dbf0109..b14a7c282 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 4049262b1..51f7d586f 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/__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 0203dc2d8..fc2569782 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 }; /** @@ -139,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(); @@ -155,6 +217,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 @@ -176,19 +239,14 @@ 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; - } - 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(() => windowHasNap(data), [data]); + // Empty-state guard — no perNight rows AND no aggregate. const hasData = data.length > 0 && @@ -287,10 +345,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 +370,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 +393,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 +430,17 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { ))} + {hasNap && ( + + + + )} 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); + }); +}); 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, + }; +}