mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
c0065437a7
commit
766b395900
7 files changed
+343
-156
No files matched your search
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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) ?? [];
|
||||
|
||||
Reference in new issue
Block a user