diff --git a/src/lib/__tests__/doctor-report-recovery-canonical.test.ts b/src/lib/__tests__/doctor-report-recovery-canonical.test.ts index 8f23d122e..9042098fa 100644 --- a/src/lib/__tests__/doctor-report-recovery-canonical.test.ts +++ b/src/lib/__tests__/doctor-report-recovery-canonical.test.ts @@ -14,11 +14,14 @@ describe("summariseCanonicalRecovery", () => { ).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. + it("prefers the WHOOP-native row over the COMPUTED proxy per night", () => { + // ONE physiological night (wake morning Jun 1) carries both rows on their + // real, off-by-one stamps: WHOOP stamps the wake morning (Jun 1), the + // COMPUTED proxy stamps the day that ended (May 31 noon). The resolver + // shifts the proxy onto the wake day so they collapse to one night, and the + // PDF reads the native value, not a blend. const summary = summariseCanonicalRecovery([ - m("RECOVERY_SCORE", 50, "2026-06-01T12:00:00Z", "COMPUTED"), + m("RECOVERY_SCORE", 50, "2026-05-31T12:00:00Z", "COMPUTED"), m("RECOVERY_SCORE", 80, "2026-06-01T06:00:00Z", "WHOOP"), ]); expect(summary).not.toBeNull(); @@ -30,14 +33,18 @@ describe("summariseCanonicalRecovery", () => { expect(summary!.count).toBe(1); }); - it("summarises across days using one canonical row each", () => { + it("summarises across nights using one canonical row each", () => { + // Night A (wake Jun 1): WHOOP 80 + its COMPUTED proxy 40 (stamped May 31 + // noon) collapse to the native 80. Night B (wake Jun 2): a COMPUTED-only + // proxy 60, stamped Jun 1 noon, stands alone. const summary = summariseCanonicalRecovery([ - m("RECOVERY_SCORE", 60, "2026-06-02T12:00:00Z", "COMPUTED"), + m("RECOVERY_SCORE", 60, "2026-06-01T12:00:00Z", "COMPUTED"), m("RECOVERY_SCORE", 80, "2026-06-01T06:00:00Z", "WHOOP"), - m("RECOVERY_SCORE", 40, "2026-06-01T12:00:00Z", "COMPUTED"), + m("RECOVERY_SCORE", 40, "2026-05-31T12:00:00Z", "COMPUTED"), ]); expect(summary!.count).toBe(2); - // Canonical set: 06-02 COMPUTED 60, 06-01 WHOOP 80 (proxy 40 dropped). + // Canonical set: night B COMPUTED 60 (latest stamp), night A WHOOP 80 + // (proxy 40 dropped). expect(summary!.latest).toBe(60); expect(summary!.min).toBe(60); expect(summary!.max).toBe(80); diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index a86fd8282..9e33e35b1 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -252,6 +252,7 @@ interface RecoveryMeasurementRow { */ export function summariseCanonicalRecovery( measurements: readonly RecoveryMeasurementRow[], + timezone?: string | null, ): WellnessScoreSummary | null { const recovery = measurements.filter((m) => m.type === "RECOVERY_SCORE"); if (recovery.length === 0) return null; @@ -261,6 +262,7 @@ export function summariseCanonicalRecovery( measuredAt: m.measuredAt, source: m.source, })), + timezone, ); if (canonical.length === 0) return null; // `resolveCanonicalRecovery` returns rows newest-first; the report wants the @@ -996,7 +998,7 @@ export async function collectDoctorReportData( // 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); + const summary = summariseCanonicalRecovery(measurements, reportTz); return summary ? [summary] : []; } const s = stats[type];