feat(insights): surface the recovery score's factor breakdown

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-05 14:58:49 +02:00
1 parent 75b0e618b4
commit dc963540b8
3 files changed
+102 -2

No files matched your search

@@ -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);
}
}
}
@@ -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<typeof vi.fn>;
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 () => {
+35 -1
View File
@@ -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<Derived<WellnessScoreValue>> {
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<WellnessScoreValue>({
value: {
score,
@@ -216,6 +249,7 @@ export async function computeWellnessScore(
.map((r) => r.value)
.reverse(),
anchor,
components,
},
coverage: {
requiredInputs: 1,