mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +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
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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