From dc963540b899135b350f8f9548af220bc05af314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 5 Jul 2026 14:58:49 +0200 Subject: [PATCH] feat(insights): surface the recovery score's factor breakdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recovery detail page rendered only the ring — the one wellness score without a composition view, although the COMPUTED score IS the readiness blend persisted nightly. The wellness-score read now attaches the blend's per-factor sub-scores (RHR, HRV, sleep, respiratory rate, mood stability — same engine, same weights, resolved server-side on the same request) when the canonical latest row is the COMPUTED proxy, and the anatomy view renders them through the identical ranked contributor rows the readiness and sleep-score composites use, reusing their labels. A WHOOP-native percentage is not our blend, so it keeps the row-less presentation; a gated blend simply omits the breakdown. The new value field is optional and additive — existing clients decode unchanged. --- .../derived/composite-score-anatomy.tsx | 17 ++++++- .../derived/__tests__/wellness-scores.test.ts | 51 +++++++++++++++++++ src/lib/insights/derived/wellness-scores.ts | 36 ++++++++++++- 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/src/components/insights/derived/composite-score-anatomy.tsx b/src/components/insights/derived/composite-score-anatomy.tsx index 4432f8eab..1fa58e27d 100644 --- a/src/components/insights/derived/composite-score-anatomy.tsx +++ b/src/components/insights/derived/composite-score-anatomy.tsx @@ -185,10 +185,25 @@ export function CompositeScoreAnatomy({ })) .sort(rankByImpact); } else { - // Persisted nightly score — no sub-decomposition; ring + provenance. + // Persisted nightly score — ring + provenance. RECOVERY additionally + // carries the readiness-blend factor decomposition (v1.27.5, resolved + // server-side by the same engine that mints the nightly score), so it + // renders the same ranked contributor rows as the composites. The + // factor set IS the readiness component set, so the labels reuse the + // READINESS component keys verbatim. STRESS / STRAIN stay row-less. const v = data.value as WellnessScoreValue; score = v.score; caption = t(`insights.derived.scoreRing.band.${v.band}`); + if (metric === "RECOVERY_SCORE" && v.components) { + contributors = v.components + .map((c) => ({ + key: c.key, + label: t(`insights.derived.composite.READINESS.component.${c.key}`), + value: c.value, + weight: c.weight, + })) + .sort(rankByImpact); + } } } diff --git a/src/lib/insights/derived/__tests__/wellness-scores.test.ts b/src/lib/insights/derived/__tests__/wellness-scores.test.ts index 0322d258b..ce8ae1d1b 100644 --- a/src/lib/insights/derived/__tests__/wellness-scores.test.ts +++ b/src/lib/insights/derived/__tests__/wellness-scores.test.ts @@ -8,7 +8,14 @@ vi.mock("@/lib/db", () => ({ }, })); +// v1.27.5 — the RECOVERY read attaches the readiness-blend components; the +// blend engine itself is covered by its own suite, so it is mocked here. +vi.mock("../readiness", () => ({ + computeReadiness: vi.fn(), +})); + import { prisma } from "@/lib/db"; +import { computeReadiness } from "../readiness"; import { computeWellnessScore, bandWellnessScore, @@ -22,6 +29,7 @@ const cacheFindUnique = prisma.strainTrimpCache.findUnique as ReturnType< typeof vi.fn >; const userFindUnique = prisma.user.findUnique as ReturnType; +const readinessMock = vi.mocked(computeReadiness); beforeEach(() => { findMany.mockReset(); @@ -31,6 +39,10 @@ beforeEach(() => { // loads the zone when none is passed. Default the cohort to Europe/Berlin. userFindUnique.mockReset(); userFindUnique.mockResolvedValue({ timezone: "Europe/Berlin" }); + // Default: the blend gates (below the min-component floor) — the recovery + // read must survive that and simply omit the breakdown. + readinessMock.mockReset(); + readinessMock.mockResolvedValue({ status: "insufficient" } as never); }); describe("bandWellnessScore", () => { @@ -142,6 +154,41 @@ describe("computeWellnessScore", () => { expect(r.status).toBe("ok"); if (r.status === "ok") { expect((r.value as WellnessScoreValue).anchor).toBeNull(); + // Gated blend (default mock) → no breakdown, read still ok. + expect((r.value as WellnessScoreValue).components).toBeNull(); + } + }); + + it("RECOVERY attaches the readiness-blend components for a COMPUTED row", async () => { + findMany.mockResolvedValue([ + { + value: 72, + measuredAt: new Date("2026-06-01T12:00:00Z"), + source: "COMPUTED", + }, + ]); + const components = [ + { key: "rhr", value: 90, weight: 0.25 }, + { key: "hrv", value: 60, weight: 0.25 }, + { key: "sleep", value: 70, weight: 0.25 }, + { key: "respiratory", value: null, weight: 0 }, + { key: "mood", value: 80, weight: 0.25 }, + ]; + readinessMock.mockResolvedValue({ + status: "ok", + value: { score: 74, band: "green", components }, + } as never); + const r = await computeWellnessScore("RECOVERY_SCORE", "u1", PROFILE, { + now: NOW, + }); + // Same engine, same user, the resolved profile timezone threaded through. + expect(readinessMock).toHaveBeenCalledWith("u1", PROFILE, { + now: NOW, + tz: "Europe/Berlin", + }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect((r.value as WellnessScoreValue).components).toEqual(components); } }); @@ -174,7 +221,11 @@ describe("computeWellnessScore", () => { expect((r.value as WellnessScoreValue).band).toBe("green"); // One night, one canonical row — the off-by-one collapsed. expect((r.value as WellnessScoreValue).daysInWindow).toBe(1); + // A WHOOP-native percentage is not our blend — no decomposition, and + // the blend engine is never invoked for it. + expect((r.value as WellnessScoreValue).components).toBeNull(); } + expect(readinessMock).not.toHaveBeenCalled(); }); it("STRESS still hard-filters to the COMPUTED source", async () => { diff --git a/src/lib/insights/derived/wellness-scores.ts b/src/lib/insights/derived/wellness-scores.ts index 1539e89d5..fb486bbef 100644 --- a/src/lib/insights/derived/wellness-scores.ts +++ b/src/lib/insights/derived/wellness-scores.ts @@ -20,6 +20,7 @@ import type { import { buildInsufficient, buildOk, nowProvenanceTimestamp } from "./coverage"; import type { BaselineProfile } from "./baseline"; import type { StrainAnchor } from "@/lib/insights/strain-score"; +import { computeReadiness, type ReadinessComponent } from "./readiness"; import { resolveCanonicalRecovery } from "./recovery-resolve"; import { SPARKLINE_MAX_POINTS, type Derived } from "./types"; @@ -53,6 +54,19 @@ export interface WellnessScoreValue { * non-breaking). */ anchor?: StrainAnchor | null; + /** + * v1.27.5 — RECOVERY only: the factor decomposition behind the score, so + * the detail page renders the same ranked contributor bars the READINESS / + * SLEEP_SCORE composites carry. The persisted row stores only the number; + * the components come from a server-side run of the SAME readiness blend + * that mints the nightly score (`computeRecoveryScore` delegates to + * `computeReadiness` verbatim), so weights and per-factor values can never + * drift from the engine. Only attached when the canonical latest row is + * the COMPUTED proxy — a WHOOP-native percentage is not our blend, so it + * carries no decomposition. `null` / absent otherwise (additive — iOS + * non-breaking). + */ + components?: ReadinessComponent[] | null; } /** The three persisted score types this engine serves. */ @@ -108,7 +122,7 @@ export interface WellnessScoreOpts { export async function computeWellnessScore( type: WellnessScoreType, userId: string, - _profile: BaselineProfile, + profile: BaselineProfile, opts: WellnessScoreOpts = {}, ): Promise> { const now = opts.now ?? new Date(); @@ -203,6 +217,25 @@ export async function computeWellnessScore( } } + // v1.27.5 — RECOVERY factor decomposition. The persisted row stores only + // the number, but the COMPUTED proxy IS the readiness blend + // (`computeRecoveryScore` delegates to `computeReadiness` verbatim), so the + // blend's per-factor sub-scores are the honest breakdown to show under the + // ring — same engine, same weights, resolved server-side on the same read. + // A WHOOP-native canonical row is not our blend and carries none. Never + // fails the read: a gated blend (below the min-component floor) simply + // leaves the breakdown off. + let components: ReadinessComponent[] | null = null; + if (isRecovery && latest.source === "COMPUTED") { + const readiness = await computeReadiness(userId, profile, { + now, + tz: timezone ?? undefined, + }); + if (readiness.status === "ok" && readiness.value) { + components = readiness.value.components; + } + } + return buildOk({ value: { score, @@ -216,6 +249,7 @@ export async function computeWellnessScore( .map((r) => r.value) .reverse(), anchor, + components, }, coverage: { requiredInputs: 1,