fix(doctor-report): resolve recovery by night in the user timezone

Pass the report's timezone into the canonical recovery resolution so the PDF
buckets a WHOOP-native row and its computed proxy onto the same physiological
night — the proxy is stamped the day that ended while WHOOP stamps the wake
morning — instead of a raw calendar day. Aligns the printed recovery summary
with the tile, the coach, and the companion feed.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 17:57:21 +02:00
1 parent 5831143e26
commit 5b88401935
2 files changed
+18 -9

No files matched your search

@@ -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);
+3 -1
View File
@@ -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];