mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
merge: wave/v1212-wire (server→card wiring for tension/returnToBand/memory) into release/v1.21.2
This commit is contained in:
commit
ea1a0e2829
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