mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
a2260d5bb8
commit
b4b9d8d9d7
7 files changed
+169
-95
No files matched your search
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user