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

@@ -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);
});
});