fix(insights): unify sleep reconstruction and make WHOOP recovery canonical

Route the derived Sleep Score through the canonical night reconstruction
instead of its own UTC-day grouping, so a night logged by more than one
source is deduplicated to a single writer rather than summed. The score and
the readiness that folds it now read true per-night totals; multi-source
nights drop to their real figure. Emitted minutes and the night stage map
round to integers.

Resolve recovery to one canonical series — WHOOP-native when present, the
computed proxy as fallback — in the wellness reader, the doctor report, and
the coach snapshot, and stop presenting the proxy and the native score as two
numbers. A WHOOP-connected account's recovery now reads the native value on
the tile, the chart, and the printed report alike.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 17:24:59 +02:00
commit e27bd8b9bf
16 files changed
+529 -106

No files matched your search

+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(),
+9
View File
@@ -319,6 +319,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
updatedAt: row.updatedAt.toISOString(),
});
} else {
// Every row carries its `source`. RECOVERY_SCORE is written by BOTH the
// WHOOP-native sync (`source = WHOOP`) and the COMPUTED proxy job
// (`source = COMPUTED`) for the same day; the client must resolve to ONE
// canonical recovery series — WHOOP wins when present, COMPUTED is the
// fallback — and never chart the proxy and the native value as two
// competing series. The feed faithfully mirrors both rows (a delta-sync
// mirror cannot drop a row mid-keyset without a tombstone); the
// canonical pick is the same WHOOP-over-COMPUTED rule the server's
// wellness tile + doctor PDF apply (`resolveCanonicalRecovery`).
measurementUpserts.push({
id: row.id,
externalId: row.externalId,
@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import { summariseCanonicalRecovery } from "@/lib/doctor-report-data";
function m(type: string, value: number, iso: string, source: string) {
return { type, value, measuredAt: new Date(iso), source: source as never };
}
describe("summariseCanonicalRecovery", () => {
it("returns null when no recovery rows exist", () => {
expect(
summariseCanonicalRecovery([
m("STRESS_SCORE", 40, "2026-06-01T12:00:00Z", "COMPUTED"),
]),
).toBeNull();
});
it("prefers the WHOOP-native row over the COMPUTED proxy per day", () => {
// The same day has a low proxy AND a high native row. The PDF must read the
// native value, not blend both into one average.
const summary = summariseCanonicalRecovery([
m("RECOVERY_SCORE", 50, "2026-06-01T12:00:00Z", "COMPUTED"),
m("RECOVERY_SCORE", 80, "2026-06-01T06:00:00Z", "WHOOP"),
]);
expect(summary).not.toBeNull();
// Only the canonical (WHOOP) row counts — avg/min/max/latest all 80, not 65.
expect(summary!.latest).toBe(80);
expect(summary!.avg).toBe(80);
expect(summary!.min).toBe(80);
expect(summary!.max).toBe(80);
expect(summary!.count).toBe(1);
});
it("summarises across days using one canonical row each", () => {
const summary = summariseCanonicalRecovery([
m("RECOVERY_SCORE", 60, "2026-06-02T12:00:00Z", "COMPUTED"),
m("RECOVERY_SCORE", 80, "2026-06-01T06:00:00Z", "WHOOP"),
m("RECOVERY_SCORE", 40, "2026-06-01T12:00:00Z", "COMPUTED"),
]);
expect(summary!.count).toBe(2);
// Canonical set: 06-02 COMPUTED 60, 06-01 WHOOP 80 (proxy 40 dropped).
expect(summary!.latest).toBe(60);
expect(summary!.min).toBe(60);
expect(summary!.max).toBe(80);
expect(summary!.avg).toBe(70);
});
});
@@ -61,6 +61,32 @@ describe("buildDerivedSnapshotBlock", () => {
]);
});
it("drops RECOVERY_SCORE when it mirrors READINESS (COMPUTED proxy, no native)", async () => {
// With no WHOOP-native recovery, the COMPUTED proxy IS the readiness blend
// verbatim — feeding both labels hands the model the same number twice.
compute.mockImplementation(async (args?: { metric?: string }) => {
if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" });
if (args?.metric === "RECOVERY_SCORE") return ok({ score: 64, band: "yellow" });
return insufficient;
});
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
expect(block).not.toBeNull();
expect(block!.READINESS).toBeDefined();
// The redundant proxy is removed — readiness once, not twice.
expect(block!.RECOVERY_SCORE).toBeUndefined();
});
it("keeps RECOVERY_SCORE when the WHOOP-native value differs from READINESS", async () => {
compute.mockImplementation(async (args?: { metric?: string }) => {
if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" });
if (args?.metric === "RECOVERY_SCORE") return ok({ score: 80, band: "green" });
return insufficient;
});
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
expect(block!.READINESS?.value).toBe(64);
expect(block!.RECOVERY_SCORE?.value).toBe(80);
});
it("isolates a per-metric compute failure", async () => {
compute.mockImplementation(async (args?: { metric?: string }) => {
if (args?.metric === "READINESS") throw new Error("boom");
+13
View File
@@ -124,5 +124,18 @@ export async function buildDerivedSnapshotBlock(
};
}
// Recovery dedup (one number, one engine). The COMPUTED recovery proxy IS the
// readiness blend verbatim, so when no WHOOP-native row exists the resolved
// recovery equals readiness — feeding both labels would hand the model the
// identical score under two names. Drop the redundant recovery line in that
// case; a WHOOP-native recovery resolves to a DIFFERENT number and both stay.
if (
block.READINESS !== undefined &&
block.RECOVERY_SCORE !== undefined &&
block.RECOVERY_SCORE.value === block.READINESS.value
) {
delete block.RECOVERY_SCORE;
}
return Object.keys(block).length > 0 ? block : null;
}
+72 -6
View File
@@ -18,7 +18,11 @@ import {
thresholdMetricForContext,
type GlucoseUnit,
} from "@/lib/glucose";
import type { GlucoseContext } from "@/generated/prisma/client";
import type {
GlucoseContext,
MeasurementSource,
} from "@/generated/prisma/client";
import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve";
import {
DEFAULT_DOCTOR_REPORT_PREFS,
type DoctorReportPrefs,
@@ -221,6 +225,59 @@ export const WELLNESS_SCORE_REPORT_TYPES = [
"STRAIN_SCORE",
] as const;
/** One wellness-summary row (latest + range over the report window). */
interface WellnessScoreSummary {
type: string;
latest: number;
avg: number;
min: number;
max: number;
count: number;
latestAt: string;
}
/** A measurement row as the doctor-report aggregator reads it (subset). */
interface RecoveryMeasurementRow {
type: string;
value: number;
measuredAt: Date;
source: MeasurementSource;
}
/**
* Summarise RECOVERY_SCORE over the window from the CANONICAL row per day:
* a WHOOP-native row wins over the COMPUTED proxy for the same day, so the
* doctor PDF never blends the proxy and the native value into one min/avg/max.
* Returns null when no recovery row exists. Exported for unit tests.
*/
export function summariseCanonicalRecovery(
measurements: readonly RecoveryMeasurementRow[],
): WellnessScoreSummary | null {
const recovery = measurements.filter((m) => m.type === "RECOVERY_SCORE");
if (recovery.length === 0) return null;
const canonical = resolveCanonicalRecovery(
recovery.map((m) => ({
value: m.value,
measuredAt: m.measuredAt,
source: m.source,
})),
);
if (canonical.length === 0) return null;
// `resolveCanonicalRecovery` returns rows newest-first; the report wants the
// latest value + the window range over the canonical set.
const values = canonical.map((r) => r.value);
const latestRow = canonical[0];
return {
type: "RECOVERY_SCORE",
latest: Math.round(latestRow.value),
avg: Math.round(values.reduce((a, b) => a + b, 0) / values.length),
min: Math.round(Math.min(...values)),
max: Math.round(Math.max(...values)),
count: values.length,
latestAt: latestRow.measuredAt.toISOString(),
};
}
const GLUCOSE_CONTEXTS: GlucoseContext[] = [
"FASTING",
"POSTPRANDIAL",
@@ -927,12 +984,21 @@ export async function collectDoctorReportData(
};
}
// v1.10.0 — wellness-score summary. The persisted COMPUTED `*_SCORE`
// rows are already in `byType`/`stats` (they're only filtered out of the
// clinical vitals table). Summarise each present score type for the
// separate "Wellness summary" section. Empty array → null so the renderer
// + FHIR builder skip the section entirely.
// v1.10.0 — wellness-score summary. The persisted `*_SCORE` rows are already
// in `byType`/`stats` (they're only filtered out of the clinical vitals
// table). Summarise each present score type for the separate "Wellness
// summary" section. Empty array → null so the renderer + FHIR builder skip
// the section entirely.
//
// RECOVERY_SCORE carries BOTH the WHOOP-native row and the COMPUTED proxy for
// the same day; mixing them into one min/avg/max would blend two distinct
// series. Resolve to the canonical row per day (WHOOP wins) so the PDF reads
// the SAME value the tile + iOS feed show — one number, one engine.
const wellnessScoreSummaries = WELLNESS_SCORE_REPORT_TYPES.flatMap((type) => {
if (type === "RECOVERY_SCORE") {
const summary = summariseCanonicalRecovery(measurements);
return summary ? [summary] : [];
}
const s = stats[type];
const rows = byType[type];
if (!s || !rows || rows.length === 0) return [];
@@ -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", () => ({
@@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import {
resolveCanonicalRecovery,
type RecoveryRow,
} from "../recovery-resolve";
function row(iso: string, value: number, source: string): RecoveryRow {
return { value, measuredAt: new Date(iso), source: source as never };
}
describe("resolveCanonicalRecovery", () => {
it("prefers the WHOOP-native row over the COMPUTED proxy for the same day", () => {
const rows = [
row("2026-06-01T12:00:00Z", 50, "COMPUTED"),
row("2026-06-01T06:00:00Z", 80, "WHOOP"),
];
const resolved = resolveCanonicalRecovery(rows);
expect(resolved).toHaveLength(1);
expect(resolved[0].source).toBe("WHOOP");
expect(resolved[0].value).toBe(80);
});
it("falls back to COMPUTED when no WHOOP row exists for the day", () => {
const rows = [row("2026-06-01T12:00:00Z", 55, "COMPUTED")];
const resolved = resolveCanonicalRecovery(rows);
expect(resolved).toHaveLength(1);
expect(resolved[0].source).toBe("COMPUTED");
expect(resolved[0].value).toBe(55);
});
it("resolves per day — WHOOP one day, COMPUTED another", () => {
const rows = [
row("2026-06-02T12:00:00Z", 51, "COMPUTED"),
row("2026-06-01T12:00:00Z", 49, "COMPUTED"),
row("2026-06-01T06:00:00Z", 70, "WHOOP"),
];
const resolved = resolveCanonicalRecovery(rows);
// Sorted desc by measuredAt: 06-02 COMPUTED first, then 06-01 WHOOP.
expect(resolved).toHaveLength(2);
expect(resolved[0].source).toBe("COMPUTED");
expect(resolved[0].value).toBe(51);
expect(resolved[1].source).toBe("WHOOP");
expect(resolved[1].value).toBe(70);
});
it("keeps the latest same-source row within a day", () => {
const rows = [
row("2026-06-01T06:00:00Z", 60, "WHOOP"),
row("2026-06-01T18:00:00Z", 75, "WHOOP"),
];
const resolved = resolveCanonicalRecovery(rows);
expect(resolved).toHaveLength(1);
expect(resolved[0].value).toBe(75);
});
it("returns an empty list for no rows", () => {
expect(resolveCanonicalRecovery([])).toEqual([]);
});
});
@@ -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", () => {
@@ -122,7 +122,7 @@ describe("computeWellnessScore", () => {
it("RECOVERY does not read the strain cache and carries a null anchor", async () => {
findMany.mockResolvedValue([
{ value: 72, measuredAt: new Date("2026-06-01T12:00:00Z") },
{ value: 72, measuredAt: new Date("2026-06-01T12:00:00Z"), source: "COMPUTED" },
]);
const r = await computeWellnessScore("RECOVERY_SCORE", "u1", PROFILE, {
now: NOW,
@@ -133,4 +133,33 @@ describe("computeWellnessScore", () => {
expect((r.value as WellnessScoreValue).anchor).toBeNull();
}
});
it("RECOVERY reads BOTH sources and resolves the WHOOP-native row when present", async () => {
// Same day written by both the COMPUTED proxy AND the WHOOP-native sync.
// The native row is canonical → the tile shows 80, not the proxy's 50.
findMany.mockResolvedValue([
{ value: 50, measuredAt: new Date("2026-06-02T12:00:00Z"), source: "COMPUTED" },
{ value: 80, measuredAt: new Date("2026-06-02T06:00:00Z"), source: "WHOOP" },
]);
const r = await computeWellnessScore("RECOVERY_SCORE", "u1", PROFILE, {
now: NOW,
});
// The read must NOT hard-filter to COMPUTED — both sources reach the resolver.
const where = findMany.mock.calls[0][0].where as { source?: unknown };
expect(where.source).toBeUndefined();
expect(r.status).toBe("ok");
if (r.status === "ok") {
expect((r.value as WellnessScoreValue).score).toBe(80);
expect((r.value as WellnessScoreValue).band).toBe("green");
}
});
it("STRESS still hard-filters to the COMPUTED source", async () => {
findMany.mockResolvedValue([
{ value: 30, measuredAt: new Date("2026-06-02T12:00:00Z"), source: "COMPUTED" },
]);
await computeWellnessScore("STRESS_SCORE", "u1", PROFILE, { now: NOW });
const where = findMany.mock.calls[0][0].where as { source?: unknown };
expect(where.source).toBe("COMPUTED");
});
});
@@ -0,0 +1,77 @@
/**
* v1.16.16 — canonical recovery resolution (one number, one engine).
*
* `RECOVERY_SCORE` is written by TWO sources that mean different things for the
* same day:
* - `WHOOP` — the device's own native recovery percentage (ground truth
* when the user wears a WHOOP strap), ingested by the sync.
* - `COMPUTED` — the server-derived proxy (the READINESS blend persisted by
* the nightly `recovery-score` job) — the fallback for a user
* with no native recovery signal.
*
* The DECISION (locked): the WHOOP-native row is canonical WHEN PRESENT; the
* COMPUTED proxy is the fallback. Every read surface — the wellness tile, the
* doctor PDF, the iOS feed — must resolve to the SAME canonical row per day so
* none of them shows the proxy and the native value as two competing series.
*
* Pure resolution: group rows by their calendar day and, per day, keep the
* WHOOP row when one exists, else the COMPUTED one. No DB access here — the
* caller does the bounded read and passes rows in.
*/
import type { MeasurementSource } from "@/generated/prisma/client";
/** A `RECOVERY_SCORE` row as read for resolution. */
export interface RecoveryRow {
value: number;
measuredAt: Date;
source: MeasurementSource;
}
/**
* The per-source preference for `RECOVERY_SCORE`: a native WHOOP row outranks
* the COMPUTED proxy for the same day. Lower number = higher authority. A
* source not listed (there is none today) falls back below both.
*/
const RECOVERY_SOURCE_RANK: Partial<Record<MeasurementSource, number>> = {
WHOOP: 0,
COMPUTED: 1,
};
function rankOf(source: MeasurementSource): number {
return RECOVERY_SOURCE_RANK[source] ?? Number.MAX_SAFE_INTEGER;
}
/** The UTC calendar-day key a recovery row is filed under. */
function dayKeyOf(d: Date): string {
return d.toISOString().slice(0, 10);
}
/**
* Collapse a mixed-source `RECOVERY_SCORE` set to ONE canonical row per day:
* the WHOOP-native row wins when present, else the COMPUTED proxy. Within a
* source the latest `measuredAt` wins (a same-day re-score). Returns the
* canonical rows in the same shape, sorted by `measuredAt` DESCENDING so the
* first element is the most recent canonical day — matching the order the
* wellness reader already expects.
*/
export function resolveCanonicalRecovery(rows: RecoveryRow[]): RecoveryRow[] {
const byDay = new Map<string, RecoveryRow>();
for (const row of rows) {
const key = dayKeyOf(row.measuredAt);
const incumbent = byDay.get(key);
if (incumbent === undefined) {
byDay.set(key, row);
continue;
}
const rowRank = rankOf(row.source);
const incumbentRank = rankOf(incumbent.source);
const better =
rowRank < incumbentRank ||
(rowRank === incumbentRank &&
row.measuredAt.getTime() > incumbent.measuredAt.getTime());
if (better) byDay.set(key, row);
}
return [...byDay.values()].sort(
(a, b) => b.measuredAt.getTime() - a.measuredAt.getTime(),
);
}
+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;
}
+28 -4
View File
@@ -13,10 +13,14 @@
* Server-only — reads `@/lib/db`.
*/
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import type {
MeasurementSource,
MeasurementType,
} from "@/generated/prisma/client";
import { buildInsufficient, buildOk, nowProvenanceTimestamp } from "./coverage";
import type { BaselineProfile } from "./baseline";
import type { StrainAnchor } from "@/lib/insights/strain-score";
import { resolveCanonicalRecovery } from "./recovery-resolve";
import { SPARKLINE_MAX_POINTS, type Derived } from "./types";
/** A 0–100 wellness score band. Higher is better for recovery; for stress a
@@ -106,18 +110,38 @@ export async function computeWellnessScore(
const computedAt = nowProvenanceTimestamp(now);
const measurementType = WELLNESS_SCORE_TYPES[type] as MeasurementType;
const rows = await prisma.measurement.findMany({
// RECOVERY_SCORE is written by TWO sources for the same day — the WHOOP
// native percentage and the COMPUTED proxy. The native row is canonical when
// present, so the read must NOT hard-filter to COMPUTED (that silently drops
// every ingested native row); read both sources and resolve per day below.
// STRESS / STRAIN are COMPUTED-only, so they keep the source filter.
const isRecovery = type === "RECOVERY_SCORE";
const rawRows = await prisma.measurement.findMany({
where: {
userId,
type: measurementType,
source: "COMPUTED",
...(isRecovery ? {} : { source: "COMPUTED" as MeasurementSource }),
deletedAt: null,
measuredAt: { gte: cutoff, lte: now },
},
select: { value: true, measuredAt: true },
select: { value: true, measuredAt: true, source: true },
orderBy: { measuredAt: "desc" },
});
// Collapse a mixed-source recovery set to ONE canonical row per day (WHOOP
// wins over COMPUTED). Non-recovery sets are already single-source, so the
// resolver is recovery-only — the tile, doctor PDF, and iOS feed all read the
// SAME canonical value.
const rows = isRecovery
? resolveCanonicalRecovery(
rawRows.map((r) => ({
value: r.value,
measuredAt: r.measuredAt,
source: r.source,
})),
)
: rawRows;
if (rows.length === 0) {
return buildInsufficient<WellnessScoreValue>({
coverage: {
+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),
});