Unify sleep-night reconstruction onto the canonical engine

Replace the divergent UTC-day night reconstructor in the Sleep Score with a
thin adapter over reconstructSleepNights, the shared engine the dashboard,
hypnogram, and doctor surfaces already read. The old path bucketed rows by UTC
calendar day and never deduplicated writers, so a multi-source night (WHOOP +
Apple Health for the same sleep) summed both writers and roughly doubled the
total. The adapter inherits session-clustering, local-wake-day keying, and
multi-source writer dedup, then derives the score-only fields the canonical
night does not carry: REM and Deep minutes from the stage map, the stage-
breakdown flag from any granular stage, and the midpoint from the asleep span.

Select source and deviceType in the Sleep Score read so the writer-dedup
ladder fires, and thread the user source priority through so the chosen writer
matches every other sleep surface. Round emitted asleep and in-bed minutes to
whole numbers, and round the per-stage map in the sleep-night DTO, matching the
integer contract the native client decodes.

A multi-source user's sleep score and the readiness score that folds it drop
once the double-count is removed, reflecting the true single-writer night.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 17:14:14 +02:00
1 parent a2260d5bb8
commit b4b9d8d9d7
7 files changed
+169 -95

No files matched your search

+6 -2
View File
@@ -8661,7 +8661,9 @@ components:
- CORE
- DEEP
additionalProperties:
type: number
type: integer
minimum: 0
maximum: 9007199254740991
required:
- IN_BED
- AWAKE
@@ -8776,7 +8778,9 @@ components:
- CORE
- DEEP
additionalProperties:
type: number
type: integer
minimum: 0
maximum: 9007199254740991
required:
- IN_BED
- AWAKE
+8 -1
View File
@@ -64,7 +64,14 @@ function serializeSession(s: SleepSession) {
inBedMinutes: s.inBedMinutes === null ? null : Math.round(s.inBedMinutes),
awakeMinutes: s.awakeMinutes === null ? null : Math.round(s.awakeMinutes),
awakenings: s.awakenings,
stages: s.stages,
// iOS #18 — round the per-stage map too; the underlying totals sum
// second-precision segments and otherwise serialise as e.g. 88.4999.
stages: Object.fromEntries(
Object.entries(s.stages).map(([stage, minutes]) => [
stage,
Math.round(minutes),
]),
),
segments: s.segments.map((seg) => ({
stage: seg.stage,
start: seg.start.toISOString(),
@@ -4,6 +4,9 @@ vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn().mockResolvedValue([]) },
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
// The Sleep Score reads the user's source priority for the canonical
// writer-dedup ladder; default to no override.
user: { findUnique: vi.fn().mockResolvedValue(null) },
},
}));
vi.mock("@/lib/rollups/measurement-coverage", () => ({
@@ -4,6 +4,9 @@ vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
// The folded Sleep Score now reads the user's source priority for the
// canonical writer-dedup ladder; default to no override.
user: { findUnique: vi.fn().mockResolvedValue(null) },
},
}));
vi.mock("@/lib/rollups/measurement-coverage", () => ({
@@ -1,7 +1,12 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: { measurement: { findMany: vi.fn() } },
prisma: {
measurement: { findMany: vi.fn() },
// `computeSleepScore` reads the user's source priority for the canonical
// writer-dedup ladder; default to no override (null → default ladder).
user: { findUnique: vi.fn().mockResolvedValue(null) },
},
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: vi.fn().mockResolvedValue("UTC"),
@@ -39,6 +44,22 @@ function night(day: string, stages: Array<[string | null, number, string]>) {
}));
}
/** Build per-stage rows tagged with an ingest source + device-type. */
function srcNight(
day: string,
source: string,
stages: Array<[string | null, number, string]>,
deviceType?: string,
) {
return stages.map(([sleepStage, value, hhmm]) => ({
value,
measuredAt: new Date(`${day}T${hhmm}:00Z`),
sleepStage: sleepStage as never,
source: source as never,
deviceType: deviceType ?? null,
}));
}
describe("pure sleep scorers", () => {
it("sleepNeedMinutes is age-banded with an adult default", () => {
expect(sleepNeedMinutes(40)).toBe(7 * 60);
@@ -154,8 +175,9 @@ describe("reconstructNights", () => {
expect(nights[0].deepMinutes).toBe(60);
});
it("expresses the midpoint in UTC by default", () => {
// Earliest 01:00Z, latest 05:00Z → midpoint 03:00Z = 180 min-of-day.
it("expresses the midpoint in the user's wall clock (asleep span centred on wake)", () => {
// Asleep span ends 05:00Z, 240 asleep minutes → midpoint at wake −
// 120 min = 03:00Z = 180 min-of-day in UTC.
const rows = night("2026-06-02", [
["CORE", 120, "01:00"],
["CORE", 120, "05:00"],
@@ -174,6 +196,39 @@ describe("reconstructNights", () => {
const nights = reconstructNights(rows, "Asia/Kolkata");
expect(nights[0].midpoint).toBe(8 * 60 + 30);
});
it("deduplicates a multi-source night to ONE canonical total (no double-count)", () => {
// The SAME physical night written by both Apple Health AND WHOOP. The
// divergent UTC-day reconstructor used to SUM both writers (~doubling the
// night). The canonical engine collapses to one writer (WHOOP wins the
// sleep ladder) before summing.
const apple = srcNight(
"2026-06-02",
"APPLE_HEALTH",
[
["CORE", 240, "03:00"],
["DEEP", 60, "04:00"],
["REM", 90, "05:00"],
],
"watch",
);
const whoop = srcNight(
"2026-06-02",
"WHOOP",
[
["CORE", 250, "03:00"],
["DEEP", 65, "04:00"],
["REM", 95, "05:00"],
],
"strap",
);
const nights = reconstructNights([...apple, ...whoop]);
expect(nights).toHaveLength(1);
// ONE writer's total, NOT apple + whoop summed (~780).
expect(nights[0].asleepMinutes).toBeLessThan(500);
// WHOOP wins the default sleep ladder → its 250+65+95 = 410 total.
expect(nights[0].asleepMinutes).toBe(410);
});
});
describe("computeSleepScore", () => {
+90 -88
View File
@@ -32,10 +32,19 @@
* are not rolled up by stage, so the night reconstruction reads raw, but
* bounded to the window). The pure scorers are exported for unit tests.
*/
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
import type {
MeasurementSource,
MeasurementType,
SleepStage,
} from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import {
buildInsufficient,
buildOk,
@@ -48,8 +57,6 @@ import type { Derived } from "./types";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** Default trailing window for the consistency/timing baseline (days). */
const DEFAULT_WINDOW_DAYS = 30;
/** A night needs ≥ this many stage rows to score at all. */
const MIN_STAGE_ROWS_PER_NIGHT = 1;
/** Transparent sub-score weights (shown to the user, sum to 1 over present). */
export const SLEEP_SUBSCORE_WEIGHTS = {
@@ -88,13 +95,6 @@ export interface SleepScoreValue {
windowNights: number;
}
const ASLEEP_STAGES: ReadonlySet<SleepStage> = new Set<SleepStage>([
"ASLEEP",
"REM",
"CORE",
"DEEP",
]);
// ── pure scorers (exported for tests) ─────────────────────────────────
/**
@@ -300,6 +300,14 @@ interface SleepRow {
value: number;
measuredAt: Date;
sleepStage: SleepStage | null;
/**
* Ingest source + device-type — fed to the canonical `reconstructSleepNights`
* writer-dedup so a multi-source night (WHOOP + Apple Health) collapses to
* ONE writer before summing instead of double-counting. Optional: a legacy
* fixture or single-source caller that omits them dedups as before.
*/
source?: MeasurementSource | null;
deviceType?: string | null;
}
export interface NightSummary {
@@ -316,83 +324,67 @@ export interface NightSummary {
}
/**
* Group raw stage rows into per-night summaries. A "night" is keyed by the
* latest stage row's calendar day (the wake day). Pure — the caller does
* the bounded DB read and passes rows in.
* Adapt the canonical per-night reconstruction (`reconstructSleepNights`) into
* the `NightSummary` shape the Sleep Score scorers consume. ONE ENGINE: the
* same session-clustering, local-wake-day keying, and multi-source writer
* dedup the dashboard / hypnogram / doctor surfaces use now also backs the
* Sleep Score, so a multi-source night (WHOOP + Apple Health) is counted ONCE,
* not summed across writers.
*
* This is an ADAPTER, not a drop-in: `reconstructSleepNights` returns the
* canonical asleep total + per-stage map + in-bed/awake totals, and this
* function derives the score-only fields the canonical engine does not carry —
* `remMinutes` / `deepMinutes` from the stage map, `hasStageBreakdown` from the
* presence of any granular stage, and `midpoint` from the asleep span (the
* canonical night's wake instant minus half the asleep minutes), expressed in
* the user's wall clock.
*
* `tz` is the IANA zone the midpoint is expressed against: a sleeper's
* 03:00-local midpoint must read as minutes-of-day in THEIR wall clock, not
* UTC, so a non-UTC user's consistency / timing sub-scores don't drift with
* the offset. Defaults to UTC for back-compatible pure use.
* the offset. Defaults to UTC for back-compatible pure use. `priorityJson` is
* the user's persisted source priority (or null for the defaults) so the
* writer-dedup ladder matches every other sleep surface.
*/
export function reconstructNights(
rows: SleepRow[],
tz: string = "UTC",
priorityJson: unknown = null,
): NightSummary[] {
// Bucket rows by the wake-day key (UTC day of the row's measuredAt).
const byNight = new Map<string, SleepRow[]>();
for (const row of rows) {
const key = row.measuredAt.toISOString().slice(0, 10);
const list = byNight.get(key) ?? [];
list.push(row);
byNight.set(key, list);
}
const nights: NightSummary[] = [];
for (const [night, nightRows] of byNight) {
let asleep = 0;
let awake = 0;
let rem = 0;
let deep = 0;
let inBed = 0;
let sawInBed = false;
let sawStageBreakdown = false;
let earliest = Infinity;
let latest = -Infinity;
for (const r of nightRows) {
const stage = r.sleepStage;
const minutes = Number.isFinite(r.value) ? r.value : 0;
const t = r.measuredAt.getTime();
if (t < earliest) earliest = t;
if (t > latest) latest = t;
if (stage === "IN_BED") {
inBed += minutes;
sawInBed = true;
continue;
}
if (stage === "AWAKE") {
awake += minutes;
continue;
}
if (stage && ASLEEP_STAGES.has(stage)) {
asleep += minutes;
if (stage === "REM") rem += minutes;
if (stage === "DEEP") deep += minutes;
if (stage === "REM" || stage === "CORE" || stage === "DEEP") {
sawStageBreakdown = true;
}
} else if (stage == null) {
// A bare SLEEP_DURATION row (no stage) is the night's total asleep.
asleep += minutes;
}
}
// In-bed = explicit IN_BED rows when present, else asleep + awake.
const inBedMinutes = sawInBed ? inBed : awake > 0 ? asleep + awake : null;
const stageRows: SleepStageRow[] = rows.map((r) => ({
value: r.value,
measuredAt: r.measuredAt,
sleepStage: r.sleepStage,
source: r.source ?? null,
deviceType: r.deviceType ?? null,
}));
const nights = reconstructSleepNights(stageRows, tz, priorityJson);
return nights.map((n) => {
const rem = n.stages.REM ?? 0;
const deep = n.stages.DEEP ?? 0;
const core = n.stages.CORE ?? 0;
const hasStageBreakdown = rem > 0 || deep > 0 || core > 0;
// Midpoint = centre of the asleep span. The canonical night's `measuredAt`
// is the wake instant (latest stage END); the asleep span is
// `asleepMinutes` long ending there, so its centre is wake − asleep/2.
const midpoint =
Number.isFinite(earliest) && Number.isFinite(latest) && latest > earliest
? minutesOfDay(new Date((earliest + latest) / 2), tz)
n.asleepMinutes > 0
? minutesOfDay(
new Date(n.measuredAt.getTime() - (n.asleepMinutes / 2) * 60_000),
tz,
)
: null;
nights.push({
night,
asleepMinutes: asleep,
awakeMinutes: awake,
return {
night: n.night,
asleepMinutes: n.asleepMinutes,
awakeMinutes: n.awakeMinutes ?? 0,
remMinutes: rem,
deepMinutes: deep,
inBedMinutes,
hasStageBreakdown: sawStageBreakdown,
inBedMinutes: n.inBedMinutes,
hasStageBreakdown,
midpoint,
});
}
return nights.sort((a, b) => (a.night < b.night ? -1 : 1));
};
});
}
function minutesOfDay(d: Date, tz: string): number {
@@ -430,6 +422,10 @@ export async function computeSleepScore(
// non-UTC sleeper's consistency / timing sub-scores don't drift with the
// offset. Resolve the stored zone unless a caller pins one.
const tz = opts.tz ?? (await resolveUserTimezone(userId));
// The canonical writer-dedup ladder needs the user's source priority — read
// it alongside so a multi-source night collapses to the SAME writer the
// dashboard / hypnogram pick.
const priorityJson = await loadUserSourcePriority(userId);
const inputs = ["SLEEP_DURATION"];
const required = 1;
const since = new Date(now.getTime() - windowDays * MS_PER_DAY);
@@ -442,7 +438,15 @@ export async function computeSleepScore(
measuredAt: { gte: since },
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, sleepStage: true },
// `source` + `deviceType` feed the canonical writer-dedup so a multi-source
// night is counted ONCE, not summed across writers.
select: {
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
})) as SleepRow[];
if (rows.length === 0) {
@@ -460,12 +464,14 @@ export async function computeSleepScore(
});
}
const nights = reconstructNights(rows, tz).filter(
// Reconstruct via the canonical engine (session-clustering, local-wake-day
// keying, multi-source writer dedup) so the Sleep Score reads the SAME night
// totals the dashboard / hypnogram / doctor surfaces do. Every reconstructed
// night derives from ≥ 1 contributing row by construction, so a scorable
// night is just one that carries asleep minutes.
const scorableNights = reconstructNights(rows, tz, priorityJson).filter(
(n) => n.asleepMinutes > 0,
);
const scorableNights = nights.filter(
(n) => countNightRows(rows, n.night) >= MIN_STAGE_ROWS_PER_NIGHT,
);
if (scorableNights.length === 0) {
const { coverage } = deriveCoverage({
@@ -527,8 +533,12 @@ export async function computeSleepScore(
score,
band: bandForScore(score),
night: latest.night,
asleepMinutes: latest.asleepMinutes,
inBedMinutes: latest.inBedMinutes,
// Emit whole minutes (iOS #18) — the canonical totals sum second-
// precision segments and can otherwise serialise as e.g. 433.4999.
// Null-preserving on the in-bed signal.
asleepMinutes: Math.round(latest.asleepMinutes),
inBedMinutes:
latest.inBedMinutes === null ? null : Math.round(latest.inBedMinutes),
subScores,
windowNights: scorableNights.length,
},
@@ -542,11 +552,3 @@ export async function computeSleepScore(
},
});
}
function countNightRows(rows: SleepRow[], night: string): number {
let c = 0;
for (const r of rows) {
if (r.measuredAt.toISOString().slice(0, 10) === night) c += 1;
}
return c;
}
+1 -1
View File
@@ -200,7 +200,7 @@ const sleepSessionResource = z.object({
inBedMinutes: z.number().int().nonnegative().nullable(),
awakeMinutes: z.number().int().nonnegative().nullable(),
awakenings: z.number().int().nonnegative(),
stages: z.record(sleepStageEnum, z.number()),
stages: z.record(sleepStageEnum, z.number().int().nonnegative()),
segments: z.array(sleepSegmentResource),
});