mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge work/nap-separation
This commit is contained in:
commit
1c5191d1e3
12 files changed
+555
-55
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<string, number>;
|
||||
perNight: Array<{ dayKey: string; stages: Record<string, number> }>;
|
||||
} | null> {
|
||||
): Promise<SleepStageComposition | null> {
|
||||
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<string, number> = {};
|
||||
let totalMinutes = 0;
|
||||
const perNight = nights
|
||||
.map((n) => {
|
||||
const nightStages: Record<string, number> = {};
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, number>;
|
||||
/**
|
||||
* 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<string, string> = {
|
||||
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<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();
|
||||
|
||||
@@ -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<string, number | string> = {
|
||||
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<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) =>
|
||||
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}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{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}%`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -351,6 +430,17 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
|
||||
<Cell fill={STAGE_COLORS[stage]} />
|
||||
</Bar>
|
||||
))}
|
||||
{hasNap && (
|
||||
<Bar
|
||||
key={NAP_KEY}
|
||||
dataKey={NAP_KEY}
|
||||
stackId="stages"
|
||||
fill={STAGE_COLORS[NAP_KEY]}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
<Cell fill={STAGE_COLORS[NAP_KEY]} />
|
||||
</Bar>
|
||||
)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, SleepSession[]>();
|
||||
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
|
||||
|
||||
@@ -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<string, number>;
|
||||
/**
|
||||
* 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<string, number>;
|
||||
/** 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<string, number> = {};
|
||||
let totalMinutes = 0;
|
||||
const perNight: SleepStageCompositionNight[] = [];
|
||||
|
||||
for (const { night, main, naps } of days) {
|
||||
const nightStages: Record<string, number> = {};
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user