diff --git a/src/app/api/dashboard/snapshot/route.ts b/src/app/api/dashboard/snapshot/route.ts index 5d5654052..edc86ed69 100644 --- a/src/app/api/dashboard/snapshot/route.ts +++ b/src/app/api/dashboard/snapshot/route.ts @@ -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, - `${user.id}|dashboard-snapshot`, - () => buildDashboardSnapshot(prisma, snapshotUser, { time }), + `${user.id}|dashboard-snapshot|${locale}`, + () => buildDashboardSnapshot(prisma, snapshotUser, { time, locale }), annotate, SNAPSHOT_CACHE_TTL_MS, ); diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 0f3a51e94..000d31c73 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -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: , @@ -491,6 +510,11 @@ export default function InsightsPage() { // " 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 diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index 63557622a..99a03f3f2 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -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 = { + 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 = { + 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} /> )} diff --git a/src/lib/dashboard/__tests__/score-narrative.test.ts b/src/lib/dashboard/__tests__/score-narrative.test.ts new file mode 100644 index 000000000..b8bd1052b --- /dev/null +++ b/src/lib/dashboard/__tests__/score-narrative.test.ts @@ -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(); + return { + ...actual, + computeReadiness: (...a: unknown[]) => computeReadiness(...a), + computeCoincidentDeviation: (...a: unknown[]) => + computeCoincidentDeviation(...a), + }; +}); +vi.mock("@/lib/insights/derived/baseline", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + readDayMeanSeries: (...a: unknown[]) => readDayMeanSeries(...a), + }; +}); +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: vi.fn(async () => new Map()), +})); + +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(); + +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(); + }); +}); diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index b458beb6a..72799e24f 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -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(); + }); +}); diff --git a/src/lib/dashboard/score-narrative.ts b/src/lib/dashboard/score-narrative.ts new file mode 100644 index 000000000..5cd582570 --- /dev/null +++ b/src/lib/dashboard/score-narrative.ts @@ -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>, +): Promise { + // `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> | null, +): Promise { + 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>, +): Promise { + 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 => 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 }; +} diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index cb83ce463..1c2ca209f 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -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 { + // `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>; + /** + * 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 { 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,