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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-31 08:13:58 +02:00
1 parent 340d1db6d9
commit 1f8c31d29e
10 files changed
+244 -44

No files matched your search

+2
View File
@@ -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": {
+2
View File
@@ -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": {
+2
View File
@@ -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": {
+2
View File
@@ -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": {
+2
View File
@@ -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": {
+2
View File
@@ -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": {
+17 -40
View File
@@ -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
@@ -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
};
/**
@@ -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<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 +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}
</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 +408,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>
+40
View File
@@ -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,
};
}