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

+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>