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, + }; +}