From 766b3959005c878f08de66215937b93fcf943eef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 19:39:05 +0200 Subject: [PATCH] fix(sleep): dedup the bare ASLEEP aggregate across the stage breakdown and hypnogram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple Health writes an unspecified ASLEEP aggregate alongside the granular CORE/DEEP/REM partition for the same period. The asleep total already preferred the granular partition, but the per-night/per-session `stages` map and the hypnogram `segments` list recorded every stage row unconditionally, so a night ended up carrying the granular set PLUS its ~equal bare twin — a double-height green ASLEEP block on the stacked bar and the hypnogram, and a doubled total in the analytics stage breakdown. Route the `stages` map and the `segments` list through the same granular-over-bare gate as the asleep total: when a session carries a granular stage, drop the redundant bare ASLEEP (and stage-less) rows everywhere. WHOOP never emits a bare ASLEEP, so WHOOP nights are unaffected. Apply the gate per session, not over the merged night pool, so a granular overnight no longer strips a same-day bare-only nap's minutes from the night total. Render the hypnogram per-segment in the stage palette (one ReferenceArea per segment) instead of a single flat cyan line, and show the breakdown plus totals footer whenever any per-stage minutes exist — reserving the unavailable state for a truly empty session — so a legacy stage-summary night still surfaces its breakdown. Thread the user's source priority into the CSV export's sleep dedup, and honour offset with a true pre-slice night count on the sleep list. --- src/app/api/export/measurements/route.ts | 5 +- src/app/api/export/route.ts | 10 +- src/app/api/measurements/route.ts | 14 +- src/components/insights/sleep-hypnogram.tsx | 237 ++++++++++-------- .../analytics/__tests__/sleep-night.test.ts | 76 ++++++ src/lib/analytics/sleep-night.ts | 139 ++++++---- src/lib/export.ts | 18 +- 7 files changed, 343 insertions(+), 156 deletions(-) diff --git a/src/app/api/export/measurements/route.ts b/src/app/api/export/measurements/route.ts index acf4e1dc2..e91e16312 100644 --- a/src/app/api/export/measurements/route.ts +++ b/src/app/api/export/measurements/route.ts @@ -24,6 +24,7 @@ import { apiError, getClientIp } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { toCSV, formatMeasurementsForExport } from "@/lib/export"; import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { NextRequest, NextResponse } from "next/server"; export const GET = apiHandler(async (request: NextRequest) => { @@ -44,18 +45,20 @@ export const GET = apiHandler(async (request: NextRequest) => { ? "raw" : "night"; - const [measurements, userTz] = await Promise.all([ + const [measurements, userTz, sourcePriorityJson] = await Promise.all([ prisma.measurement.findMany({ where, orderBy: { measuredAt: "desc" }, }), resolveUserTimezone(user.id), + loadUserSourcePriority(user.id), ]); const csv = toCSV( formatMeasurementsForExport(measurements, userTz, { granularity, sleepTz: userTz, + sourcePriorityJson, }), ); diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index 87b46ca82..c53865254 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -12,6 +12,7 @@ import { formatMoodEntriesForExport, } from "@/lib/export"; import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { NextRequest, NextResponse } from "next/server"; /** @@ -49,7 +50,10 @@ export const GET = apiHandler(async (request: NextRequest) => { } const userId = user.id; - const userTz = await resolveUserTimezone(userId); + const [userTz, sourcePriorityJson] = await Promise.all([ + resolveUserTimezone(userId), + loadUserSourcePriority(userId), + ]); const data: Record = {}; @@ -63,10 +67,12 @@ export const GET = apiHandler(async (request: NextRequest) => { }); // v1.11.5 — sleep collapses to one row per night by default (the // formatter's `night` granularity), matching the per-type CSV route. + // Thread the user's source-priority so a multi-source night dedups to + // the same canonical source the UI shows. const measurementRecords = formatMeasurementsForExport( measurements, userTz, - { sleepTz: userTz }, + { sleepTz: userTz, sourcePriorityJson }, ); data.measurements = format === "csv" ? toCSV(measurementRecords) : measurementRecords; diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 525ee52e2..91d88109d 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -135,6 +135,7 @@ export const GET = apiHandler(async (request: NextRequest) => { from, to, limit, + offset, sortDir, dayKey, }); @@ -677,11 +678,12 @@ async function sleepListResponse( from?: Date; to?: Date; limit: number; + offset: number; sortDir: "asc" | "desc"; dayKey?: string; }, ) { - const { from, to, limit, sortDir, dayKey } = opts; + const { from, to, limit, offset, sortDir, dayKey } = opts; const [tz, priorityJson] = await Promise.all([ resolveUserTimezone(user.id), loadUserSourcePriority(user.id), @@ -809,17 +811,21 @@ async function sleepListResponse( }); } + // Honour offset + limit against the full night set, and report the true + // pre-slice night count as `meta.total` — matching the non-sleep list + // branch's pagination contract so a client can page through nights. + const totalNights = nightRows.length; const measurements = nightRows .sort((a, b) => { const cmp = a.dayKey < b.dayKey ? -1 : a.dayKey > b.dayKey ? 1 : 0; return sortDir === "asc" ? cmp : -cmp; }) - .slice(0, limit); + .slice(offset, offset + limit); annotate({ action: { name: "measurement.list" }, meta: { - total: measurements.length, + total: totalNights, type: "SLEEP_DURATION", mode: "sleep-per-night", rowsScanned: rows.length, @@ -827,7 +833,7 @@ async function sleepListResponse( }); return apiSuccess({ measurements, - meta: { total: nightRows.length, limit, offset: 0, groupBy: "night" }, + meta: { total: totalNights, limit, offset, groupBy: "night" }, }); } diff --git a/src/components/insights/sleep-hypnogram.tsx b/src/components/insights/sleep-hypnogram.tsx index dd40dfe1a..f18bea157 100644 --- a/src/components/insights/sleep-hypnogram.tsx +++ b/src/components/insights/sleep-hypnogram.tsx @@ -3,8 +3,8 @@ import { useMemo } from "react"; import { CartesianGrid, - Line, - LineChart, + ComposedChart, + ReferenceArea, ResponsiveContainer, XAxis, YAxis, @@ -26,8 +26,18 @@ import { STAGE_COLORS } from "./sleep-stage-stacked-bar"; * One canonical source per night (the endpoint already collapsed it), so * exactly one timeline renders — Apple ↔ WHOOP are never overlaid. * - * Recharts stays (project rule). The stepped track is a `stepAfter` line - * over a {t, lane} series built from each segment's [start, end] span. + * Recharts stays (project rule). Each stage segment is drawn as its own + * `ReferenceArea` rectangle spanning the segment's [start, end] clock range + * at the stage's depth lane, filled with the segment's `STAGE_COLORS` token + * (the same palette the breakdown legend below uses) — so the chart reads as + * a coloured "cityscape" rather than one flat single-colour line. Depth still + * increases downward (Awake top, Deep base). + * + * v1.11.5 — the chart renders only when per-segment `segments` exist + * (`hasTrack`); the per-stage breakdown + totals footer render whenever + * `session.stages` carries any positive value, so a legacy stage-summary + * night (no segment spans) still shows its breakdown instead of collapsing + * the whole card to "unavailable". */ export interface SleepHypnogramSegment { @@ -95,19 +105,24 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) { IN_BED: t("insights.sleep.stages.inBed"), }; - // Build the stepped {t, lane} series: a point at each segment START and - // END so a `stepAfter` line holds each stage's depth across its span. - const data = useMemo(() => { - const points: Array<{ t: number; lane: number }> = []; - const sorted = [...session.segments] + // Build one coloured span per stage segment: its [start, end] clock range + // at the stage's depth lane, in the stage's own palette token. Each span + // renders as a `ReferenceArea` so the chart is multi-coloured (a single + // Recharts line can only carry one stroke). + const spans = useMemo(() => { + return [...session.segments] .filter((seg) => seg.stage != null && LANE_OF[seg.stage] !== undefined) - .sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()); - for (const seg of sorted) { - const lane = LANE_OF[seg.stage as string]; - points.push({ t: new Date(seg.start).getTime(), lane }); - points.push({ t: new Date(seg.end).getTime(), lane }); - } - return points; + .sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()) + .map((seg) => { + const stage = seg.stage as string; + return { + stage, + x1: new Date(seg.start).getTime(), + x2: new Date(seg.end).getTime(), + lane: LANE_OF[stage], + fill: STAGE_COLORS[stage], + }; + }); }, [session.segments]); const domain: [number, number] = [ @@ -144,7 +159,12 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) { awakenings: String(session.awakenings), }); - const hasTrack = data.length > 0; + // The CHART needs per-segment spans; the BREAKDOWN + totals footer only + // need any positive per-stage minutes. A legacy stage-summary night (no + // segment spans) still shows its breakdown rather than collapsing the + // whole card. "Unavailable" is reserved for a truly empty session. + const hasTrack = spans.length > 0; + const hasBreakdown = breakdown.length > 0; return ( @@ -161,7 +181,7 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) { - {!hasTrack ? ( + {!hasTrack && !hasBreakdown ? (

) : ( <> -

- - - - timeFmt.format(new Date(v))} - /> - LANE_ORDER.length - 1 - i, - )} - stroke="var(--muted-foreground)" - fontSize={10} - width={56} - tickFormatter={(v: number) => { - const stage = LANE_ORDER[LANE_ORDER.length - 1 - v]; - return stageLabels[stage] ?? ""; - }} - /> - - - -
- -
- {breakdown.map(({ stage, minutes, pct }) => ( -
- -
+ )} + + {hasBreakdown && ( + <> +
+ {breakdown.map(({ stage, minutes, pct }) => ( +
+ + + + {formatMinutes(minutes, locale)} · {pct}% + +
+ ))} +
+ +
+ + {t("insights.sleep.hypnogram.asleepTotal", { + value: formatMinutes(session.asleepMinutes, locale), + })} - - {formatMinutes(minutes, locale)} · {pct}% + {session.inBedMinutes != null && ( + + {t("insights.sleep.hypnogram.inBedTotal", { + value: formatMinutes(session.inBedMinutes, locale), + })} + + )} + + {t("insights.sleep.hypnogram.awakenings", { + count: String(session.awakenings), + })}
- ))} -
- -
- - {t("insights.sleep.hypnogram.asleepTotal", { - value: formatMinutes(session.asleepMinutes, locale), - })} - - {session.inBedMinutes != null && ( - - {t("insights.sleep.hypnogram.inBedTotal", { - value: formatMinutes(session.inBedMinutes, locale), - })} - - )} - - {t("insights.sleep.hypnogram.awakenings", { - count: String(session.awakenings), - })} - -
+ + )} )}
diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts index f345c88f3..3d6f7ee4b 100644 --- a/src/lib/analytics/__tests__/sleep-night.test.ts +++ b/src/lib/analytics/__tests__/sleep-night.test.ts @@ -193,6 +193,43 @@ describe("reconstructSleepNights", () => { expect(nights[0].asleepMinutes).toBe(480); expect(nights[0].inBedMinutes).toBe(510); expect(nights[0].awakeMinutes).toBe(30); + // HIGH (H1): the per-stage `stages` map must NOT carry the bare ASLEEP + // aggregate alongside the granular partition — it would render a + // double-height green ASLEEP segment on top of CORE+DEEP+REM. Only the + // granular stages + IN_BED + AWAKE survive. + expect(nights[0].stages.ASLEEP).toBeUndefined(); + expect(nights[0].stages.CORE).toBe(240); + expect(nights[0].stages.DEEP).toBe(120); + expect(nights[0].stages.REM).toBe(120); + expect(nights[0].stages.IN_BED).toBe(510); + expect(nights[0].stages.AWAKE).toBe(30); + }); + + it("includes a bare-only nap in the night total when the overnight is granular (MEDIUM-1, v1.11.5)", () => { + // Same wake day: a granular WHOOP overnight (8 h) plus a bare-ASLEEP-only + // Apple nap (45 min) more than 3 h later. Reconstructing over the MERGED + // pool would set sawGranular from the overnight and wrongly drop the + // nap's bare ASLEEP. The per-session asleep total must keep the nap. + const rows: SleepStageRow[] = [ + // Overnight (WHOOP, granular): 23:00 → 07:00 = 8 h asleep. + srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WHOOP"), + srcRow("2026-06-04T03:00:00.000Z", "DEEP", 120, "WHOOP"), + srcRow("2026-06-04T07:00:00.000Z", "REM", 120, "WHOOP"), + // Afternoon nap (Apple, bare ASLEEP only): 14:00 → 14:45 = 45 min, + // well beyond the 3 h session gap from the overnight. + srcRow("2026-06-04T14:45:00.000Z", "ASLEEP", 45, "APPLE_HEALTH"), + ]; + const nights = reconstructSleepNights(rows, "UTC"); + expect(nights).toHaveLength(1); + // 480 (granular overnight) + 45 (bare nap) = 525, NOT 480 (nap dropped). + expect(nights[0].asleepMinutes).toBe(525); + // The night's stages carry the granular overnight AND the nap's bare + // ASLEEP — the nap was a separate session with no granular partition, so + // its bare aggregate is its only asleep signal and survives. + expect(nights[0].stages.CORE).toBe(240); + expect(nights[0].stages.DEEP).toBe(120); + expect(nights[0].stages.REM).toBe(120); + expect(nights[0].stages.ASLEEP).toBe(45); }); it("falls back to the bare ASLEEP row when no granular stage exists", () => { @@ -296,6 +333,45 @@ describe("reconstructSleepSessions", () => { expect(sessions[0].asleepMinutes).toBe(420); }); + it("drops the bare ASLEEP aggregate from segments + stages when granular exists (HIGH H1, v1.11.5)", () => { + // Apple-Health shape: granular CORE/DEEP/REM PLUS the unspecified ASLEEP + // aggregate covering the same period, plus IN_BED. The hypnogram must not + // draw a double-height ASLEEP lane on top of the granular stages, so the + // bare ASLEEP segment is dropped and `stages.ASLEEP` is absent. + const rows: SleepStageRow[] = [ + row("2026-06-04T01:00:00.000Z", "CORE", 240), + row("2026-06-04T03:00:00.000Z", "DEEP", 120), + row("2026-06-04T05:00:00.000Z", "REM", 120), + row("2026-06-04T05:00:00.000Z", "ASLEEP", 480), // redundant aggregate + row("2026-06-04T05:00:00.000Z", "IN_BED", 510), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions).toHaveLength(1); + const s = sessions[0]; + // No ASLEEP segment survives; CORE + DEEP + REM + IN_BED do (4 segments). + expect(s.segments.some((seg) => seg.stage === "ASLEEP")).toBe(false); + expect(s.segments).toHaveLength(4); + expect(s.stages.ASLEEP).toBeUndefined(); + expect(s.asleepMinutes).toBe(480); // granular sum, not 960 + expect(s.inBedMinutes).toBe(510); + }); + + it("keeps the bare ASLEEP segment when NO granular stage exists", () => { + // Legacy ASLEEP-only night — the bare aggregate IS the timeline, so it + // must survive as a segment (otherwise the hypnogram would have nothing). + const rows: SleepStageRow[] = [ + row("2026-06-04T06:00:00.000Z", "ASLEEP", 450), + row("2026-06-04T06:00:00.000Z", "IN_BED", 480), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions).toHaveLength(1); + expect(sessions[0].segments.some((seg) => seg.stage === "ASLEEP")).toBe( + true, + ); + expect(sessions[0].stages.ASLEEP).toBe(450); + expect(sessions[0].asleepMinutes).toBe(450); + }); + it("counts only MID-sleep AWAKE bouts as awakenings", () => { const rows: SleepStageRow[] = [ // Leading AWAKE before sleep onset — NOT an awakening. diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index 90810a61b..0b72448f9 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -15,14 +15,19 @@ * * Granular-over-bare (v1.11.5) * ---------------------------- - * Apple Health (and some WHOOP shapes) writes BOTH an unspecified `ASLEEP` - * AGGREGATE row AND the granular `CORE` / `DEEP` / `REM` breakdown for the - * SAME sleep period. The granular rows partition the same time the aggregate - * covers, so summing bare-ASLEEP together with the granular rows ~doubles the - * night. We therefore prefer the granular partition: when ANY granular stage - * (CORE / DEEP / REM) is present for a night/source we count ONLY the granular - * stages; the bare `ASLEEP` aggregate (or a stage-less legacy / manual row) is - * the night's asleep total only when no granular stage exists. + * Apple Health writes BOTH an unspecified `ASLEEP` AGGREGATE row AND the + * granular `CORE` / `DEEP` / `REM` breakdown for the SAME sleep period. The + * granular rows partition the same time the aggregate covers, so counting + * bare-ASLEEP together with the granular rows ~doubles the night. We therefore + * prefer the granular partition: when ANY granular stage (CORE / DEEP / REM) + * is present for a session we drop the redundant bare `ASLEEP` aggregate (and + * any stage-less row) EVERYWHERE — the asleep total, the per-stage `stages` + * breakdown, and the hypnogram `segments` list — so none of those views holds + * the granular set plus its ~equal bare twin. The bare `ASLEEP` aggregate (or + * a stage-less legacy / manual row) is the session's only asleep signal when + * no granular stage exists. WHOOP never emits a bare `ASLEEP` aggregate (its + * stage map writes CORE / DEEP / REM / AWAKE / IN_BED only — see + * `whoop/client.ts`), so this gate is Apple-Health-only in practice. * * Night grouping * -------------- @@ -89,14 +94,14 @@ import { /** * The GRANULAR asleep stages (iOS 16+ `asleepCore` / `asleepDeep` / - * `asleepREM`). A bare `ASLEEP` row is the legacy iOS 15- `asleepUnspecified` - * AGGREGATE — many sources (Apple Health, some WHOOP shapes) write BOTH the - * unspecified aggregate AND the granular breakdown for the SAME period, so - * summing bare-ASLEEP together with the granular rows ~doubles the night. The - * granular rows partition the same time the aggregate covers, so when any - * granular stage is present for a night/source we count ONLY the granular - * stages and drop the bare aggregate; the bare row is the asleep total only - * when no granular stage exists. + * `asleepREM`). A bare `ASLEEP` row is the iOS 15- `asleepUnspecified` + * AGGREGATE — Apple Health writes BOTH the unspecified aggregate AND the + * granular breakdown for the SAME period, so keeping bare-ASLEEP alongside the + * granular rows ~doubles the night. The granular rows partition the same time + * the aggregate covers, so when any granular stage is present for a session we + * keep ONLY the granular stages and drop the bare aggregate; the bare row is + * the asleep signal only when no granular stage exists. WHOOP never writes a + * bare `ASLEEP` aggregate (see `whoop/client.ts` `SLEEP_STAGE_MAP`). */ const GRANULAR_ASLEEP_STAGES: ReadonlySet = new Set([ "REM", @@ -104,6 +109,35 @@ const GRANULAR_ASLEEP_STAGES: ReadonlySet = new Set([ "DEEP", ]); +/** + * True when any row in the set carries a GRANULAR asleep stage + * (CORE / DEEP / REM). When this holds, the bare `ASLEEP` aggregate (and any + * stage-less row) is the redundant unspecified twin of the granular partition + * and must be dropped from EVERY downstream view — the asleep total, the + * per-stage `stages` map, and the hypnogram `segments` list — so none of them + * double-counts the same period. + */ +function sawGranularStage(rows: readonly SleepStageRow[]): boolean { + for (const r of rows) { + const stage = r.sleepStage; + if (stage != null && GRANULAR_ASLEEP_STAGES.has(stage)) return true; + } + return false; +} + +/** + * A stage row is a redundant bare-asleep aggregate (must be dropped) when the + * session/night ALSO carries a granular CORE/DEEP/REM partition. Only the bare + * `ASLEEP` aggregate and stage-less (`null`) rows are redundant — IN_BED / + * AWAKE and the granular stages themselves are always kept. + */ +function isRedundantBareAsleep( + stage: SleepStage | null, + sawGranular: boolean, +): boolean { + return sawGranular && (stage === "ASLEEP" || stage == null); +} + /** * Sum a set of stage rows into time-asleep minutes WITHOUT double-counting a * bare `ASLEEP` aggregate against the granular CORE/DEEP/REM rows it overlaps. @@ -268,10 +302,13 @@ export function reconstructSleepNights( } if (current.length > 0) sessions.push(current); - // Two sessions can land on the same wake day (e.g. a genuine second sleep); - // merge their rows under one key after the source-collapse so the night key - // stays unique. Collapse each session to its canonical source first. - const byNight = new Map(); + // Two sessions can land on the same wake day (e.g. a genuine overnight plus + // a same-day nap); collect each session's canonical-source rows under one + // wake-day key. The rows are grouped PER SESSION (not flattened) so the + // granular-over-bare gate and the asleep total are decided per session — a + // bare-only nap keeps its minutes even when the overnight session is + // granular (the v1.11.5 merged-night nap under-count fix). + const byNight = new Map(); for (const session of sessions) { const canonical = pickSessionSource(session, sleepLadder); const kept = session.filter((r) => (r.source ?? NO_SOURCE) === canonical); @@ -284,40 +321,48 @@ export function reconstructSleepNights( ); const key = userDayKey(wakeInstant, tz); const list = byNight.get(key) ?? []; - list.push(...kept); + list.push(pool); byNight.set(key, list); } const nights: SleepNight[] = []; - for (const [night, nightRows] of byNight) { + for (const [night, nightSessions] of byNight) { let inBed = 0; let awake = 0; let sawInBed = false; let sawAwake = false; - let latest = nightRows[0].measuredAt; + let asleep = 0; + let latest = nightSessions[0][0].measuredAt; const stages: Partial> = {}; - for (const r of nightRows) { - const minutes = Number.isFinite(r.value) ? r.value : 0; - if (r.measuredAt.getTime() > latest.getTime()) latest = r.measuredAt; - const stage = r.sleepStage; - if (stage) { - stages[stage] = (stages[stage] ?? 0) + minutes; + for (const sessionRows of nightSessions) { + // v1.11.5 — decide the granular-over-bare gate PER SESSION. A granular + // overnight does not strip a bare-only nap's minutes, and the per-stage + // `stages` map drops the redundant bare ASLEEP aggregate only for the + // session that also carries the granular partition. + const sawGranular = sawGranularStage(sessionRows); + // Asleep total: sum per session so a granular overnight + a bare-only + // nap both contribute (the merged-night nap under-count fix). + asleep += asleepMinutesOf(sessionRows); + for (const r of sessionRows) { + const minutes = Number.isFinite(r.value) ? r.value : 0; + if (r.measuredAt.getTime() > latest.getTime()) latest = r.measuredAt; + const stage = r.sleepStage; + // Drop the redundant bare ASLEEP aggregate (and stage-less rows) from + // the per-stage breakdown when this session has the granular partition, + // so `stages` is never the granular set PLUS its ~equal bare twin. + if (isRedundantBareAsleep(stage, sawGranular)) continue; + if (stage) { + stages[stage] = (stages[stage] ?? 0) + minutes; + } + if (stage === "IN_BED") { + inBed += minutes; + sawInBed = true; + } else if (stage === "AWAKE") { + awake += minutes; + sawAwake = true; + } } - if (stage === "IN_BED") { - inBed += minutes; - sawInBed = true; - } else if (stage === "AWAKE") { - awake += minutes; - sawAwake = true; - } - // Asleep minutes are summed with the granular-over-bare rule below so a - // bare ASLEEP aggregate is never double-counted against the granular - // CORE/DEEP/REM rows it overlaps. } - // v1.11.5 — single source of truth for the asleep total: prefer the - // granular CORE/DEEP/REM partition; fall back to the bare ASLEEP / - // stage-less row only when no granular stage exists for the night. - const asleep = asleepMinutesOf(nightRows); nights.push({ night, measuredAt: latest, @@ -465,7 +510,15 @@ export function reconstructSleepSessions( const canonical = pickSessionSource(session, sleepLadder); const kept = session.filter((r) => (r.source ?? NO_SOURCE) === canonical); const pool = kept.length > 0 ? kept : session; + // v1.11.5 — drop the redundant bare ASLEEP aggregate (and stage-less + // rows) from the hypnogram segments AND the per-stage breakdown when the + // session also carries the granular CORE/DEEP/REM partition, so the + // hypnogram never draws a double-height ASLEEP lane on top of the + // granular stages and the `stages` map is the granular set OR the bare + // aggregate, never both. + const sawGranular = sawGranularStage(pool); const segments = pool + .filter((r) => !isRedundantBareAsleep(r.sleepStage, sawGranular)) .map(segmentOf) .sort((a, b) => a.start.getTime() - b.start.getTime()); diff --git a/src/lib/export.ts b/src/lib/export.ts index 15ab25432..da69e3fd6 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -116,7 +116,17 @@ interface ExportMeasurement { export function formatMeasurementsForExport( measurements: ExportMeasurement[], userTz?: string, - opts: { granularity?: "night" | "raw"; sleepTz?: string } = {}, + opts: { + granularity?: "night" | "raw"; + sleepTz?: string; + /** + * The user's persisted `sourcePriorityJson` (or null for the defaults). + * Threaded into `reconstructSleepSessions` so the CSV's per-night sleep + * dedup picks the SAME canonical source the UI shows on a multi-source + * night — without it the export silently fell back to the default ladder. + */ + sourcePriorityJson?: unknown; + } = {}, ): ExportableRecord[] { const granularity = opts.granularity ?? "night"; const toRecord = (m: ExportMeasurement): ExportableRecord => ({ @@ -149,7 +159,11 @@ export function formatMeasurementsForExport( sleepStage: m.sleepStage ?? null, source: m.source as SleepStageRow["source"], })); - const sessions = reconstructSleepSessions(stageRows, tz); + const sessions = reconstructSleepSessions( + stageRows, + tz, + opts.sourcePriorityJson ?? null, + ); const byDay = new Map(); for (const s of sessions) { const list = byDay.get(s.night) ?? [];