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] 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: {