mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
02c50acd46
commit
a8daef7376
7 files changed
+308
-2
No files matched your search
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user