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 1/2] 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), }); From eba01b37ce0f68608949ec5a1ef1e460d946d877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 17:19:16 +0200 Subject: [PATCH 2/2] Make WHOOP-native recovery the canonical read RECOVERY_SCORE is written by two sources for the same day: the WHOOP-native percentage from the device sync and the COMPUTED proxy from the nightly readiness job. The wellness reader hard-filtered to source COMPUTED, silently dropping every ingested native row, and the doctor PDF blended both sources into one min/avg/max. The COMPUTED proxy also equals the readiness blend verbatim, so the Coach prompt carried the same number under both READINESS and RECOVERY_SCORE. Add resolveCanonicalRecovery: per day, keep the WHOOP row when present, else the COMPUTED proxy. Drop the COMPUTED-only filter for RECOVERY in the wellness reader and resolve to the canonical row; STRESS and STRAIN stay COMPUTED-only. Resolve the doctor PDF wellness summary the same way so the report reads the native value, not a proxy/native blend. Dedup the Coach snapshot: omit the recovery line when it mirrors readiness (the no-native case), keep it when a WHOOP-native value differs. Document the WHOOP-over-COMPUTED resolution on the sync delta feed, which already mirrors each row with its source so the client resolves one canonical series rather than charting two. A WHOOP-connected user's recovery tile, chart, and PDF flip from the proxy to the native value. --- src/app/api/sync/changes/route.ts | 9 +++ .../doctor-report-recovery-canonical.test.ts | 46 +++++++++++ .../coach/__tests__/derived-snapshot.test.ts | 26 +++++++ src/lib/ai/coach/derived-snapshot.ts | 13 ++++ src/lib/doctor-report-data.ts | 78 +++++++++++++++++-- .../__tests__/recovery-resolve.test.ts | 59 ++++++++++++++ .../derived/__tests__/wellness-scores.test.ts | 31 +++++++- src/lib/insights/derived/recovery-resolve.ts | 77 ++++++++++++++++++ src/lib/insights/derived/wellness-scores.ts | 32 +++++++- 9 files changed, 360 insertions(+), 11 deletions(-) create mode 100644 src/lib/__tests__/doctor-report-recovery-canonical.test.ts create mode 100644 src/lib/insights/derived/__tests__/recovery-resolve.test.ts create mode 100644 src/lib/insights/derived/recovery-resolve.ts diff --git a/src/app/api/sync/changes/route.ts b/src/app/api/sync/changes/route.ts index 6a6d62e1b..72dddd448 100644 --- a/src/app/api/sync/changes/route.ts +++ b/src/app/api/sync/changes/route.ts @@ -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, diff --git a/src/lib/__tests__/doctor-report-recovery-canonical.test.ts b/src/lib/__tests__/doctor-report-recovery-canonical.test.ts new file mode 100644 index 000000000..8f23d122e --- /dev/null +++ b/src/lib/__tests__/doctor-report-recovery-canonical.test.ts @@ -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); + }); +}); diff --git a/src/lib/ai/coach/__tests__/derived-snapshot.test.ts b/src/lib/ai/coach/__tests__/derived-snapshot.test.ts index a43175f29..01a1259d5 100644 --- a/src/lib/ai/coach/__tests__/derived-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/derived-snapshot.test.ts @@ -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"); diff --git a/src/lib/ai/coach/derived-snapshot.ts b/src/lib/ai/coach/derived-snapshot.ts index dc78d673d..ed672955b 100644 --- a/src/lib/ai/coach/derived-snapshot.ts +++ b/src/lib/ai/coach/derived-snapshot.ts @@ -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; } diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index c48185e84..a86fd8282 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -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 []; diff --git a/src/lib/insights/derived/__tests__/recovery-resolve.test.ts b/src/lib/insights/derived/__tests__/recovery-resolve.test.ts new file mode 100644 index 000000000..b73054353 --- /dev/null +++ b/src/lib/insights/derived/__tests__/recovery-resolve.test.ts @@ -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([]); + }); +}); diff --git a/src/lib/insights/derived/__tests__/wellness-scores.test.ts b/src/lib/insights/derived/__tests__/wellness-scores.test.ts index 0928c979a..1df5d0a46 100644 --- a/src/lib/insights/derived/__tests__/wellness-scores.test.ts +++ b/src/lib/insights/derived/__tests__/wellness-scores.test.ts @@ -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"); + }); }); diff --git a/src/lib/insights/derived/recovery-resolve.ts b/src/lib/insights/derived/recovery-resolve.ts new file mode 100644 index 000000000..6485ea332 --- /dev/null +++ b/src/lib/insights/derived/recovery-resolve.ts @@ -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> = { + 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(); + 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(), + ); +} diff --git a/src/lib/insights/derived/wellness-scores.ts b/src/lib/insights/derived/wellness-scores.ts index f52f739f8..2e71d0123 100644 --- a/src/lib/insights/derived/wellness-scores.ts +++ b/src/lib/insights/derived/wellness-scores.ts @@ -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({ coverage: {