mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user