fix(sleep): dedup the bare ASLEEP aggregate across the stage breakdown and hypnogram

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 19:39:37 +02:00
1 parent c0065437a7
commit 766b395900
7 files changed
+343 -156

No files matched your search

+4 -1
View File
@@ -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,
}),
);
+8 -2
View File
@@ -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<string, unknown> = {};
@@ -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;
+10 -4
View File
@@ -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" },
});
}
+133 -104
View File
@@ -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 (
<Card data-slot="sleep-hypnogram">
@@ -161,7 +181,7 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
</div>
</CardHeader>
<CardContent className="space-y-4">
{!hasTrack ? (
{!hasTrack && !hasBreakdown ? (
<p
className="text-muted-foreground py-8 text-center text-xs"
data-slot="sleep-hypnogram-empty"
@@ -170,96 +190,105 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
</p>
) : (
<>
<div role="img" aria-label={ariaLabel}>
<ResponsiveContainer width="100%" height={200}>
<LineChart
data={data}
margin={{ top: 8, right: 16, left: 8, bottom: 8 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.3}
/>
<XAxis
type="number"
dataKey="t"
domain={domain}
scale="time"
stroke="var(--muted-foreground)"
fontSize={10}
tickFormatter={(v: number) => timeFmt.format(new Date(v))}
/>
<YAxis
type="number"
dataKey="lane"
domain={[-0.5, LANE_ORDER.length - 0.5]}
ticks={LANE_ORDER.map(
(_, i) => 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] ?? "";
}}
/>
<Line
type="stepAfter"
dataKey="lane"
stroke="var(--dracula-cyan, #8be9fd)"
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div
className="space-y-1.5"
data-slot="sleep-hypnogram-breakdown"
>
{breakdown.map(({ stage, minutes, pct }) => (
<div
key={stage}
className="flex items-center justify-between gap-3 text-xs"
>
<span className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="inline-block h-2 w-2 rounded-sm"
style={{ background: STAGE_COLORS[stage] }}
{hasTrack && (
<div role="img" aria-label={ariaLabel}>
<ResponsiveContainer width="100%" height={200}>
<ComposedChart
data={[]}
margin={{ top: 8, right: 16, left: 8, bottom: 8 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.3}
/>
{stageLabels[stage] ?? stage}
<XAxis
type="number"
dataKey="t"
domain={domain}
scale="time"
stroke="var(--muted-foreground)"
fontSize={10}
tickFormatter={(v: number) => timeFmt.format(new Date(v))}
/>
<YAxis
type="number"
dataKey="lane"
domain={[-0.5, LANE_ORDER.length - 0.5]}
ticks={LANE_ORDER.map((_, i) => 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] ?? "";
}}
/>
{spans.map((span, i) => (
<ReferenceArea
key={`${span.stage}-${i}`}
x1={span.x1}
x2={span.x2}
y1={span.lane - 0.4}
y2={span.lane + 0.4}
fill={span.fill}
fillOpacity={0.9}
stroke="none"
ifOverflow="extendDomain"
/>
))}
</ComposedChart>
</ResponsiveContainer>
</div>
)}
{hasBreakdown && (
<>
<div
className="space-y-1.5"
data-slot="sleep-hypnogram-breakdown"
>
{breakdown.map(({ stage, minutes, pct }) => (
<div
key={stage}
className="flex items-center justify-between gap-3 text-xs"
>
<span className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="inline-block h-2 w-2 rounded-sm"
style={{ background: STAGE_COLORS[stage] }}
/>
{stageLabels[stage] ?? stage}
</span>
<span className="text-muted-foreground tabular-nums">
{formatMinutes(minutes, locale)} · {pct}%
</span>
</div>
))}
</div>
<div className="border-border flex flex-wrap items-center gap-x-4 gap-y-1 border-t pt-2 text-xs">
<span className="font-medium">
{t("insights.sleep.hypnogram.asleepTotal", {
value: formatMinutes(session.asleepMinutes, locale),
})}
</span>
<span className="text-muted-foreground tabular-nums">
{formatMinutes(minutes, locale)} · {pct}%
{session.inBedMinutes != null && (
<span className="text-muted-foreground">
{t("insights.sleep.hypnogram.inBedTotal", {
value: formatMinutes(session.inBedMinutes, locale),
})}
</span>
)}
<span className="text-muted-foreground">
{t("insights.sleep.hypnogram.awakenings", {
count: String(session.awakenings),
})}
</span>
</div>
))}
</div>
<div className="border-border flex flex-wrap items-center gap-x-4 gap-y-1 border-t pt-2 text-xs">
<span className="font-medium">
{t("insights.sleep.hypnogram.asleepTotal", {
value: formatMinutes(session.asleepMinutes, locale),
})}
</span>
{session.inBedMinutes != null && (
<span className="text-muted-foreground">
{t("insights.sleep.hypnogram.inBedTotal", {
value: formatMinutes(session.inBedMinutes, locale),
})}
</span>
)}
<span className="text-muted-foreground">
{t("insights.sleep.hypnogram.awakenings", {
count: String(session.awakenings),
})}
</span>
</div>
</>
)}
</>
)}
</CardContent>
@@ -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.
+96 -43
View File
@@ -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<SleepStage> = new Set<SleepStage>([
"REM",
@@ -104,6 +109,35 @@ const GRANULAR_ASLEEP_STAGES: ReadonlySet<SleepStage> = new Set<SleepStage>([
"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<string, SleepStageRow[]>();
// 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<string, SleepStageRow[][]>();
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<Record<SleepStage, number>> = {};
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());
+16 -2
View File
@@ -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<string, typeof sessions>();
for (const s of sessions) {
const list = byDay.get(s.night) ?? [];