mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
feat(insights): wire the score-card tension + return-to-baseline and the briefing recall
The hero score card and the daily briefing accept tension, returnToBand, and memory props, but nothing populated them server-side, so the lines never rendered. Resolve all three server-authoritatively on the dashboard snapshot DTO (iOS rides the same payload): - tension — fires when the readiness composite's contributors disagree (a strongly favourable one alongside a strongly unfavourable one), reusing the readiness components the wellness ring grades and the shared disagreement detector. Suppressed under a clinical red-flag (a coincident-deviation that no illness explains) so the red-flag path is never reconciled into a calm verdict. - returnToBand — runs the streak detector across the salient vitals and surfaces at most one metric that has come back inside its own personal range after a genuine prior out-of-band run. - memory — maps the machine-derived coach memory block to the briefing's recall + forward-look. The blocks are locale-agnostic (contributor keys + a metric type); the insights hero localises them through the existing readiness-contributor and metric-name labels before handing the cards their resolved strings. Each value omits rather than guesses. The snapshot cache key now carries the locale for the recall prose.
This commit is contained in:
1 parent
6c1a4b7f45
commit
4d05bcf8d9
7 files changed
+971
-3
No files matched your search
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* v1.21.2 (A5 / A6) — unit pins for the score-card narrative resolver.
|
||||
*
|
||||
* The PROSE is not tested; the FLAGS the card renders are:
|
||||
* - tension fires on a DISAGREEING readiness component set and carries the
|
||||
* contributor KEYS partitioned onto the right side,
|
||||
* - tension is SUPPRESSED (null) under the clinical-floors override (the
|
||||
* coincident-deviation flag fired WITHOUT an illness explanation),
|
||||
* - tension stays QUIET on a coherent component set,
|
||||
* - returnToBand surfaces exactly ONE returned metric (the most salient) and
|
||||
* omits when no salient metric returned.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
const computeReadiness = vi.fn();
|
||||
const computeCoincidentDeviation = vi.fn();
|
||||
const readDayMeanSeries = vi.fn();
|
||||
|
||||
vi.mock("@/lib/insights/derived", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/insights/derived")>();
|
||||
return {
|
||||
...actual,
|
||||
computeReadiness: (...a: unknown[]) => computeReadiness(...a),
|
||||
computeCoincidentDeviation: (...a: unknown[]) =>
|
||||
computeCoincidentDeviation(...a),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/insights/derived/baseline", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/insights/derived/baseline")>();
|
||||
return {
|
||||
...actual,
|
||||
readDayMeanSeries: (...a: unknown[]) => readDayMeanSeries(...a),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/rollups/measurement-coverage", () => ({
|
||||
probeRollupCoverage: vi.fn(async () => new Map<string, boolean>()),
|
||||
}));
|
||||
|
||||
import { buildScoreNarrativeBlock } from "@/lib/dashboard/score-narrative";
|
||||
|
||||
/** A readiness `ok` result with the given component values. */
|
||||
function readiness(
|
||||
components: Array<{ key: string; value: number | null }>,
|
||||
band = "yellow",
|
||||
) {
|
||||
return {
|
||||
status: "ok" as const,
|
||||
value: {
|
||||
score: 60,
|
||||
band,
|
||||
components: components.map((c) => ({ ...c, weight: 0.2 })),
|
||||
},
|
||||
confidence: { score: 80 },
|
||||
coverage: { historyDays: 30 },
|
||||
};
|
||||
}
|
||||
|
||||
/** A coincident-deviation `ok` result. */
|
||||
function coincident(fired: boolean, illnessExplained = false) {
|
||||
return {
|
||||
status: "ok" as const,
|
||||
value: {
|
||||
fired,
|
||||
contributing: [],
|
||||
day: "2026-06-27",
|
||||
illnessExplained,
|
||||
},
|
||||
confidence: { score: 80 },
|
||||
coverage: { historyDays: 30 },
|
||||
};
|
||||
}
|
||||
|
||||
const PROFILE = { ageYears: 40, sex: "MALE" as const, heightCm: 180 };
|
||||
const NOW = new Date("2026-06-27T08:00:00Z");
|
||||
const COVERAGE = new Map<string, boolean>();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default: no metric series → no return-to-baseline.
|
||||
readDayMeanSeries.mockResolvedValue({ points: [], source: "none" });
|
||||
computeCoincidentDeviation.mockResolvedValue(coincident(false));
|
||||
});
|
||||
|
||||
describe("buildScoreNarrativeBlock — tension (A5)", () => {
|
||||
it("fires on a disagreeing component set and partitions the keys", async () => {
|
||||
// sleep strongly favourable (>= 70), resting pulse strongly unfavourable
|
||||
// (<= 45) — a genuine internal disagreement.
|
||||
computeReadiness.mockResolvedValue(
|
||||
readiness(
|
||||
[
|
||||
{ key: "sleep", value: 85 },
|
||||
{ key: "rhr", value: 30 },
|
||||
{ key: "hrv", value: 60 }, // neutral — neither side
|
||||
],
|
||||
"yellow",
|
||||
),
|
||||
);
|
||||
|
||||
const block = await buildScoreNarrativeBlock(
|
||||
"u1",
|
||||
PROFILE,
|
||||
NOW,
|
||||
"Europe/Berlin",
|
||||
COVERAGE,
|
||||
);
|
||||
|
||||
expect(block.tension).toEqual({
|
||||
band: "yellow",
|
||||
positive: ["sleep"],
|
||||
negative: ["rhr"],
|
||||
});
|
||||
});
|
||||
|
||||
it("is suppressed under the clinical-floors override", async () => {
|
||||
computeReadiness.mockResolvedValue(
|
||||
readiness([
|
||||
{ key: "sleep", value: 85 },
|
||||
{ key: "rhr", value: 30 },
|
||||
]),
|
||||
);
|
||||
// A real clinical red-flag: coincident-deviation fired WITHOUT an illness
|
||||
// explanation. The verdict must be suppressed so the red-flag dominates.
|
||||
computeCoincidentDeviation.mockResolvedValue(coincident(true, false));
|
||||
|
||||
const block = await buildScoreNarrativeBlock(
|
||||
"u1",
|
||||
PROFILE,
|
||||
NOW,
|
||||
"Europe/Berlin",
|
||||
COVERAGE,
|
||||
);
|
||||
|
||||
expect(block.tension).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT suppress when the deviation is illness-explained", async () => {
|
||||
computeReadiness.mockResolvedValue(
|
||||
readiness([
|
||||
{ key: "sleep", value: 85 },
|
||||
{ key: "rhr", value: 30 },
|
||||
]),
|
||||
);
|
||||
// Fired, but an illness episode explains it → not a clinical red-flag.
|
||||
computeCoincidentDeviation.mockResolvedValue(coincident(true, true));
|
||||
|
||||
const block = await buildScoreNarrativeBlock(
|
||||
"u1",
|
||||
PROFILE,
|
||||
NOW,
|
||||
"Europe/Berlin",
|
||||
COVERAGE,
|
||||
);
|
||||
|
||||
expect(block.tension).not.toBeNull();
|
||||
expect(block.tension?.positive).toContain("sleep");
|
||||
expect(block.tension?.negative).toContain("rhr");
|
||||
});
|
||||
|
||||
it("stays quiet on a coherent component set", async () => {
|
||||
// All contributors favourable — nothing to reconcile.
|
||||
computeReadiness.mockResolvedValue(
|
||||
readiness([
|
||||
{ key: "sleep", value: 85 },
|
||||
{ key: "rhr", value: 80 },
|
||||
{ key: "hrv", value: 75 },
|
||||
]),
|
||||
);
|
||||
|
||||
const block = await buildScoreNarrativeBlock(
|
||||
"u1",
|
||||
PROFILE,
|
||||
NOW,
|
||||
"Europe/Berlin",
|
||||
COVERAGE,
|
||||
);
|
||||
|
||||
expect(block.tension).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildScoreNarrativeBlock — returnToBand (A6)", () => {
|
||||
beforeEach(() => {
|
||||
// A coherent readiness so the tension branch contributes nothing.
|
||||
computeReadiness.mockResolvedValue(
|
||||
readiness([
|
||||
{ key: "sleep", value: 80 },
|
||||
{ key: "rhr", value: 80 },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
/** A consecutive per-day-mean series starting 2026-06-01. */
|
||||
function series(values: number[]) {
|
||||
return {
|
||||
points: values.map((mean, i) => ({
|
||||
day: `2026-06-${String(1 + i).padStart(2, "0")}`,
|
||||
mean,
|
||||
})),
|
||||
source: "live" as const,
|
||||
};
|
||||
}
|
||||
|
||||
it("surfaces exactly one returned metric", async () => {
|
||||
// RESTING_HEART_RATE: a prior out-of-band run (high), then back inside its
|
||||
// own personal band → a return-to-baseline event. Other types: flat in-band
|
||||
// (no return).
|
||||
readDayMeanSeries.mockImplementation(
|
||||
async (_u: string, type: MeasurementType) => {
|
||||
if (type === "RESTING_HEART_RATE") {
|
||||
// 8 days near 58, then 4 days spiking to ~80 (out high), then 4 days
|
||||
// back near 58 (returned). The MAD band is built over the whole
|
||||
// series; the recent return is the event.
|
||||
return series([
|
||||
58, 59, 57, 58, 60, 58, 59, 57, 80, 82, 81, 83, 58, 57, 59, 58,
|
||||
]);
|
||||
}
|
||||
return { points: [], source: "none" as const };
|
||||
},
|
||||
);
|
||||
|
||||
const block = await buildScoreNarrativeBlock(
|
||||
"u1",
|
||||
PROFILE,
|
||||
NOW,
|
||||
"Europe/Berlin",
|
||||
COVERAGE,
|
||||
);
|
||||
|
||||
expect(block.returnToBand).not.toBeNull();
|
||||
expect(block.returnToBand?.metricType).toBe("RESTING_HEART_RATE");
|
||||
expect(block.returnToBand?.daysInside).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("omits when no salient metric returned", async () => {
|
||||
// Every type flat in-band → no prior out-of-band run, no return.
|
||||
readDayMeanSeries.mockResolvedValue(
|
||||
series([60, 61, 59, 60, 60, 61, 59, 60]),
|
||||
);
|
||||
|
||||
const block = await buildScoreNarrativeBlock(
|
||||
"u1",
|
||||
PROFILE,
|
||||
NOW,
|
||||
"Europe/Berlin",
|
||||
COVERAGE,
|
||||
);
|
||||
|
||||
expect(block.returnToBand).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -303,6 +303,11 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => {
|
||||
band: "yellow",
|
||||
delta: 3,
|
||||
restMode: null,
|
||||
// v1.21.2 (A5 / A6) — additive narrative fields, null absent a
|
||||
// disagreeing readiness set / a returned metric (the fake derived
|
||||
// engines resolve nothing here).
|
||||
tension: null,
|
||||
returnToBand: null,
|
||||
});
|
||||
// v1.17 W1b — two runs (current + prior-week), identical to the analytics
|
||||
// route, so the ring's delta reflects BP movement.
|
||||
@@ -439,6 +444,9 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => {
|
||||
band: "yellow",
|
||||
delta: -12,
|
||||
restMode: null,
|
||||
// v1.21.2 (A5 / A6) — additive narrative fields, null here.
|
||||
tension: null,
|
||||
returnToBand: null,
|
||||
});
|
||||
|
||||
// The fast path reuses the BP windows + coverage map already
|
||||
@@ -1029,3 +1037,133 @@ describe("buildDashboardSnapshot — module gating (v1.18.0)", () => {
|
||||
expect(widget(snap, "mood")!.visible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// v1.21.2 (A4 / A5 / A6) — the ambient-narrative wire. The Tension Verdict +
|
||||
// return-to-baseline ride the warm-phase healthScore; the briefing recall +
|
||||
// forward-look rides `briefingMemory`. The resolvers themselves are pinned in
|
||||
// `score-narrative.test.ts`; here only the THREADING through the snapshot DTO is
|
||||
// asserted, via the injectable builders.
|
||||
describe("buildDashboardSnapshot — ambient narrative (A4/A5/A6)", () => {
|
||||
/** Warm coverage + a non-null health score so the narrative branch runs. */
|
||||
function warmScore() {
|
||||
probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", true]]));
|
||||
isFullyCovered.mockReturnValue(true);
|
||||
computeBpInTargetFastPath.mockResolvedValue({
|
||||
last7Days: { pct: 70 },
|
||||
last30Days: { pct: 80 },
|
||||
last90Days: { pct: 80, pairs: 20 },
|
||||
last90EarliestAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||
allTime: { pct: 75 },
|
||||
priorMonth: { pct: 60 },
|
||||
priorYear: { pct: 50 },
|
||||
});
|
||||
computeUserHealthScoreFastPath.mockResolvedValue({
|
||||
score: 71,
|
||||
band: "yellow",
|
||||
delta: 3,
|
||||
});
|
||||
}
|
||||
|
||||
it("folds tension + returnToBand onto the health score", async () => {
|
||||
warmScore();
|
||||
const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), {
|
||||
scoreNarrative: vi.fn(async () => ({
|
||||
tension: {
|
||||
band: "yellow" as const,
|
||||
positive: ["sleep" as const],
|
||||
negative: ["rhr" as const],
|
||||
},
|
||||
returnToBand: {
|
||||
metricType: "RESTING_HEART_RATE" as const,
|
||||
daysInside: 4,
|
||||
},
|
||||
})),
|
||||
});
|
||||
expect(snap.healthScore!.tension).toEqual({
|
||||
band: "yellow",
|
||||
positive: ["sleep"],
|
||||
negative: ["rhr"],
|
||||
});
|
||||
expect(snap.healthScore!.returnToBand).toEqual({
|
||||
metricType: "RESTING_HEART_RATE",
|
||||
daysInside: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves tension + returnToBand null when the resolver yields nothing", async () => {
|
||||
warmScore();
|
||||
const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), {
|
||||
scoreNarrative: vi.fn(async () => ({
|
||||
tension: null,
|
||||
returnToBand: null,
|
||||
})),
|
||||
});
|
||||
expect(snap.healthScore!.tension).toBeNull();
|
||||
expect(snap.healthScore!.returnToBand).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT run the score narrative when no health score rendered", async () => {
|
||||
// No warm coverage → healthScore null → the narrative branch is skipped.
|
||||
probeRollupCoverage.mockResolvedValue(new Map());
|
||||
const scoreNarrative = vi.fn(async () => ({
|
||||
tension: null,
|
||||
returnToBand: null,
|
||||
}));
|
||||
const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), {
|
||||
scoreNarrative,
|
||||
});
|
||||
expect(snap.healthScore).toBeNull();
|
||||
expect(scoreNarrative).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("threads the briefing recall + forward-look through briefingMemory", async () => {
|
||||
warmScore();
|
||||
const snap = await buildDashboardSnapshot(
|
||||
fakePrisma,
|
||||
baseUser({
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify({
|
||||
dailyBriefing: { greeting: "x", paragraph: "y", keyFindings: [] },
|
||||
}),
|
||||
}),
|
||||
{
|
||||
locale: "en",
|
||||
coachMemory: vi.fn(async () => ({
|
||||
priorNarrative: {
|
||||
headline: "Your resting heart rate drifted up early last month.",
|
||||
drivers: [],
|
||||
},
|
||||
trendMemory: {
|
||||
RESTING_HEART_RATE: {
|
||||
priorBand: "in" as const,
|
||||
currentBand: "above" as const,
|
||||
priorPeriod: "month" as const,
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
);
|
||||
expect(snap.briefingMemory).not.toBeNull();
|
||||
expect(snap.briefingMemory!.recall).toContain("resting heart rate");
|
||||
// The forward-look points at the drifted metric (or a holding line); either
|
||||
// way it is a non-empty localised string.
|
||||
expect(snap.briefingMemory!.forward.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("leaves briefingMemory null when no prior narrative is on file", async () => {
|
||||
warmScore();
|
||||
const snap = await buildDashboardSnapshot(
|
||||
fakePrisma,
|
||||
baseUser({
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify({
|
||||
dailyBriefing: { greeting: "x", paragraph: "y", keyFindings: [] },
|
||||
}),
|
||||
}),
|
||||
{
|
||||
coachMemory: vi.fn(async () => ({ trendMemory: {} })),
|
||||
},
|
||||
);
|
||||
expect(snap.briefingMemory).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* v1.21.2 (A5 / A6) — the score-card narrative block for the dashboard /
|
||||
* insights hero.
|
||||
*
|
||||
* Two server-resolved, DTO-only signals the `HealthScoreCard` renders but never
|
||||
* recomputes:
|
||||
*
|
||||
* - `tension` (A5) — the honest "internal read" when the readiness composite's
|
||||
* contributors DISAGREE (good sleep but a rising resting pulse, …). Reuses
|
||||
* the SAME readiness components the wellness ring grades + the SAME
|
||||
* `deriveTension` disagreement detector the Coach snapshot uses, so the card
|
||||
* and the Coach never narrate two different verdicts. Clinical-floors
|
||||
* override: when a real clinical red-flag is in play (the coincident-
|
||||
* deviation flag fired WITHOUT an illness explanation) the tension is
|
||||
* SUPPRESSED (`null`) so the red-flag path dominates — a red-flag is never
|
||||
* reconciled away into a calm verdict.
|
||||
*
|
||||
* - `returnToBand` (A6) — present only when a salient metric has come BACK
|
||||
* inside the user's OWN personal range after a prior out-of-band run. Runs
|
||||
* the pure `detectStreak` engine across the salient vitals and surfaces AT
|
||||
* MOST ONE (the most salient return). Actively closes a worry rather than
|
||||
* reporting a number.
|
||||
*
|
||||
* Both omit (return `null`) rather than guess: tension stays quiet on a coherent
|
||||
* day, `returnToBand` requires a genuine prior out-of-band run. The block is
|
||||
* LOCALE-AGNOSTIC — it emits contributor KEYS + a metric TYPE; the client
|
||||
* localises them through the existing readiness-contributor + metric-name i18n
|
||||
* keys before handing the card its already-localised strings. iOS rides the same
|
||||
* DTO and resolves its own labels.
|
||||
*
|
||||
* Server-only — reads `@/lib/db` transitively through the derived engines.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import {
|
||||
computeReadiness,
|
||||
computeCoincidentDeviation,
|
||||
isDerivedOk,
|
||||
type BaselineProfile,
|
||||
type ReadinessComponentKey,
|
||||
} from "@/lib/insights/derived";
|
||||
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
|
||||
import {
|
||||
deriveTension,
|
||||
TENSION_HIGH_SCORE,
|
||||
TENSION_LOW_SCORE,
|
||||
} from "@/lib/ai/coach/derived-snapshot";
|
||||
import { detectStreak, type StreakPoint } from "@/lib/insights/streak-detector";
|
||||
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
|
||||
|
||||
/**
|
||||
* The Tension Verdict in its locale-agnostic DTO shape. `band` is the readiness
|
||||
* composite's band; `positive` / `negative` carry the readiness contributor
|
||||
* KEYS (`rhr` / `hrv` / `sleep` / `respiratory` / `mood`) so the client maps
|
||||
* each to its localised display label. Null when the contributors agree (no
|
||||
* tension) or when a clinical red-flag suppresses it.
|
||||
*/
|
||||
export interface ScoreTensionDto {
|
||||
band: "green" | "yellow" | "red";
|
||||
positive: ReadinessComponentKey[];
|
||||
negative: ReadinessComponentKey[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The return-to-baseline event in its locale-agnostic DTO shape. `metricType`
|
||||
* is the `MeasurementType` the client maps to its localised metric name;
|
||||
* `daysInside` is how long the metric has now sat back inside its personal range.
|
||||
* Null when no salient metric has returned from a genuine prior out-of-band run.
|
||||
*/
|
||||
export interface ScoreReturnToBandDto {
|
||||
metricType: MeasurementType;
|
||||
daysInside: number;
|
||||
}
|
||||
|
||||
export interface ScoreNarrativeBlock {
|
||||
tension: ScoreTensionDto | null;
|
||||
returnToBand: ScoreReturnToBandDto | null;
|
||||
}
|
||||
|
||||
/** The deviation-from-baseline vitals worth a return-to-baseline surface. */
|
||||
const RETURN_SALIENT_TYPES: MeasurementType[] = [
|
||||
"RESTING_HEART_RATE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"RESPIRATORY_RATE",
|
||||
"WEIGHT",
|
||||
];
|
||||
|
||||
/** Trailing window the streak detector reads per metric (matches the band engine). */
|
||||
const RETURN_WINDOW_DAYS = 30;
|
||||
|
||||
/** A band is one of green / yellow / red. Narrow the readiness band string. */
|
||||
function narrowBand(
|
||||
band: string | undefined,
|
||||
): "green" | "yellow" | "red" | undefined {
|
||||
return band === "green" || band === "yellow" || band === "red"
|
||||
? band
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the score-card narrative block. Both signals are computed concurrently
|
||||
* and fail-soft to `null` so a transient read never sinks the snapshot.
|
||||
*
|
||||
* `coverage` is the per-request coverage map the caller already probed (the
|
||||
* snapshot probes it once up front); when omitted the helper probes itself so
|
||||
* it is independently callable.
|
||||
*/
|
||||
export async function buildScoreNarrativeBlock(
|
||||
userId: string,
|
||||
profile: BaselineProfile,
|
||||
now: Date,
|
||||
tz: string,
|
||||
coverage?: Awaited<ReturnType<typeof probeRollupCoverage>>,
|
||||
): Promise<ScoreNarrativeBlock> {
|
||||
// `readDayMeanSeries` (the streak series source) requires a coverage map, so
|
||||
// probe once here when the caller did not supply one. A probe failure leaves
|
||||
// `returnToBand` quiet rather than guessing.
|
||||
const cov = coverage ?? (await probeRollupCoverage(userId).catch(() => null));
|
||||
|
||||
const [tension, returnToBand] = await Promise.all([
|
||||
buildTension(userId, profile, now, tz, cov).catch(() => null),
|
||||
cov
|
||||
? buildReturnToBand(userId, now, cov).catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
return { tension, returnToBand };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Tension Verdict from the readiness contributors + the clinical
|
||||
* override. Reuses `computeReadiness` (the same engine the wellness ring grades)
|
||||
* and the shared `deriveTension` detector; the clinical override fires when the
|
||||
* coincident-deviation flag fired WITHOUT an illness explanation.
|
||||
*/
|
||||
async function buildTension(
|
||||
userId: string,
|
||||
profile: BaselineProfile,
|
||||
now: Date,
|
||||
tz: string,
|
||||
coverage: Awaited<ReturnType<typeof probeRollupCoverage>> | null,
|
||||
): Promise<ScoreTensionDto | null> {
|
||||
const readinessOpts = {
|
||||
now,
|
||||
tz,
|
||||
...(coverage ? { coverage } : {}),
|
||||
};
|
||||
// The coincident-deviation flag carries the clinical-override bit. Fail-soft
|
||||
// to "no override" so a baseline hiccup never fabricates a red-flag suppress.
|
||||
const [readiness, coincident] = await Promise.all([
|
||||
computeReadiness(userId, profile, readinessOpts),
|
||||
computeCoincidentDeviation(userId, profile, {
|
||||
now,
|
||||
...(tz ? { tz } : {}),
|
||||
}).catch(() => null),
|
||||
]);
|
||||
|
||||
if (!isDerivedOk(readiness)) return null;
|
||||
|
||||
const components = readiness.value.components;
|
||||
const band = narrowBand(readiness.value.band);
|
||||
|
||||
const coincidentFired =
|
||||
coincident !== null && isDerivedOk(coincident) && coincident.value.fired;
|
||||
// A real clinical red-flag is in play when the coincident-deviation flag fired
|
||||
// WITHOUT an illness explanation.
|
||||
const clinicalOverride =
|
||||
coincidentFired && !coincident.value.illnessExplained;
|
||||
|
||||
// CLINICAL-FLOORS OVERRIDE — when a real red-flag is in play the card must NOT
|
||||
// render a calm reconciled tension verdict: suppress it (`null`) so the
|
||||
// red-flag path dominates. A red-flag is never reconciled away.
|
||||
if (clinicalOverride) return null;
|
||||
|
||||
// Reuse the shared `deriveTension` detector for the authoritative fire
|
||||
// decision (same disagreement thresholds the Coach narrates). It emits ENGLISH
|
||||
// display labels; we consume only its DECISION and re-derive the contributor
|
||||
// KEY lists from the SAME components against the SAME exported thresholds, so
|
||||
// the client can localise. `clinicalOverride: false` here — the suppress above
|
||||
// already handled the red-flag case.
|
||||
const decision = deriveTension(
|
||||
components.map((c) => ({ key: c.key, value: c.value })),
|
||||
band,
|
||||
false,
|
||||
);
|
||||
if (!decision) return null;
|
||||
|
||||
const positive: ReadinessComponentKey[] = [];
|
||||
const negative: ReadinessComponentKey[] = [];
|
||||
for (const c of components) {
|
||||
if (c.value === null) continue;
|
||||
if (c.value >= TENSION_HIGH_SCORE) positive.push(c.key);
|
||||
else if (c.value <= TENSION_LOW_SCORE) negative.push(c.key);
|
||||
}
|
||||
|
||||
return {
|
||||
band: narrowBand(decision.band) ?? "yellow",
|
||||
positive,
|
||||
negative,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the return-to-baseline detector across the salient vitals and surface AT
|
||||
* MOST ONE — the most salient return. Salience ranks the longer prior
|
||||
* out-of-band run first (the bigger worry to close), then the longer in-band
|
||||
* run, then the canonical metric order as a deterministic tie-break.
|
||||
*/
|
||||
async function buildReturnToBand(
|
||||
userId: string,
|
||||
now: Date,
|
||||
coverage: Awaited<ReturnType<typeof probeRollupCoverage>>,
|
||||
): Promise<ScoreReturnToBandDto | null> {
|
||||
const candidates = await Promise.all(
|
||||
RETURN_SALIENT_TYPES.map(async (type, order) => {
|
||||
try {
|
||||
const { points } = await readDayMeanSeries(
|
||||
userId,
|
||||
type,
|
||||
RETURN_WINDOW_DAYS,
|
||||
now,
|
||||
coverage,
|
||||
);
|
||||
if (points.length === 0) return null;
|
||||
const series: StreakPoint[] = points.map((p) => ({
|
||||
day: p.day,
|
||||
value: p.mean,
|
||||
}));
|
||||
const result = detectStreak(series);
|
||||
if (!result.returnEvent) return null;
|
||||
return {
|
||||
type,
|
||||
order,
|
||||
daysInside: result.returnEvent.daysInside,
|
||||
priorDaysOutside: result.returnEvent.priorDaysOutside,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const returned = candidates.filter(
|
||||
(c): c is NonNullable<typeof c> => c !== null,
|
||||
);
|
||||
if (returned.length === 0) return null;
|
||||
|
||||
returned.sort(
|
||||
(a, b) =>
|
||||
b.priorDaysOutside - a.priorDaysOutside ||
|
||||
b.daysInside - a.daysInside ||
|
||||
a.order - b.order,
|
||||
);
|
||||
const top = returned[0];
|
||||
return { metricType: top.type, daysInside: top.daysInside };
|
||||
}
|
||||
@@ -84,6 +84,18 @@ import type {
|
||||
} from "@/lib/analytics/health-score";
|
||||
import { resolveRestMode } from "@/lib/illness/rest-mode";
|
||||
import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate";
|
||||
import {
|
||||
buildScoreNarrativeBlock,
|
||||
type ScoreTensionDto,
|
||||
type ScoreReturnToBandDto,
|
||||
} from "@/lib/dashboard/score-narrative";
|
||||
import {
|
||||
buildCoachMemoryBlock,
|
||||
type CoachMemoryBlock,
|
||||
} from "@/lib/ai/coach/memory-snapshot";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
|
||||
|
||||
/** Briefing freshness window — mirrors the 24 h TTL on the advisor cache. */
|
||||
const BRIEFING_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -305,6 +317,36 @@ export interface DashboardSnapshotHealthScore {
|
||||
* builder always sets it (null when not in Rest Mode).
|
||||
*/
|
||||
restMode?: RestModeAnnotation | null;
|
||||
/**
|
||||
* v1.21.2 (A5) — Tension Verdict, locale-agnostic. Fires only when the
|
||||
* readiness composite's contributors DISAGREE (≥1 strongly favourable AND ≥1
|
||||
* strongly unfavourable); suppressed (`null`) under a clinical red-flag so the
|
||||
* red-flag path dominates. `positive` / `negative` carry the readiness
|
||||
* contributor KEYS — the client maps each to its localised display label
|
||||
* before handing the card its already-localised strings. Null on a coherent
|
||||
* day. Optional (additive contract) so older cached snapshots stay valid.
|
||||
*/
|
||||
tension?: ScoreTensionDto | null;
|
||||
/**
|
||||
* v1.21.2 (A6) — return-to-baseline, locale-agnostic. Present only when a
|
||||
* salient metric has come BACK inside the user's own personal range after a
|
||||
* prior out-of-band run; at most one (the most salient). `metricType` is the
|
||||
* `MeasurementType` the client maps to its localised metric name. Null
|
||||
* otherwise. Optional (additive contract).
|
||||
*/
|
||||
returnToBand?: ScoreReturnToBandDto | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.21.2 (A4) — the briefing recall + forward-look. Both strings are
|
||||
* already-localised, server-resolved prose: `recall` is the prior period's
|
||||
* narrative headline; `forward` points ahead to the most salient trend drift
|
||||
* (or a calm "holding steady" when nothing drifted). The card renders them
|
||||
* verbatim. Null when no prior narrative is on file.
|
||||
*/
|
||||
export interface DashboardSnapshotBriefingMemory {
|
||||
recall: string;
|
||||
forward: string;
|
||||
}
|
||||
|
||||
export interface DashboardSnapshot {
|
||||
@@ -360,6 +402,13 @@ export interface DashboardSnapshot {
|
||||
*/
|
||||
healthScore: DashboardSnapshotHealthScore | null;
|
||||
briefing: DailyBriefing | null;
|
||||
/**
|
||||
* v1.21.2 (A4) — server-resolved recall + forward-look for the briefing card.
|
||||
* Null when no prior narrative is on file. Rides the snapshot DTO so iOS reads
|
||||
* the same already-localised block. Optional on the type (additive contract)
|
||||
* so older cached snapshots / fixtures without it stay valid.
|
||||
*/
|
||||
briefingMemory?: DashboardSnapshotBriefingMemory | null;
|
||||
briefingState: BriefingState;
|
||||
briefingUpdatedAt: string | null;
|
||||
/**
|
||||
@@ -864,6 +913,76 @@ function gateSummariesByModules(
|
||||
return { summaries: outSummaries, lastSeenByType: outLastSeen };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.21.2 (A4) — map the machine-derived coach memory block onto the briefing
|
||||
* card's `{ recall, forward }` shape, both already-localised. `recall` is the
|
||||
* prior period's narrative headline (locale-generated prose). `forward` points
|
||||
* ahead from the single most salient trend drift (a metric whose current-period
|
||||
* center has moved OUT of its prior personal band), or a calm "holding steady"
|
||||
* line when nothing drifted. Returns `null` when there is no prior narrative to
|
||||
* recall — silence is the default, never a fabricated callback.
|
||||
*
|
||||
* Localised through the server translator + the EXISTING metric-name keys
|
||||
* (`measurements.type*`). Two NEW forward-look template keys
|
||||
* (`insights.briefing.memory.forwardWatch` / `forwardHolding`) are reported to
|
||||
* the orchestrator; they degrade to the raw key string until the bundle lands.
|
||||
*/
|
||||
function mapBriefingMemory(
|
||||
block: CoachMemoryBlock | null,
|
||||
locale: Locale,
|
||||
): DashboardSnapshotBriefingMemory | null {
|
||||
if (!block?.priorNarrative) return null;
|
||||
const recall = block.priorNarrative.headline.trim();
|
||||
if (recall.length === 0) return null;
|
||||
|
||||
const { t } = getServerTranslator(locale);
|
||||
|
||||
// The most salient drift: a trend metric whose current-period center sits
|
||||
// OUT of its prior band. Deterministic — first in canonical map order. When
|
||||
// none drifted, the forward-look is the calm "holding steady" line.
|
||||
const driftType = Object.keys(block.trendMemory).find(
|
||||
(type) => block.trendMemory[type]?.currentBand !== "in",
|
||||
);
|
||||
let forward: string;
|
||||
if (driftType) {
|
||||
const metricLabel = localisedMeasurementLabel(driftType, t);
|
||||
forward = t("insights.briefing.memory.forwardWatch", {
|
||||
metric: metricLabel,
|
||||
});
|
||||
} else {
|
||||
forward = t("insights.briefing.memory.forwardHolding");
|
||||
}
|
||||
return { recall, forward };
|
||||
}
|
||||
|
||||
/**
|
||||
* Localised display name for a `MeasurementType`, via the existing
|
||||
* `measurements.type*` keys (reused — no new metric-name keys). Falls back to
|
||||
* the prettified raw type when no key resolves so a new type never blanks.
|
||||
*/
|
||||
function localisedMeasurementLabel(
|
||||
type: string,
|
||||
t: (key: string, vars?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
// `WALKING_RUNNING_DISTANCE` → `measurements.typeWalkingRunningDistance`.
|
||||
const camel = type
|
||||
.toLowerCase()
|
||||
.split("_")
|
||||
.map((part, i) =>
|
||||
i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1),
|
||||
)
|
||||
.join("");
|
||||
const key = `measurements.type${camel.charAt(0).toUpperCase()}${camel.slice(1)}`;
|
||||
const resolved = t(key);
|
||||
// The server translator echoes the key on a miss; fall back to a readable form.
|
||||
return resolved === key ? type.replace(/_/g, " ").toLowerCase() : resolved;
|
||||
}
|
||||
|
||||
/** Narrow a 6-locale `Locale` to the de/en the narrative pipeline generates. */
|
||||
function narrativeLocale(locale: Locale): "de" | "en" {
|
||||
return locale === "de" ? "de" : "en";
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the full snapshot in ONE `Promise.all`. Every sub-read is
|
||||
* timed via the optional `time` wrapper so a regression is attributable
|
||||
@@ -889,6 +1008,25 @@ export async function buildDashboardSnapshot(
|
||||
* the client.
|
||||
*/
|
||||
modules?: () => Promise<Record<ModuleKey, boolean>>;
|
||||
/**
|
||||
* v1.21.2 — active locale for the A4 briefing memory prose. Defaults to
|
||||
* English; the route resolves it from the request (cookie / header / user
|
||||
* preference). The recall headline is read locale-specific; non-de locales
|
||||
* fall back to the English narrative read inside `buildCoachMemoryBlock`.
|
||||
*/
|
||||
locale?: Locale;
|
||||
/**
|
||||
* v1.21.2 — injectable narrative-memory builder so the snapshot tests can
|
||||
* exercise the A4 wire without a narrative row. Defaults to the real
|
||||
* `buildCoachMemoryBlock`.
|
||||
*/
|
||||
coachMemory?: typeof buildCoachMemoryBlock;
|
||||
/**
|
||||
* v1.21.2 — injectable score-narrative builder (A5 tension + A6
|
||||
* return-to-baseline) so the snapshot tests can drive the wire without the
|
||||
* derived engines. Defaults to the real `buildScoreNarrativeBlock`.
|
||||
*/
|
||||
scoreNarrative?: typeof buildScoreNarrativeBlock;
|
||||
} = {},
|
||||
): Promise<DashboardSnapshot> {
|
||||
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
@@ -968,6 +1106,50 @@ export async function buildDashboardSnapshot(
|
||||
options.hasProvider ?? (() => hasAnyConfiguredProvider(user.id)),
|
||||
);
|
||||
|
||||
// v1.21.2 (A4 / A5 / A6) — the score-card narrative (Tension Verdict +
|
||||
// return-to-baseline) and the briefing recall + forward-look. Gated like the
|
||||
// briefing on the `insights` module so a narrative-off account carries none.
|
||||
// Both are fail-soft — a transient derived-engine read resolves to null and
|
||||
// never sinks the snapshot. The narrative block rides the warm phase's
|
||||
// healthScore; memory rides the briefing card. The locale defaults to English.
|
||||
const locale = options.locale ?? "en";
|
||||
const narrativeSex: "MALE" | "FEMALE" | null =
|
||||
user.gender === "MALE" || user.gender === "FEMALE" ? user.gender : null;
|
||||
const narrativeProfile = {
|
||||
ageYears: getAgeFromDateOfBirth(user.dateOfBirth),
|
||||
sex: narrativeSex,
|
||||
heightCm: user.heightCm ?? null,
|
||||
};
|
||||
// The Tension Verdict only adds signal once a health score actually rendered
|
||||
// (a coherent ring with no contributors is nothing to reconcile); memory only
|
||||
// matters once a briefing is shown.
|
||||
const narrativeEnabled =
|
||||
modules.insights !== false && extrasResult?.healthScore != null;
|
||||
const [scoreNarrative, coachMemoryBlock] = await Promise.all([
|
||||
narrativeEnabled
|
||||
? time("scoreNarrative", () =>
|
||||
(options.scoreNarrative ?? buildScoreNarrativeBlock)(
|
||||
user.id,
|
||||
narrativeProfile,
|
||||
now,
|
||||
userTz,
|
||||
coverage,
|
||||
),
|
||||
).catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
briefing.briefing !== null && modules.insights !== false
|
||||
? time("coachMemory", () =>
|
||||
(options.coachMemory ?? buildCoachMemoryBlock)(
|
||||
user.id,
|
||||
narrativeProfile,
|
||||
now,
|
||||
narrativeLocale(locale),
|
||||
),
|
||||
).catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
const briefingMemory = mapBriefingMemory(coachMemoryBlock, locale);
|
||||
|
||||
const gender =
|
||||
user.gender === "MALE" || user.gender === "FEMALE" ? user.gender : null;
|
||||
|
||||
@@ -1000,8 +1182,19 @@ export async function buildDashboardSnapshot(
|
||||
},
|
||||
extras: extrasResult?.extras ?? null,
|
||||
medsToday,
|
||||
healthScore: extrasResult?.healthScore ?? null,
|
||||
// v1.21.2 (A5 / A6) — fold the score-card narrative onto the health score so
|
||||
// the hero reads tension + return-to-baseline off the resolved score DTO.
|
||||
healthScore: extrasResult?.healthScore
|
||||
? {
|
||||
...extrasResult.healthScore,
|
||||
tension: scoreNarrative?.tension ?? null,
|
||||
returnToBand: scoreNarrative?.returnToBand ?? null,
|
||||
}
|
||||
: null,
|
||||
briefing: briefing.briefing,
|
||||
// v1.21.2 (A4) — the briefing recall + forward-look. Null when there is no
|
||||
// prior narrative on file or no briefing is shown.
|
||||
briefingMemory: briefing.briefing !== null ? briefingMemory : null,
|
||||
briefingState: briefing.briefingState,
|
||||
briefingUpdatedAt: briefing.briefingUpdatedAt,
|
||||
briefingStale: briefing.briefingStale,
|
||||
|
||||
Reference in new issue
Block a user