From b4b9d8d9d7de0a8881bf8509f0e171309efe3bb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 17:14:14 +0200 Subject: [PATCH] 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. --- docs/api/openapi.yaml | 8 +- src/app/api/sleep/night/route.ts | 9 +- .../derived/__tests__/dispatch.test.ts | 3 + .../derived/__tests__/readiness.test.ts | 3 + .../derived/__tests__/sleep-score.test.ts | 61 +++++- src/lib/insights/derived/sleep-score.ts | 178 +++++++++--------- src/lib/openapi/routes/measurements.ts | 2 +- 7 files changed, 169 insertions(+), 95 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4ca8aebd8..7c102ba6d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/src/app/api/sleep/night/route.ts b/src/app/api/sleep/night/route.ts index 8b308436e..a930d3a3d 100644 --- a/src/app/api/sleep/night/route.ts +++ b/src/app/api/sleep/night/route.ts @@ -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(), diff --git a/src/lib/insights/derived/__tests__/dispatch.test.ts b/src/lib/insights/derived/__tests__/dispatch.test.ts index f23ef2450..f717d3dd8 100644 --- a/src/lib/insights/derived/__tests__/dispatch.test.ts +++ b/src/lib/insights/derived/__tests__/dispatch.test.ts @@ -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", () => ({ diff --git a/src/lib/insights/derived/__tests__/readiness.test.ts b/src/lib/insights/derived/__tests__/readiness.test.ts index ddc506bc5..aa40d326c 100644 --- a/src/lib/insights/derived/__tests__/readiness.test.ts +++ b/src/lib/insights/derived/__tests__/readiness.test.ts @@ -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", () => ({ diff --git a/src/lib/insights/derived/__tests__/sleep-score.test.ts b/src/lib/insights/derived/__tests__/sleep-score.test.ts index b63dc5347..3eaf40a68 100644 --- a/src/lib/insights/derived/__tests__/sleep-score.test.ts +++ b/src/lib/insights/derived/__tests__/sleep-score.test.ts @@ -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", () => { diff --git a/src/lib/insights/derived/sleep-score.ts b/src/lib/insights/derived/sleep-score.ts index 4c8a24ef4..01fcf2314 100644 --- a/src/lib/insights/derived/sleep-score.ts +++ b/src/lib/insights/derived/sleep-score.ts @@ -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 = new Set([ - "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(); - 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; -} diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index e452f5a96..d89852a44 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -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), });