feat(insights): read the persisted wellness scores through the derived contract

Register RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE as derived metrics that
read the nightly-persisted COMPUTED Measurement rows — a passthrough, never a
recompute. The dispatcher routes the three ids to a thin reader that returns
the latest score, a trailing-window trend, and a direction-aware band (stress
and strain invert, so a high stress reads red). This lets every surface — the
dashboard, the Coach snapshot, the doctor report — consume the scores through
the one Derived<T> route + hook rather than each growing its own read path.
Regenerate the OpenAPI enum, which picks up the three ids from the registry.
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 23:13:26 +02:00
1 parent 02c50acd46
commit a8daef7376
7 files changed
+308 -2

No files matched your search

+6
View File
@@ -2445,6 +2445,9 @@ paths:
- COINCIDENT_DEVIATION
- HRV_BALANCE
- BMI
- RECOVERY_SCORE
- STRESS_SCORE
- STRAIN_SCORE
description: "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, VASCULAR_AGE_DELTA, HRV_BALANCE, BMI,
READINESS). Closed enum: an unknown id 422s. Metrics whose compute has not yet landed return an
`insufficient` value with reason `not_implemented`."
@@ -7592,6 +7595,9 @@ components:
- COINCIDENT_DEVIATION
- HRV_BALANCE
- BMI
- RECOVERY_SCORE
- STRESS_SCORE
- STRAIN_SCORE
description: Echoes the requested derived-metric id (tags the union).
status:
type: string
@@ -36,6 +36,9 @@ describe("registry", () => {
expect(getDerivedMetricMeta("SLEEP_SCORE")?.implemented).toBe(true);
expect(getDerivedMetricMeta("READINESS")?.implemented).toBe(true);
expect(getDerivedMetricMeta("COINCIDENT_DEVIATION")?.implemented).toBe(true);
expect(getDerivedMetricMeta("RECOVERY_SCORE")?.implemented).toBe(true);
expect(getDerivedMetricMeta("STRESS_SCORE")?.implemented).toBe(true);
expect(getDerivedMetricMeta("STRAIN_SCORE")?.implemented).toBe(true);
});
it("isDerivedMetricId rejects unknown ids", () => {
@@ -53,7 +56,10 @@ describe("registry", () => {
expect(DERIVED_METRIC_IDS).toContain("READINESS");
expect(DERIVED_METRIC_IDS).toContain("HRV_BALANCE");
expect(DERIVED_METRIC_IDS).toContain("BMI");
expect(DERIVED_METRIC_IDS.length).toBe(8);
expect(DERIVED_METRIC_IDS).toContain("RECOVERY_SCORE");
expect(DERIVED_METRIC_IDS).toContain("STRESS_SCORE");
expect(DERIVED_METRIC_IDS).toContain("STRAIN_SCORE");
expect(DERIVED_METRIC_IDS.length).toBe(11);
});
});
@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: { measurement: { findMany: vi.fn() } },
}));
import { prisma } from "@/lib/db";
import {
computeWellnessScore,
bandWellnessScore,
type WellnessScoreValue,
} from "../wellness-scores";
const PROFILE = { ageYears: 40, sex: "MALE" as const };
const NOW = new Date("2026-06-02T08:00:00Z");
const findMany = prisma.measurement.findMany as ReturnType<typeof vi.fn>;
beforeEach(() => {
findMany.mockReset();
});
describe("bandWellnessScore", () => {
it("higher is better for recovery", () => {
expect(bandWellnessScore("RECOVERY_SCORE", 80)).toBe("green");
expect(bandWellnessScore("RECOVERY_SCORE", 50)).toBe("yellow");
expect(bandWellnessScore("RECOVERY_SCORE", 20)).toBe("red");
});
it("higher is worse for stress (band inverts)", () => {
expect(bandWellnessScore("STRESS_SCORE", 80)).toBe("red");
expect(bandWellnessScore("STRESS_SCORE", 50)).toBe("yellow");
expect(bandWellnessScore("STRESS_SCORE", 20)).toBe("green");
});
});
describe("computeWellnessScore", () => {
it("returns insufficient with no_score_in_window when the job hasn't run", async () => {
findMany.mockResolvedValue([]);
const r = await computeWellnessScore("RECOVERY_SCORE", "u1", PROFILE, {
now: NOW,
});
expect(r.status).toBe("insufficient");
if (r.status === "insufficient") {
expect(r.reason).toBe("no_score_in_window");
}
});
it("reads the latest persisted score and a trailing trend", async () => {
findMany.mockResolvedValue([
{ value: 72, measuredAt: new Date("2026-06-02T06:00:00Z") },
{ value: 60, measuredAt: new Date("2026-06-01T06:00:00Z") },
{ value: 64, measuredAt: new Date("2026-05-31T06:00:00Z") },
]);
const r = await computeWellnessScore("RECOVERY_SCORE", "u1", PROFILE, {
now: NOW,
});
expect(r.status).toBe("ok");
if (r.status === "ok") {
const v = r.value as WellnessScoreValue;
expect(v.score).toBe(72);
expect(v.band).toBe("green");
// 72 - mean(60, 64) = 72 - 62 = 10
expect(v.trendDelta).toBe(10);
expect(v.daysInWindow).toBe(3);
}
});
it("null trend when only one score exists", async () => {
findMany.mockResolvedValue([
{ value: 40, measuredAt: new Date("2026-06-02T06:00:00Z") },
]);
const r = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, {
now: NOW,
});
expect(r.status).toBe("ok");
if (r.status === "ok") {
expect((r.value as WellnessScoreValue).trendDelta).toBeNull();
}
});
});
+15
View File
@@ -29,6 +29,10 @@ import { computeBmi } from "./bmi";
import { computeSleepScore } from "./sleep-score";
import { computeReadiness } from "./readiness";
import { computeCoincidentDeviation } from "./coincident-deviation";
import {
computeWellnessScore,
type WellnessScoreType,
} from "./wellness-scores";
import type { Derived } from "./types";
export interface DerivedComputeArgs {
@@ -142,6 +146,17 @@ export async function computeDerivedMetric(
windowDays: args.windowDays,
now,
}) as Promise<Derived<unknown>>;
case "RECOVERY_SCORE":
case "STRESS_SCORE":
case "STRAIN_SCORE":
// Passthrough read of the nightly-persisted COMPUTED rows — never a
// recompute. The same `Derived<T>` contract carries the scores.
return computeWellnessScore(
args.metric as WellnessScoreType,
args.userId,
args.profile,
{ windowDays: args.windowDays, now },
) as Promise<Derived<unknown>>;
default:
// Registered + implemented but no dispatch arm — treat as a stub.
return notImplemented(meta.inputs.map(String), now);
+13
View File
@@ -130,3 +130,16 @@ export type {
CoincidentDeviationOpts,
VitalDeviation,
} from "./coincident-deviation";
// ── persisted nightly wellness scores (passthrough read) ─────────────
export {
computeWellnessScore,
bandWellnessScore,
WELLNESS_SCORE_TYPES,
} from "./wellness-scores";
export type {
WellnessScoreValue,
WellnessScoreBand,
WellnessScoreType,
WellnessScoreOpts,
} from "./wellness-scores";
+34 -1
View File
@@ -40,7 +40,13 @@ export type DerivedMetricId =
/** W2: HRV (SDNN) personal-trend balance band (reuses the baseline engine). */
| "HRV_BALANCE"
/** W2: BMI from weight + height (fallback when no device BMI). */
| "BMI";
| "BMI"
/** Persisted nightly recovery composite (passthrough read of COMPUTED rows). */
| "RECOVERY_SCORE"
/** Persisted nightly stress composite (passthrough read of COMPUTED rows). */
| "STRESS_SCORE"
/** Persisted nightly strain composite (passthrough read of COMPUTED rows). */
| "STRAIN_SCORE";
/** Archetype of a derived metric — drives shaping + the QA inventory. */
export type DerivedArchetype =
@@ -177,6 +183,33 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
minInputs: 2,
implemented: true,
},
RECOVERY_SCORE: {
id: "RECOVERY_SCORE",
displayName: "Recovery score",
archetype: "passthrough-reframe",
inputs: ["RECOVERY_SCORE"],
minHistoryDays: 1,
minInputs: 1,
implemented: true,
},
STRESS_SCORE: {
id: "STRESS_SCORE",
displayName: "Stress score",
archetype: "passthrough-reframe",
inputs: ["STRESS_SCORE"],
minHistoryDays: 1,
minInputs: 1,
implemented: true,
},
STRAIN_SCORE: {
id: "STRAIN_SCORE",
displayName: "Strain score",
archetype: "passthrough-reframe",
inputs: ["STRAIN_SCORE"],
minHistoryDays: 1,
minInputs: 1,
implemented: true,
},
};
/** Closed set of ids the generic route accepts (Zod enum source). */
+153
View File
@@ -0,0 +1,153 @@
/**
* v1.10.0 — passthrough read of the persisted nightly wellness scores
* (`RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE`).
*
* These three 0–100 composites are NOT recomputed here. A nightly job
* (`src/lib/jobs/{recovery,stress,strain}-score.ts`) writes them as
* `COMPUTED`-source Measurement rows; this engine simply reads the most
* recent persisted value plus a short trend so the SAME `Derived<T>`
* contract every other derived metric speaks also carries the scores. No
* surface ever recomputes — the dashboard, Coach, and doctor report all
* pattern-match the `Derived<WellnessScoreValue>` this returns.
*
* Server-only — reads `@/lib/db`.
*/
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { buildInsufficient, buildOk, nowProvenanceTimestamp } from "./coverage";
import type { BaselineProfile } from "./baseline";
import type { Derived } from "./types";
/** A 0–100 wellness score band. Higher is better for recovery; for stress a
* higher score is worse, so the band direction flips (see `WELLNESS_DIR`). */
export type WellnessScoreBand = "green" | "yellow" | "red";
export interface WellnessScoreValue {
/** The latest persisted 0–100 score. */
score: number;
band: WellnessScoreBand;
/** Score minus the trailing-window mean (excluding today), or null. */
trendDelta: number | null;
/** Distinct days with a score in the window — drives the trend confidence. */
daysInWindow: number;
/** ISO timestamp of the latest score's `measuredAt`. */
asOf: string;
}
/** The three persisted score types this engine serves. */
export const WELLNESS_SCORE_TYPES = {
RECOVERY_SCORE: "RECOVERY_SCORE",
STRESS_SCORE: "STRESS_SCORE",
STRAIN_SCORE: "STRAIN_SCORE",
} as const;
export type WellnessScoreType = keyof typeof WELLNESS_SCORE_TYPES;
/** `true` when a higher score is the healthier direction. Stress + strain
* invert (more = worse / harder), so the band flips for them. */
const HIGHER_IS_BETTER: Record<WellnessScoreType, boolean> = {
RECOVERY_SCORE: true,
STRESS_SCORE: false,
STRAIN_SCORE: false,
};
/** Band a 0–100 score honouring the metric's direction. */
export function bandWellnessScore(
type: WellnessScoreType,
score: number,
): WellnessScoreBand {
const good = HIGHER_IS_BETTER[type]
? score
: // Invert for stress/strain so 80 stress reads red, not green.
100 - score;
if (good >= 70) return "green";
if (good >= 40) return "yellow";
return "red";
}
export interface WellnessScoreOpts {
/** Trailing window for the trend mean (days). Defaults to 14. */
windowDays?: number;
now?: Date;
}
/**
* Read the latest persisted wellness score + a trailing trend. Returns
* `insufficient` with `reason: "no_score_in_window"` when the nightly job
* has not yet written one (e.g. a brand-new account, or no underlying
* signals) — never a fabricated value.
*/
export async function computeWellnessScore(
type: WellnessScoreType,
userId: string,
_profile: BaselineProfile,
opts: WellnessScoreOpts = {},
): Promise<Derived<WellnessScoreValue>> {
const now = opts.now ?? new Date();
const windowDays = opts.windowDays ?? 14;
const cutoff = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
const computedAt = nowProvenanceTimestamp(now);
const measurementType = WELLNESS_SCORE_TYPES[type] as MeasurementType;
const rows = await prisma.measurement.findMany({
where: {
userId,
type: measurementType,
source: "COMPUTED",
deletedAt: null,
measuredAt: { gte: cutoff, lte: now },
},
select: { value: true, measuredAt: true },
orderBy: { measuredAt: "desc" },
});
if (rows.length === 0) {
return buildInsufficient<WellnessScoreValue>({
coverage: {
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: [type],
},
provenance: { inputs: [type], source: "none", windowDays, computedAt },
reason: "no_score_in_window",
});
}
const latest = rows[0];
const score = Math.round(latest.value);
// Trend = latest vs the mean of the prior rows in the window.
const prior = rows.slice(1);
const trendDelta =
prior.length > 0
? Math.round(
score - prior.reduce((s, r) => s + r.value, 0) / prior.length,
)
: null;
return buildOk<WellnessScoreValue>({
value: {
score,
band: bandWellnessScore(type, score),
trendDelta,
daysInWindow: rows.length,
asOf: latest.measuredAt.toISOString(),
},
coverage: {
requiredInputs: 1,
presentInputs: 1,
historyDays: rows.length,
missing: [],
},
// The score is a persisted, already-computed composite — high
// confidence by construction; the trailing-day count carries the
// trend's strength via the coverage meter rather than the band.
confidence: { score: 90, band: "high" },
provenance: {
inputs: [type],
source: "DAY",
windowDays,
computedAt,
},
});
}