merge: wave/v1212-wire (server→card wiring for tension/returnToBand/memory) into release/v1.21.2

This commit is contained in:
Marc-André Bombeck committed 2026-06-27 00:30:09 +02:00
commit ea1a0e2829
7 files changed
+971 -3

No files matched your search

+10 -2
View File
@@ -41,6 +41,7 @@ import {
type DashboardSnapshot,
type SnapshotUserInput,
} from "@/lib/dashboard/snapshot";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
export const dynamic = "force-dynamic";
@@ -89,6 +90,13 @@ export const GET = apiHandler(async () => {
dashboardWidgetsJson: user.dashboardWidgetsJson,
};
// v1.21.2 (A4) — the briefing recall + forward-look is locale-specific prose,
// so the snapshot cache key carries the resolved locale: an EN and a DE
// session never share a snapshot cell (and never see each other's memory
// wording). The `${user.id}|` prefix still covers the key under the
// measurement-write stale-sweep.
const locale = await resolveServerLocale({ userLocale: user.locale });
// Stale-while-revalidate: a measurement write marks the analytics
// bucket stale rather than hard-evicting the snapshot, so a busy iOS
// sync serves the prior snapshot immediately (within the bucket's
@@ -98,8 +106,8 @@ export const GET = apiHandler(async () => {
// miss + synchronous rebuild here.
const body = await cachedSwr(
caches.analytics as ServerCache<DashboardSnapshot>,
`${user.id}|dashboard-snapshot`,
() => buildDashboardSnapshot(prisma, snapshotUser, { time }),
`${user.id}|dashboard-snapshot|${locale}`,
() => buildDashboardSnapshot(prisma, snapshotUser, { time, locale }),
annotate,
SNAPSHOT_CACHE_TTL_MS,
);
+24
View File
@@ -25,6 +25,7 @@ import { cn } from "@/lib/utils";
import { HeroStrip } from "@/components/insights/hero-strip";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
import { useDashboardDerived } from "@/components/insights/derived/use-dashboard-derived";
// v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as
// `InsightsAnalyticsData`; aliased back to the local name to keep the
@@ -275,6 +276,20 @@ export default function InsightsPage() {
const analyticsQuery = useAnalyticsQuery();
const analytics = analyticsQuery.data as AnalyticsData | undefined;
// v1.21.2 (A4 / A5 / A6) — the dashboard snapshot is the single server source
// for the ambient narrative the hero + briefing surface: the Tension Verdict
// and return-to-baseline ride its `healthScore`, the briefing recall +
// forward-look rides `briefingMemory`. The hero score number itself stays on
// the analytics payload (unchanged); only these three additive, server-
// resolved DTO fields are lifted from the snapshot. Reads the same shared
// `queryKeys.dashboardSnapshot()` cell the dashboard warms, so the insights
// visit pays at most one extra round-trip and usually a warm cache hit.
const snapshotQuery = useDashboardSnapshot(isAuthenticated);
const snapshotHealthScore = snapshotQuery.data?.healthScore ?? null;
const heroTension = snapshotHealthScore?.tension ?? null;
const heroReturnToBand = snapshotHealthScore?.returnToBand ?? null;
const briefingMemory = snapshotQuery.data?.briefingMemory ?? null;
// v1.12.6 — the page owns the ONE overview derived batch. The wellness
// strip (above the briefing) and the vitals grid (below it) both read this
// single query instance, so the wellness lift adds no second request.
@@ -432,6 +447,10 @@ export default function InsightsPage() {
// briefing is actually rendered (the empty-state path owns the
// `noProvider` branch above).
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
// v1.21.2 (A4) — server-resolved recall + forward-look off the dashboard
// snapshot. Both strings are already localised; null leaves the card's
// memory block unrendered.
memory={briefingMemory}
/>
) : null,
vitals: <VitalsDashboard batch={dashboardDerived} layout={layout} />,
@@ -491,6 +510,11 @@ export default function InsightsPage() {
// <relative>" line with a discreet connect-provider hint, matching
// the briefing card footer. Only when a briefing is actually shown.
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
// v1.21.2 (A5 / A6) — server-resolved Tension Verdict + return-to-
// baseline off the dashboard snapshot. The hero localises the keys and
// forwards them to the score card; null leaves the card quiet.
tension={heroTension}
returnToBand={heroReturnToBand}
/>
{/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the
+97
View File
@@ -10,9 +10,23 @@ import { cn } from "@/lib/utils";
import {
HealthScoreCard,
type HealthScoreCardProps,
type HealthScoreBand,
} from "./health-score-card";
import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
/**
* v1.21.2 (A5) — readiness contributor keys the Tension Verdict surfaces. Kept
* as a local string union so the client never imports the server derived-engine
* types. Localised through the existing
* `insights.derived.composite.READINESS.component.{key}` labels.
*/
export type ReadinessContributorKey =
| "rhr"
| "hrv"
| "sleep"
| "respiratory"
| "mood";
/**
* Insights redesign hero strip.
*
@@ -78,6 +92,31 @@ interface HeroStripProps {
*/
restMode?: { active: boolean } | null;
} | null;
/**
* v1.21.2 (A5) — Tension Verdict, server-resolved + locale-agnostic. `band`
* is the readiness composite's band; `positive` / `negative` carry the
* readiness contributor KEYS (`rhr` / `hrv` / `sleep` / `respiratory` /
* `mood`). The hero localises each key through the existing
* `insights.derived.composite.READINESS.component.*` labels before handing the
* score card its already-localised strings. Null on a coherent day (or under a
* clinical red-flag suppress). The score card carries it only when the score
* is present.
*/
tension?: {
band: HealthScoreBand;
positive: ReadinessContributorKey[];
negative: ReadinessContributorKey[];
} | null;
/**
* v1.21.2 (A6) — return-to-baseline, server-resolved + locale-agnostic.
* `metricType` is a `MeasurementType` the hero maps to its localised metric
* name (the existing `measurements.type*` keys). Null when no salient metric
* returned from a genuine prior out-of-band run.
*/
returnToBand?: {
metricType: string;
daysInside: number;
} | null;
/**
* v1.16.8 — true while the analytics payload that carries
* `healthScore` is still in flight. The right column then reserves the
@@ -111,6 +150,32 @@ function resolveGreetingKey(now: Date): string {
return "insights.heroGreetingEvening";
}
/**
* v1.21.2 (A5) — readiness contributor key → existing localised label key.
* Reuses the score-anatomy contributor labels so the Tension Verdict reads the
* same names the wellness ring's anatomy view renders — no new keys.
*/
const TENSION_CONTRIBUTOR_LABEL_KEY: Record<ReadinessContributorKey, string> = {
rhr: "insights.derived.composite.READINESS.component.rhr",
hrv: "insights.derived.composite.READINESS.component.hrv",
sleep: "insights.derived.composite.READINESS.component.sleep",
respiratory: "insights.derived.composite.READINESS.component.respiratory",
mood: "insights.derived.composite.READINESS.component.mood",
};
/**
* v1.21.2 (A6) — `MeasurementType` → existing localised metric-name key
* (`measurements.type*`). Only the salient deviation vitals the
* return-to-baseline detector scans need an entry; an unmapped type falls back
* to a prettified raw form so a new type never blanks.
*/
const RETURN_METRIC_LABEL_KEY: Record<string, string> = {
RESTING_HEART_RATE: "measurements.typeRestingHeartRate",
HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability",
RESPIRATORY_RATE: "measurements.typeRespiratoryRate",
WEIGHT: "measurements.typeWeight",
};
export function HeroStrip({
briefing,
updatedAt,
@@ -119,8 +184,38 @@ export function HeroStrip({
healthScore,
healthScorePending = false,
noProviderStale = false,
tension = null,
returnToBand = null,
}: HeroStripProps) {
const { t } = useTranslations();
// v1.21.2 (A5) — localise the Tension Verdict's contributor keys into the
// card's already-localised `{ positive, negative }` shape. Only forwarded when
// a real disagreement is present on BOTH sides; the resolver already enforces
// that, but the guard keeps the card from rendering a one-sided line.
const tensionProp =
tension && tension.positive.length > 0 && tension.negative.length > 0
? {
band: tension.band,
positive: tension.positive.map((k) =>
t(TENSION_CONTRIBUTOR_LABEL_KEY[k]),
),
negative: tension.negative.map((k) =>
t(TENSION_CONTRIBUTOR_LABEL_KEY[k]),
),
}
: null;
// v1.21.2 (A6) — localise the return-to-baseline metric into the card's
// already-localised `metricLabel`.
const returnToBandProp = returnToBand
? {
metricLabel: RETURN_METRIC_LABEL_KEY[returnToBand.metricType]
? t(RETURN_METRIC_LABEL_KEY[returnToBand.metricType])
: returnToBand.metricType.replace(/_/g, " ").toLowerCase(),
daysInside: returnToBand.daysInside,
}
: null;
// v1.18.0 R4 — mood-module state so the score card hides its Mood row
// when the account turned the module off (the server already drops the
// pillar from the number itself). SSR-safe + default-on, matching the
@@ -291,6 +386,8 @@ export function HeroStrip({
delta={healthScore.delta}
moodEnabled={moodEnabled}
restModeActive={healthScore.restMode?.active ?? false}
tension={tensionProp}
returnToBand={returnToBandProp}
/>
)}
</div>
@@ -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();
});
});
+255
View File
@@ -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 };
}
+194 -1
View File
@@ -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,