fix(sleep): stop double-counting bare ASLEEP against granular stages

Apple Health (and some WHOOP shapes) write BOTH an unspecified ASLEEP
aggregate row AND the granular CORE/DEEP/REM breakdown for the same
sleep period. The per-night reconstruction summed both, so a real ~8 h
night surfaced as ~16 h across every sleep surface — the chart showed
~16-18 h points and the target-range 30-day average read ~16.4 h/day.

Prefer the granular partition: when any CORE/DEEP/REM stage is present
for a night/source, count only those; fall back to the bare ASLEEP (or
a stage-less legacy/manual) row only when no granular stage exists.
IN_BED and AWAKE stay excluded; dual-source nights still collapse to one
canonical source via the user's sleep priority ladder. The same rule
applies inside the per-source asleep tiebreak so a source writing both
shapes is not over-weighted.

Align the surfaces that bypassed the shared helper:

- insights/targets: SLEEP_DURATION was averaged as raw per-stage rows
  (minutes) and fed into a target band expressed in hours, producing the
  bogus 16.4 h/day. Reconstruct per-night time-asleep, convert to hours,
  and classify + roll up consistency on the nightly hour totals.
- coach snapshot: route the sleep block through reconstructSleepNights
  so the prompt narrates the same nightly numbers (was summing every
  stage row, including IN_BED + AWAKE).
- sleep card chart: read /api/measurements/series?kind=sleep (night
  reconstruction, hours) instead of the generic measurement
  daily-average path, and label the axis in hours instead of minutes.

Add regression fixtures: a night with both a bare ASLEEP row and the
granular breakdown asserts the granular total (no double-count); a
dual-source night with both shapes per source; and a multi-night 30-day
average that lands at ~8 h, not ~16 h.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 18:22:55 +02:00
1 parent e78e3df1b9
commit 2a7195601b
6 files changed
+358 -76

No files matched your search

+88 -10
View File
@@ -17,6 +17,10 @@ import {
lastNonSkippedTakenAt,
} from "@/lib/analytics/compliance";
import { pairByTimestamp } from "@/lib/analytics/correlations";
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import {
@@ -161,6 +165,9 @@ async function buildTargetsResponse(user: AuthedUser) {
gender: true,
glucoseUnit: true,
thresholdsJson: true,
// v1.11.5 — needed to collapse a dual-source sleep night to one
// canonical source before reconstructing per-night asleep totals.
sourcePriorityJson: true,
},
});
@@ -169,12 +176,15 @@ async function buildTargetsResponse(user: AuthedUser) {
const heightCm = dbUser?.heightCm ?? null;
// Fetch latest measurements for each type
// v1.11.5 — SLEEP_DURATION is handled by a dedicated per-night
// reconstruction read below (the generic per-type average treats each
// stage row as an independent reading and would inflate the night total),
// so it is intentionally excluded from this generic set.
const types: MeasurementType[] = [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"SLEEP_DURATION",
"BODY_FAT",
"ACTIVITY_STEPS",
];
@@ -729,23 +739,91 @@ async function buildTargetsResponse(user: AuthedUser) {
}
// 4. Sleep Duration
//
// v1.11.5 (deferred v1.11.4 QA L3) — SLEEP_DURATION is stored one row per
// STAGE per night in MINUTES. The generic per-type path treated every stage
// row as an independent reading, summing IN_BED + AWAKE + the bare ASLEEP
// aggregate + the granular CORE/DEEP/REM rows over 30 days and dividing by
// the row count — and then fed those MINUTES straight into a target range
// expressed in HOURS. The result was a bogus ~16 h/day average. Reconstruct
// per-night TIME-ASLEEP through the shared helper (which dedups dual-source
// nights, excludes IN_BED/AWAKE, and counts only the granular partition when
// present), convert to HOURS, then classify and roll up consistency on the
// per-night hour totals so this surface matches the dashboard / series /
// overview headline.
const sleepRange = getSleepDurationRange();
let sleepClassification: { category: string; color: string } | null = null;
if (latestByType.SLEEP_DURATION != null) {
const cls = classifySleepDuration(latestByType.SLEEP_DURATION);
sleepClassification = { category: cls.category, color: cls.color };
}
{
const sleepStageRows = await prisma.measurement.findMany({
where: {
userId,
type: "SLEEP_DURATION",
measuredAt: { gte: thirtyDaysAgo },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, sleepStage: true, source: true },
});
const nights = reconstructSleepNights(
sleepStageRows as SleepStageRow[],
userTz,
dbUser?.sourcePriorityJson ?? null,
)
.filter((n) => n.asleepMinutes > 0)
.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime());
const round1 = (n: number): number => Math.round(n * 100) / 100;
// One event per night carrying the night's asleep HOURS — the right unit
// for the 7–9 h target band and for the per-day consistency bucketing.
const sleepEvents = nights.map((n) => ({
measuredAt: n.measuredAt,
value: round1(n.asleepMinutes / 60),
}));
const latestNight = nights.length > 0 ? nights[nights.length - 1] : null;
const currentSleepHours = latestNight
? round1(latestNight.asleepMinutes / 60)
: null;
const avgSleepHours =
sleepEvents.length > 0
? round1(
sleepEvents.reduce((s, e) => s + e.value, 0) / sleepEvents.length,
)
: null;
let sleepClassification: { category: string; color: string } | null = null;
if (currentSleepHours != null) {
const cls = classifySleepDuration(currentSleepHours);
sleepClassification = { category: cls.category, color: cls.color };
}
// Trend over the per-night hour series (first vs second half), mirroring
// `computeTrend` but on reconstructed nights rather than raw stage rows.
let sleepTrend: "up" | "down" | "stable" | null = null;
if (sleepEvents.length >= 4) {
const mid = Math.floor(sleepEvents.length / 2);
const firstHalf = sleepEvents.slice(0, mid);
const secondHalf = sleepEvents.slice(mid);
const avgFirst =
firstHalf.reduce((s, e) => s + e.value, 0) / firstHalf.length;
const avgSecond =
secondHalf.reduce((s, e) => s + e.value, 0) / secondHalf.length;
const diff = avgSecond - avgFirst;
const threshold = avgFirst * 0.02;
if (diff > threshold) sleepTrend = "up";
else if (diff < -threshold) sleepTrend = "down";
else sleepTrend = "stable";
}
const consistency = rollupConsistency(
recentMeasurements.filter((m) => m.type === "SLEEP_DURATION"),
sleepEvents,
makeRangeClassifier(sleepRange),
);
targets.push({
type: "SLEEP_DURATION",
label: "Sleep duration",
current: latestByType.SLEEP_DURATION ?? null,
average30: avg30ByType.SLEEP_DURATION ?? null,
trend: computeTrend("SLEEP_DURATION"),
current: currentSleepHours,
average30: avgSleepHours,
trend: sleepTrend,
unit: "h",
range: sleepRange,
classification: sleepClassification,
+52
View File
@@ -622,7 +622,59 @@ export function HealthChart({
}
>();
// v1.11.5 — SLEEP_DURATION is stored one row per sleep STAGE per night
// (minutes). The generic `/api/measurements` daily-aggregate path
// averages those stage rows, which neither sums a night nor isolates
// the asleep stages — it surfaced a ~2× inflated, mislabeled chart. The
// `/api/measurements/series?kind=sleep` adapter reconstructs ONE point
// per night carrying the night's TIME-ASLEEP in HOURS (CORE + DEEP + REM,
// bare-ASLEEP only when no granular stage exists, IN_BED + AWAKE
// excluded, dual-source nights collapsed to one source). Routing the
// sleep chart through it keeps every sleep surface on the same
// reconstruction. One point per night → `sum/count` yields the night's
// hours unchanged.
async function fetchSleepNights() {
const sleepParams = new URLSearchParams();
sleepParams.set("kind", "sleep");
// The series route caps sleep at 365 days regardless; clamp the
// request so a longer comparison window still resolves.
sleepParams.set(
"days",
String(Math.max(1, Math.min(3650, Math.ceil(fetchWindow.windowDays)))),
);
const res = await fetch(`/api/measurements/series?${sleepParams}`);
if (!res.ok) return;
const json = await res.json();
const points = (json.data?.points ?? []) as Array<{
at: string;
value: number;
}>;
for (const point of points) {
const value = point.value * valueScale;
if (value == null || !Number.isFinite(value)) continue;
const dayKey = toDayKey(point.at, dayKeyFormatter);
const bucket = dailyAggregates.get(dayKey) ?? {
timestamp: dayKeyToTimestamp(dayKey),
values: {},
};
const current = bucket.values["SLEEP_DURATION"] ?? {
sum: 0,
count: 0,
min: null,
max: null,
};
current.sum += value;
current.count += 1;
bucket.values["SLEEP_DURATION"] = current;
dailyAggregates.set(dayKey, bucket);
}
}
async function fetchMeasurementsByType(type: string) {
if (type === "SLEEP_DURATION") {
await fetchSleepNights();
return;
}
const typeParams = new URLSearchParams();
typeParams.set("type", type);
typeParams.set("sortBy", "measuredAt");
@@ -5,20 +5,23 @@ import { useTranslations } from "@/lib/i18n/context";
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
/**
* v1.4.25 W4c — total nightly sleep duration over time.
* v1.4.25 W4c — nightly TIME-ASLEEP over time.
*
* Thin wrapper around `<HealthChartDynamic>` so the sleep sub-page gets
* the same chart-cog parity (`chartKey="sleep"`) as the dashboard chart
* surfaces. SLEEP_DURATION rows from Apple Health are per-stage (one
* row per stage per night) — the analytics API aggregates per Berlin
* day before summarising, and `HealthChart` itself relies on the
* pre-aggregated `/api/measurements/series` payload, so we pass the
* raw type through and let the existing chart wire do the day-rollup.
* surfaces.
*
* Unit semantics: SLEEP_DURATION is canonically stored in MINUTES
* (v1.4.23 schema note). The chart shows the raw minutes value with a
* "min" unit suffix — the parent sub-page renders a separate
* "X h Y min" headline above the chart for the human-friendly read.
* Data path: SLEEP_DURATION rows are stored per-stage (one row per stage
* per night, in MINUTES). `<HealthChart>` special-cases SLEEP_DURATION to
* read `/api/measurements/series?kind=sleep`, which reconstructs ONE point
* per night carrying the night's TIME-ASLEEP in HOURS (CORE + DEEP + REM,
* bare-ASLEEP only when no granular stage exists, IN_BED + AWAKE excluded,
* dual-source nights collapsed to one source). The chart therefore renders
* a nightly trend in hours that matches every other sleep surface.
*
* Unit semantics: the chart plots HOURS — the same unit the series adapter
* returns and the `<SleepOverview>` headline shows — so the axis reads e.g.
* "8 h", not the inflated per-stage minutes the legacy path surfaced.
*
* v1.4.28 R3d (BK-F-M2) — the inline `dynamic()` call site was retired
* in favour of the shared `<HealthChartDynamic>` re-export so the
@@ -40,8 +43,8 @@ export function SleepDurationChart({
types={["SLEEP_DURATION"]}
title={t("charts.sleep")}
colors={["#8be9fd"]}
unit="min"
yAxisUnit="min"
unit="h"
yAxisUnit="h"
compareBaseline={compareBaseline}
userTimezone={userTimezone}
/>
+43 -34
View File
@@ -23,6 +23,10 @@ import {
type CoachDataCluster,
} from "@/lib/validations/coach-prefs";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { compactSections } from "@/lib/ai/prompts/compact-sections";
import { annotate } from "@/lib/logging/context";
import { buildGlp1SnapshotBlock } from "./glp1-snapshot";
@@ -455,7 +459,14 @@ async function buildCoachSnapshotImpl(
// key is absent). So the prefs read must precede `resolveScope`.
const prefsRow = await prisma.user.findUnique({
where: { id: userId },
select: { coachPrefsJson: true, timezone: true, locale: true },
select: {
coachPrefsJson: true,
timezone: true,
locale: true,
// v1.11.5 — needed to collapse a dual-source sleep night to one
// canonical source before reconstructing per-night asleep totals.
sourcePriorityJson: true,
},
});
const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
// Resolve the UI locale for the rolling-profile narrative recall. The
@@ -751,7 +762,7 @@ async function buildCoachSnapshotImpl(
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, sleepStage: true },
select: { value: true, measuredAt: true, sleepStage: true, source: true },
})
: null;
const workoutRowsPromise = sources.has("workouts")
@@ -1082,46 +1093,44 @@ async function buildCoachSnapshotImpl(
meta: { cluster: "sleep", source: "sleep" },
});
} else {
// Per-night total minutes (sum of all stage rows that night) for
// the duration timeline.
const perNight = new Map<
string,
{ date: Date; total: number; stages: Record<string, number> }
>();
for (const r of sleepRows) {
const key = tzDayKey(r.measuredAt, userTz);
const e = perNight.get(key) ?? {
date: r.measuredAt,
total: 0,
stages: {},
};
e.total += r.value;
if (r.sleepStage) {
const stage = String(r.sleepStage).toLowerCase();
e.stages[stage] = (e.stages[stage] ?? 0) + r.value;
}
perNight.set(key, e);
}
// Recent nights: duration + stage breakdown when present.
const recentNights = Array.from(perNight.entries())
.filter(([, info]) => info.date >= recentCutoff)
.map(([date, info]) => {
// v1.11.5 — reconstruct per-night TIME-ASLEEP totals through the shared
// helper so the Coach narrates the same nightly numbers every other
// sleep surface shows: stages clustered into sessions, a dual-source
// night collapsed to one canonical source, and the granular
// CORE/DEEP/REM partition counted WITHOUT double-counting the bare
// ASLEEP aggregate Apple Health writes alongside it. IN_BED + AWAKE are
// excluded from the asleep total.
const nights = reconstructSleepNights(
sleepRows as SleepStageRow[],
userTz,
prefsRow?.sourcePriorityJson ?? null,
).filter((n) => n.asleepMinutes > 0);
// Recent nights: asleep duration + stage breakdown when present.
const recentNights = nights
.filter((n) => n.measuredAt >= recentCutoff)
.map((n) => {
const row: Record<string, unknown> = {
date,
weekday: tzWeekday(info.date, userTz),
minutes: Math.round(info.total),
date: n.measuredAt,
weekday: tzWeekday(n.measuredAt, userTz),
minutes: Math.round(n.asleepMinutes),
};
if (Object.keys(info.stages).length > 0) {
const stageEntries = Object.entries(n.stages).filter(
([stage]) => stage !== "IN_BED" && stage !== "AWAKE",
);
if (stageEntries.length > 0) {
row.stages = Object.fromEntries(
Object.entries(info.stages).map(([k, v]) => [k, Math.round(v)]),
stageEntries.map(([k, v]) => [
k.toLowerCase(),
Math.round(v as number),
]),
);
}
return row;
})
.sort((a, b) => String(a.date).localeCompare(String(b.date)));
const olderNights = Array.from(perNight.values())
.filter((info) => info.date < recentCutoff)
.map((info) => ({ measuredAt: info.date, value: info.total }));
const olderNights = nights
.filter((n) => n.measuredAt < recentCutoff)
.map((n) => ({ measuredAt: n.measuredAt, value: n.asleepMinutes }));
snapshot.sleep = {
timeline: {
recent: recentNights,
@@ -167,6 +167,67 @@ describe("reconstructSleepNights", () => {
expect(nights[0].stages.CORE).toBe(240);
});
it("does NOT double-count a bare ASLEEP aggregate against the granular stages (HIGH, v1.11.5)", () => {
// Apple Health writes BOTH an unspecified `ASLEEP` AGGREGATE row AND the
// granular CORE/DEEP/REM breakdown for the SAME night. The granular rows
// partition the same period the aggregate covers, so summing them would
// ~double the night (here: 480 granular + 480 bare = 960 ≈ 16 h, the
// real-data symptom). The asleep total must be the GRANULAR sum (~8 h),
// never the granular + bare blend.
const rows: SleepStageRow[] = [
// Granular breakdown — the canonical partition (8 h total).
row("2026-06-04T01:00:00.000Z", "CORE", 240), // 23:00 → 03:00
row("2026-06-04T03:00:00.000Z", "DEEP", 120), // 03:00 → 05:00
row("2026-06-04T05:00:00.000Z", "REM", 120), // 05:00 → 07:00
// Unspecified ASLEEP aggregate covering the SAME 8 h — must be dropped.
row("2026-06-04T05:00:00.000Z", "ASLEEP", 480),
// IN_BED + AWAKE never count toward asleep.
row("2026-06-04T05:00:00.000Z", "IN_BED", 510),
row("2026-06-04T04:30:00.000Z", "AWAKE", 30),
];
const nights = reconstructSleepNights(rows, "UTC");
expect(nights).toHaveLength(1);
// Granular only: 240 + 120 + 120 = 480 (8 h), NOT 960 (16 h).
expect(nights[0].asleepMinutes).toBe(480);
expect(nights[0].inBedMinutes).toBe(510);
expect(nights[0].awakeMinutes).toBe(30);
});
it("falls back to the bare ASLEEP row when no granular stage exists", () => {
// Legacy iOS 15- writes only the unspecified aggregate — there is no
// granular partition, so the bare ASLEEP row IS the night's total.
const rows: SleepStageRow[] = [
row("2026-06-04T06:00:00.000Z", "ASLEEP", 465),
row("2026-06-04T06:00:00.000Z", "IN_BED", 500),
];
const nights = reconstructSleepNights(rows, "UTC");
expect(nights).toHaveLength(1);
expect(nights[0].asleepMinutes).toBe(465);
expect(nights[0].inBedMinutes).toBe(500);
});
it("does not double-count bare+granular WITHIN one source on a dual-source night", () => {
// Both WHOOP and Apple Health report the same night, and EACH writes a
// bare ASLEEP aggregate alongside its granular breakdown. The night must
// collapse to ONE source (WHOOP by default ladder) AND count only that
// source's granular stages — no cross-source sum, no bare double-count.
const rows: SleepStageRow[] = [
srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WHOOP"),
srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "WHOOP"),
srcRow("2026-06-04T04:30:00.000Z", "REM", 90, "WHOOP"),
srcRow("2026-06-04T04:30:00.000Z", "ASLEEP", 420, "WHOOP"), // bare WHOOP aggregate
srcRow("2026-06-04T01:05:00.000Z", "CORE", 230, "APPLE_HEALTH"),
srcRow("2026-06-04T03:05:00.000Z", "DEEP", 85, "APPLE_HEALTH"),
srcRow("2026-06-04T04:35:00.000Z", "REM", 80, "APPLE_HEALTH"),
srcRow("2026-06-04T04:35:00.000Z", "ASLEEP", 395, "APPLE_HEALTH"), // bare Apple aggregate
];
const nights = reconstructSleepNights(rows, "UTC");
expect(nights).toHaveLength(1);
// WHOOP granular only: 240 + 90 + 90 = 420, not 840 (bare+granular) and
// not the ~1660 four-way blend of both sources' bare + granular rows.
expect(nights[0].asleepMinutes).toBe(420);
});
it("honours a per-user ladder that prefers Apple Health over WHOOP", () => {
const priorityJson = { sleep: ["APPLE_HEALTH", "WHOOP"] };
const rows: SleepStageRow[] = [
@@ -205,6 +266,34 @@ describe("summarizeSleepNights", () => {
expect(latestNight?.asleepMinutes).toBe(420);
});
it("averages ~8 h, not ~16 h, when every night carries bare+granular rows (HIGH, v1.11.5)", () => {
// Several consecutive nights, each written the real-data way: a bare
// ASLEEP aggregate PLUS the granular CORE/DEEP/REM partition for the same
// period. The 30-day-style average must reflect the granular ~8 h totals,
// not the ~16 h bare+granular blend that produced the live 16.4 h symptom.
const nightsInput: Array<[string, number, number, number]> = [
// [wake-day, core, deep, rem] — each sums to 480 min = 8 h.
["2026-06-01", 240, 120, 120],
["2026-06-02", 250, 110, 120],
["2026-06-03", 230, 130, 120],
["2026-06-04", 240, 120, 120],
];
const rows: SleepStageRow[] = [];
for (const [day, core, deep, rem] of nightsInput) {
// Each night's stages land between 01:00 and 06:00 UTC on the wake day.
rows.push(row(`${day}T02:00:00.000Z`, "CORE", core));
rows.push(row(`${day}T04:00:00.000Z`, "DEEP", deep));
rows.push(row(`${day}T06:00:00.000Z`, "REM", rem));
// The overlapping bare ASLEEP aggregate (= core+deep+rem) — must drop.
rows.push(row(`${day}T06:00:00.000Z`, "ASLEEP", core + deep + rem));
}
const { summary } = summarizeSleepNights(rows, "UTC");
expect(summary.count).toBe(4);
// Mean of 480 / 480 / 480 / 480 = 480 min = 8 h, NOT 960 min = 16 h.
expect(summary.mean).toBe(480);
expect((summary.mean ?? 0) / 60).toBe(8);
});
it("drops nights with zero asleep minutes (IN_BED / AWAKE only)", () => {
const rows: SleepStageRow[] = [
row("2026-06-03T23:00:00.000Z", "IN_BED", 60),
+71 -20
View File
@@ -10,10 +10,19 @@
*
* This helper is the one shared place that turns the raw per-stage rows
* into per-night totals. The headline "time asleep" is light(CORE) +
* deep + REM (+ legacy bare `ASLEEP`); IN_BED and AWAKE are excluded
* from the asleep total, matching the AASM convention already encoded in
* `sleep-score.ts`'s `ASLEEP_STAGES`. Bare rows with no `sleepStage`
* (legacy / manual) count as that night's asleep total.
* deep + REM; IN_BED and AWAKE are excluded from the asleep total,
* matching the AASM convention.
*
* 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.
*
* Night grouping
* --------------
@@ -68,14 +77,49 @@ import {
parseSourcePriority,
} from "@/lib/validations/source-priority";
/** Stages that count toward "time asleep" (excludes IN_BED + AWAKE). */
const ASLEEP_STAGES: ReadonlySet<SleepStage> = new Set<SleepStage>([
"ASLEEP",
/**
* 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.
*/
const GRANULAR_ASLEEP_STAGES: ReadonlySet<SleepStage> = new Set<SleepStage>([
"REM",
"CORE",
"DEEP",
]);
/**
* 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.
* IN_BED + AWAKE are never asleep; a stage-less (`null`) row is the night's
* total only when no granular stage is present.
*/
function asleepMinutesOf(rows: readonly SleepStageRow[]): number {
let granular = 0;
let fallback = 0;
let sawGranular = false;
for (const r of rows) {
const minutes = Number.isFinite(r.value) ? r.value : 0;
const stage = r.sleepStage;
if (stage != null && GRANULAR_ASLEEP_STAGES.has(stage)) {
granular += minutes;
sawGranular = true;
} else if (stage === "ASLEEP" || stage == null) {
// Bare unspecified aggregate (`ASLEEP`) or a stage-less legacy/manual
// row — only used when no granular stage carries the night.
fallback += minutes;
}
// IN_BED / AWAKE never count toward asleep.
}
return sawGranular ? granular : fallback;
}
/**
* Gap (ms) between consecutive stage segments that starts a new sleep
* session. Stage segments within one night are contiguous (minutes to a
@@ -104,7 +148,7 @@ export interface SleepNight {
night: string;
/** Latest stage instant in the night — the night's representative timestamp. */
measuredAt: Date;
/** Time asleep in minutes = CORE + DEEP + REM (+ legacy bare ASLEEP). */
/** Time asleep in minutes = CORE + DEEP + REM, or bare ASLEEP when granular stages are absent. */
asleepMinutes: number;
/** In-bed minutes when an IN_BED row exists, else null. */
inBedMinutes: number | null;
@@ -128,14 +172,20 @@ function pickSessionSource(
rows: SleepStageRow[],
sleepLadder: readonly MeasurementSource[],
): string {
const asleepBySource = new Map<string, number>();
// Group rows by source, then sum each source's asleep minutes with the
// granular-over-bare rule so a source that writes BOTH a bare ASLEEP
// aggregate and the granular breakdown is not over-weighted in the tiebreak.
const rowsBySource = new Map<string, SleepStageRow[]>();
for (const r of rows) {
const src = r.source ?? NO_SOURCE;
const stage = r.sleepStage;
const counts = stage == null || ASLEEP_STAGES.has(stage);
if (!counts) continue;
const minutes = Number.isFinite(r.value) ? r.value : 0;
asleepBySource.set(src, (asleepBySource.get(src) ?? 0) + minutes);
const list = rowsBySource.get(src) ?? [];
list.push(r);
rowsBySource.set(src, list);
}
const asleepBySource = new Map<string, number>();
for (const [src, srcRows] of rowsBySource) {
const minutes = asleepMinutesOf(srcRows);
if (minutes > 0) asleepBySource.set(src, minutes);
}
// No asleep minutes anywhere (IN_BED / AWAKE only) — fall back to the set
// of all present sources so the session still resolves to a single bucket.
@@ -230,7 +280,6 @@ export function reconstructSleepNights(
const nights: SleepNight[] = [];
for (const [night, nightRows] of byNight) {
let asleep = 0;
let inBed = 0;
let awake = 0;
let sawInBed = false;
@@ -250,13 +299,15 @@ export function reconstructSleepNights(
} else if (stage === "AWAKE") {
awake += minutes;
sawAwake = true;
} else if (stage && ASLEEP_STAGES.has(stage)) {
asleep += minutes;
} else if (stage == null) {
// Bare SLEEP_DURATION row (no stage) — the night's total asleep.
asleep += minutes;
}
// 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,