feat(insights): surface the active STRAIN anchor per score

Carry the per-day STRAIN anchor (personal vs population) from the
strain_trimp_cache row through to the STRAIN WellnessScoreValue read, and
pick the framing line by it on the score-anatomy surface: relative to your
own typical effort once anchored personally, a general reference during
cold start. The method copy was describing both regimes generically and
could not tell the user which one produced this score. Additive read field
— iOS non-breaking.
This commit is contained in:
Marc-André Bombeck committed 2026-06-03 21:33:06 +02:00
1 parent a116848276
commit 097af3b84a
9 files changed
+127 -7

No files matched your search

@@ -73,6 +73,29 @@ export function CompositeScoreAnatomy({
const meta = METRIC_PROVENANCE[metric];
const standard = meta.standard;
const title = titleFor(metric, t);
// STRAIN frames itself by the ACTUAL anchor that produced this score: once
// the user has enough history it is judged against their own typical effort
// (`personal`); during cold start it falls back to a general reference
// (`population`). The base method copy stays anchor-neutral; this line makes
// the displayed framing honest per-score. `null` (RECOVERY / STRESS, or no
// cache row yet) renders nothing.
let anchorLine: string | null = null;
if (
metric === "STRAIN_SCORE" &&
data?.status === "ok" &&
data.value
) {
const anchor = (data.value as WellnessScoreValue).anchor;
if (anchor === "personal") {
anchorLine = t("insights.derived.composite.STRAIN_SCORE.anchorPersonal");
} else if (anchor === "population") {
anchorLine = t(
"insights.derived.composite.STRAIN_SCORE.anchorPopulation",
);
}
}
// Method copy carries an optional honesty caveat above it (STRESS proxy,
// descriptive-not-clinical, …) so the caveat reaches the user, not just
// the engine header.
@@ -84,6 +107,7 @@ export function CompositeScoreAnatomy({
</span>
) : null}
{t(meta.methodKey)}
{anchorLine ? <span className="mt-1 block">{anchorLine}</span> : null}
</>
);
@@ -1,7 +1,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: { measurement: { findMany: vi.fn() } },
prisma: {
measurement: { findMany: vi.fn() },
strainTrimpCache: { findUnique: vi.fn() },
},
}));
import { prisma } from "@/lib/db";
@@ -14,9 +17,13 @@ import {
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>;
const cacheFindUnique = prisma.strainTrimpCache
.findUnique as ReturnType<typeof vi.fn>;
beforeEach(() => {
findMany.mockReset();
cacheFindUnique.mockReset();
cacheFindUnique.mockResolvedValue(null);
});
describe("bandWellnessScore", () => {
@@ -79,4 +86,51 @@ describe("computeWellnessScore", () => {
expect((r.value as WellnessScoreValue).trendDelta).toBeNull();
}
});
it("STRAIN carries the active anchor from the day's cache row", async () => {
findMany.mockResolvedValue([
{ value: 55, measuredAt: new Date("2026-06-01T12:00:00Z") },
]);
cacheFindUnique.mockResolvedValue({ anchor: "personal" });
const r = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, {
now: NOW,
});
// The cache is keyed by the scored day (the latest score's day key).
expect(cacheFindUnique).toHaveBeenCalledWith({
where: { userId_day: { userId: "u1", day: "2026-06-01" } },
select: { anchor: true },
});
expect(r.status).toBe("ok");
if (r.status === "ok") {
expect((r.value as WellnessScoreValue).anchor).toBe("personal");
}
});
it("STRAIN anchor is null when no cache row exists", async () => {
findMany.mockResolvedValue([
{ value: 55, measuredAt: new Date("2026-06-01T12:00:00Z") },
]);
cacheFindUnique.mockResolvedValue(null);
const r = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, {
now: NOW,
});
expect(r.status).toBe("ok");
if (r.status === "ok") {
expect((r.value as WellnessScoreValue).anchor).toBeNull();
}
});
it("RECOVERY does not read the strain cache and carries a null anchor", async () => {
findMany.mockResolvedValue([
{ value: 72, measuredAt: new Date("2026-06-01T12:00:00Z") },
]);
const r = await computeWellnessScore("RECOVERY_SCORE", "u1", PROFILE, {
now: NOW,
});
expect(cacheFindUnique).not.toHaveBeenCalled();
expect(r.status).toBe("ok");
if (r.status === "ok") {
expect((r.value as WellnessScoreValue).anchor).toBeNull();
}
});
});
@@ -16,6 +16,7 @@ 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 { StrainAnchor } from "@/lib/insights/strain-score";
import { SPARKLINE_MAX_POINTS, type Derived } from "./types";
/** A 0–100 wellness score band. Higher is better for recovery; for stress a
@@ -38,6 +39,16 @@ export interface WellnessScoreValue {
* query.
*/
series: number[];
/**
* STRAIN only — which anchor produced THIS score: `personal` once the user
* has enough training history to be judged against their own typical effort,
* `population` during cold start. Read from the latest `strain_trimp_cache`
* row for the scored day so the UI can show the framing line that actually
* applies, not a generic both-regimes blurb. `null` for RECOVERY / STRESS
* (no anchor concept) or when no cache row exists yet (additive — iOS
* non-breaking).
*/
anchor?: StrainAnchor | null;
}
/** The three persisted score types this engine serves. */
@@ -131,6 +142,24 @@ export async function computeWellnessScore(
)
: null;
// STRAIN carries the active anchor for the scored day so the UI shows the
// framing line that actually produced THIS score (personal-relative vs the
// cold-start population reference). The `strain_trimp_cache` row is keyed by
// the same day stamp the score row carries (`scoreDayKey` → noon-UTC
// `measuredAt`), so the latest score's day key is the cache key. Source of
// truth is the cache row, not a re-derivation here.
let anchor: StrainAnchor | null = null;
if (type === "STRAIN_SCORE") {
const day = latest.measuredAt.toISOString().slice(0, 10);
const cache = await prisma.strainTrimpCache.findUnique({
where: { userId_day: { userId, day } },
select: { anchor: true },
});
if (cache?.anchor === "personal" || cache?.anchor === "population") {
anchor = cache.anchor;
}
}
return buildOk<WellnessScoreValue>({
value: {
score,
@@ -143,6 +172,7 @@ export async function computeWellnessScore(
.slice(0, SPARKLINE_MAX_POINTS)
.map((r) => r.value)
.reverse(),
anchor,
},
coverage: {
requiredInputs: 1,