mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
e78e3df1b9
commit
2a7195601b
6 files changed
+358
-76
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user