diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8fbe742ce..63590ac05 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/src/lib/insights/derived/__tests__/dispatch.test.ts b/src/lib/insights/derived/__tests__/dispatch.test.ts index 2f595b115..8e0c1b987 100644 --- a/src/lib/insights/derived/__tests__/dispatch.test.ts +++ b/src/lib/insights/derived/__tests__/dispatch.test.ts @@ -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); }); }); diff --git a/src/lib/insights/derived/__tests__/wellness-scores.test.ts b/src/lib/insights/derived/__tests__/wellness-scores.test.ts new file mode 100644 index 000000000..438b67f9c --- /dev/null +++ b/src/lib/insights/derived/__tests__/wellness-scores.test.ts @@ -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; + +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(); + } + }); +}); diff --git a/src/lib/insights/derived/dispatch.ts b/src/lib/insights/derived/dispatch.ts index d09346df2..a5080a56a 100644 --- a/src/lib/insights/derived/dispatch.ts +++ b/src/lib/insights/derived/dispatch.ts @@ -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>; + case "RECOVERY_SCORE": + case "STRESS_SCORE": + case "STRAIN_SCORE": + // Passthrough read of the nightly-persisted COMPUTED rows — never a + // recompute. The same `Derived` contract carries the scores. + return computeWellnessScore( + args.metric as WellnessScoreType, + args.userId, + args.profile, + { windowDays: args.windowDays, now }, + ) as Promise>; default: // Registered + implemented but no dispatch arm — treat as a stub. return notImplemented(meta.inputs.map(String), now); diff --git a/src/lib/insights/derived/index.ts b/src/lib/insights/derived/index.ts index d0db86158..30807e1e4 100644 --- a/src/lib/insights/derived/index.ts +++ b/src/lib/insights/derived/index.ts @@ -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"; diff --git a/src/lib/insights/derived/registry.ts b/src/lib/insights/derived/registry.ts index e14c5a5b5..73620d5ef 100644 --- a/src/lib/insights/derived/registry.ts +++ b/src/lib/insights/derived/registry.ts @@ -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 = { 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). */ diff --git a/src/lib/insights/derived/wellness-scores.ts b/src/lib/insights/derived/wellness-scores.ts new file mode 100644 index 000000000..7aa7e460f --- /dev/null +++ b/src/lib/insights/derived/wellness-scores.ts @@ -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` + * 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` 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 = { + 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> { + 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({ + 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({ + 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, + }, + }); +}