mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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,
|
||||
|
||||
Reference in new issue
Block a user