mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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.
256 lines
9.2 KiB
TypeScript
256 lines
9.2 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|