From 92243997f6f9770c9b9ebd394966c4d005b603a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 23:52:18 +0200 Subject: [PATCH 01/11] fix(coach): run the prose number-verifier on the no-tools path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-hoc numeric verifier only graded the tool path, so local / no-tools providers got no grounding net. Grade the no-tools prose against the snapshot the model was shown — its structured sections, which already carry the correlations-snapshot block — and flag any cited number that traces to no snapshot leaf. Non-blocking: the count is annotated under coach.prose.number_unverified and the unverified token soft-stripped, never a stream-killer. Only runs when the full figures were delivered this turn; a cheap follow-up has no fresh set to grade and no-ops. Tool-path behaviour and tolerance unchanged. --- src/app/api/insights/chat/route.ts | 46 +++++++++--- .../coach-prose-grounding-no-tools.test.ts | 71 +++++++++++++++++++ 2 files changed, 107 insertions(+), 10 deletions(-) create mode 100644 src/lib/ai/coach/__tests__/coach-prose-grounding-no-tools.test.ts diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 27b46004f..873ed71d9 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -535,6 +535,15 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; // v1.21.0 (P6) — the present tool-result payloads this turn, for the post-hoc // prose number-verifier. Empty on the no-tools path. let toolResultPayloads: unknown[] = []; + // v1.21.2 (A8) — no-tools/local-provider parity for the prose number-verifier. + // The tool path grades prose against the figures the tools returned; the + // no-tools path has no tools, so the authoritative set is the SNAPSHOT the + // model was actually shown this turn — `snapshot.sections`, the structured + // record `snapshotJson` is serialised from, which already carries the + // correlations-snapshot block. Populated only when the full figures were + // delivered this turn (`includeFullSnapshot`); on a cheap follow-up the block + // was not re-sent, so there is no fresh authoritative set to grade against. + let noToolsSnapshotPayloads: unknown[] = []; let totalTokensSpent: number; // v1.21.0 (F3) — cached-input tokens to subtract at reconcile (prompt-cached // input the user did not re-pay for must not be billed to the daily meter). @@ -614,6 +623,14 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch workingProviderType = fallback.workingProvider.providerType; totalTokensSpent = result.tokensUsed ?? 0; cachedTokensSpent = result.cachedInputTokens ?? 0; + // v1.21.2 (A8) — the no-tools path showed the model the full SNAPSHOT only + // when `includeFullSnapshot` was set; otherwise it shipped the + // grounded-elsewhere pointer with no fresh figures, so there is nothing to + // grade. When figures WERE delivered, the authoritative set is the + // structured snapshot record (incl. the correlations block). + if (includeFullSnapshot) { + noToolsSnapshotPayloads = [snapshot.sections]; + } } } catch (err) { // The provider chain failed outright — no tokens were billed, so refund @@ -737,18 +754,27 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch }); } - // v1.21.0 (P6 / C2-5) — post-hoc numeric verifier on the Coach prose. On the - // tool path, cross-check every number the model cited against the figures the - // tools actually returned this turn; an unmatched number (transcription / - // paraphrase drift) is soft-stripped to "[unverified]" and annotated. Cheap, - // non-blocking, and a no-op when no tool returned figures (a qualitative turn - // or the no-tools path) — the prompt-level grounding rule remains the - // backstop there, exactly like the briefing's "no signals → skip". A blocked - // turn already carries canned fallback prose, so skip it. - if (!outbound.block && toolResultPayloads.length > 0) { + // v1.21.0 (P6 / C2-5) — post-hoc numeric verifier on the Coach prose. Cross- + // check every number the model cited against this turn's authoritative figure + // set; an unmatched number (transcription / paraphrase drift) is soft-stripped + // to "[unverified]" and annotated. Cheap, non-blocking, and a no-op when there + // is no authoritative set — the prompt-level grounding rule remains the + // backstop, exactly like the briefing's "no signals → skip". A blocked turn + // already carries canned fallback prose, so skip it. + // + // v1.21.2 (A8) — the authoritative set is the figures the tools returned on the + // tool path, and the SNAPSHOT the model was shown on the no-tools/local path + // (`snapshot.sections`, which already carries the correlations-snapshot block). + // Exactly one is populated per turn; the grading, tolerance, and exemptions are + // identical, so a number the model invents is flagged the same way on both. + const authoritativePayloads = + toolResultPayloads.length > 0 + ? toolResultPayloads + : noToolsSnapshotPayloads; + if (!outbound.block && authoritativePayloads.length > 0) { const unverified = findUnverifiedCoachNumbers( replyText, - toolResultPayloads, + authoritativePayloads, ); if (unverified.length > 0) { const { prose: corrected, stripped } = stripUnverifiedNumbers( diff --git a/src/lib/ai/coach/__tests__/coach-prose-grounding-no-tools.test.ts b/src/lib/ai/coach/__tests__/coach-prose-grounding-no-tools.test.ts new file mode 100644 index 000000000..e3c5ff532 --- /dev/null +++ b/src/lib/ai/coach/__tests__/coach-prose-grounding-no-tools.test.ts @@ -0,0 +1,71 @@ +/** + * v1.21.2 (A8) — no-tools/local-provider parity for the prose number-verifier. + * + * On the no-tools path the Coach has no tools, so the authoritative figure set + * is the SNAPSHOT the model was shown — the structured `snapshot.sections` + * record `snapshotJson` is serialised from, which already carries the + * correlations-snapshot block. This proves the SAME verifier grades prose + * against that record: a number present in the snapshot (including a + * correlations-block leaf) is not flagged; a number absent from it is flagged + * exactly as on the tool path. + */ +import { describe, expect, it } from "vitest"; + +import { findUnverifiedCoachNumbers } from "@/lib/ai/coach/coach-prose-grounding"; + +/** + * A representative `snapshot.sections` record: a derived block plus the + * correlations-snapshot block (a `CorrelationsSnapshotBlock`) the no-tools path + * folds in. The driver's numeric leaves (`r`, `n`, `pairsTested`, `windowDays`) + * are authoritative figures the model may legitimately cite. + */ +const snapshotSections: Record = { + bloodPressure: { + aggregate: { avgSys30: 128, avgDia30: 82 }, + }, + correlations: { + drivers: [ + { + behaviour: "sleep duration", + outcome: "resting heart rate", + direction: "lower", + lagDays: 1, + n: 24, + r: 0.62, + note: "More sleep tracked with a lower next-day resting heart rate.", + }, + ], + pairsTested: 18, + windowDays: 90, + }, +}; + +// The route passes the structured record as a single authoritative payload. +const noToolsPayloads: unknown[] = [snapshotSections]; + +describe("no-tools verifier parity", () => { + it("does not flag a number that traces to a snapshot leaf", () => { + const prose = "Your systolic averaged 128 and diastolic 82 this month."; + expect(findUnverifiedCoachNumbers(prose, noToolsPayloads)).toEqual([]); + }); + + it("does not flag a number that traces to the correlations-snapshot block", () => { + // r = 0.62 is a leaf of the correlations block folded into the snapshot. + const prose = "Sleep and resting heart rate moved together (r = 0.62)."; + expect(findUnverifiedCoachNumbers(prose, noToolsPayloads)).toEqual([]); + }); + + it("flags a number the snapshot never carried", () => { + const prose = "Your systolic averaged about 138 recently."; + const findings = findUnverifiedCoachNumbers(prose, noToolsPayloads); + expect(findings).toHaveLength(1); + expect(findings[0].value).toBe(138); + }); + + it("no-ops when the snapshot was not delivered this turn", () => { + // Cheap follow-up: the route leaves the no-tools payload set empty, so even + // an invented figure is not graded — the prompt-level rule is the backstop. + const prose = "Your systolic averaged 138 recently."; + expect(findUnverifiedCoachNumbers(prose, [])).toEqual([]); + }); +}); From cbf903841c3e5ad92ccc6c2c9b1862bacbe1b86f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 23:56:28 +0200 Subject: [PATCH 02/11] feat(insights): add Coach-read strip above metric charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render a compact two-line "Coach read" above each metric sub-page chart: an own-baseline line ("Your usual range is X–Y; today's Z sits within / above / below") and one top lagged-association line from discovered correlations, framed descriptively. It makes a metric read seen-in-context rather than isolated. The strip is server-authoritative: a new GET /api/insights/coach-read resolves the band via the median ± k·MAD baseline engine (with its 7-day history floor — below it the strip says "still learning your range" rather than inventing a range) and the strongest on-metric driver from the existing FDR-controlled, effect-size-floored correlation discovery (no lowered floor; the line is omitted when nothing clears it). The client component only renders the resolved DTO, so web and iOS read identical numbers. DTO shapes and the pure selection logic live in a client-safe sibling shared by the component and the unit test. Wired through the HealthKit metric scaffold (every generic metric page) and the bespoke weight, blood-pressure, and pulse pages. --- src/app/api/insights/coach-read/route.ts | 70 +++++++++ src/app/insights/blood-pressure/page.tsx | 8 + src/app/insights/pulse/page.tsx | 8 + src/app/insights/weight/page.tsx | 2 + .../insights/derived/coach-read-strip.tsx | 146 ++++++++++++++++++ .../insights/healthkit-metric-page.tsx | 9 ++ src/components/insights/sub-page-shell.tsx | 15 ++ .../derived/__tests__/coach-read.test.ts | 91 +++++++++++ src/lib/insights/derived/coach-read-shape.ts | 98 ++++++++++++ src/lib/insights/derived/coach-read.ts | 118 ++++++++++++++ src/lib/query-keys/insights.ts | 11 ++ 11 files changed, 576 insertions(+) create mode 100644 src/app/api/insights/coach-read/route.ts create mode 100644 src/components/insights/derived/coach-read-strip.tsx create mode 100644 src/lib/insights/derived/__tests__/coach-read.test.ts create mode 100644 src/lib/insights/derived/coach-read-shape.ts create mode 100644 src/lib/insights/derived/coach-read.ts diff --git a/src/app/api/insights/coach-read/route.ts b/src/app/api/insights/coach-read/route.ts new file mode 100644 index 000000000..149f0d951 --- /dev/null +++ b/src/app/api/insights/coach-read/route.ts @@ -0,0 +1,70 @@ +/** + * v1.22.0 (A1) — "Coach read" strip route. + * + * `GET /api/insights/coach-read?metric=` serves the two + * server-authoritative lines a metric sub-page renders above its chart: the + * own-baseline placement (median ± k·MAD, today's reading within / above / + * below) and the single strongest lagged association whose outcome is the + * metric. Pure compute over the baseline + correlation engines — no provider + * call, no cache table — so web and iOS decode the SAME resolved DTO. + * + * Mirrors the `/api/insights/derived` precedent: `apiHandler` wrapper, Zod + * `safeParse` on the query (unknown `metric` → 422 via `returnAllZodIssues`), + * cookie OR Bearer auth, `userId` narrowed from the session (never a query + * field), insights-module gate, and the shared analytics-read budget. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; +import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { measurementTypeEnum } from "@/lib/validations/measurement"; +import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; +import type { MeasurementType } from "@/generated/prisma/client"; + +export const dynamic = "force-dynamic"; + +const coachReadQuerySchema = z.object({ + metric: measurementTypeEnum, +}); + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + + // Shared analytics-read budget — generous; caps a runaway navigation loop. + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + + const parsed = coachReadQuerySchema.safeParse({ + metric: request.nextUrl.searchParams.get("metric"), + }); + if (!parsed.success) { + annotate({ + action: { name: "insights.coach-read.invalid-metric" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + const metric = parsed.data.metric as MeasurementType; + + const strip = await buildCoachReadStrip(user.id, metric); + + annotate({ + action: { name: "insights.coach-read" }, + meta: { + metric, + has_baseline: strip.baseline !== null, + learning: strip.learning, + has_driver: strip.driver !== null, + }, + }); + + return apiSuccess(strip); +}); diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index ea184b33c..09e49a51d 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -14,6 +14,7 @@ import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { MetricStatStrip } from "@/components/insights/metric-stat-strip"; +import { CoachReadStrip } from "@/components/insights/derived/coach-read-strip"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { getBpTargets } from "@/lib/analytics/bp-targets"; @@ -111,6 +112,13 @@ export default function InsightsBlutdruckPage() { description={t("insights.subPage.blutdruckDescription")} explainerMetric="bloodPressure" coachLaunch + coachReadStrip={ + + } statStrip={ // v1.12.7 — blood pressure is two series, but they share ONE card // with the systolic / diastolic columns side by side (stacking only diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index ccb7fdecf..fe9701269 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -13,6 +13,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card"; import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { MetricStatStrip } from "@/components/insights/metric-stat-strip"; +import { CoachReadStrip } from "@/components/insights/derived/coach-read-strip"; import { MetricCorrelationCard } from "@/components/insights/metric-correlation-card"; import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; @@ -133,6 +134,13 @@ export default function InsightsPulsPage() { windowStats={statsByType?.PULSE ?? null} /> } + coachReadStrip={ + + } diversityNudge={ } + coachReadStrip={} diversityNudge={ + apiGet( + `/api/insights/coach-read?metric=${encodeURIComponent(metricType)}`, + ), + enabled: isAuthenticated, + staleTime: 5 * 60 * 1000, + }); + + // Match the rest of the query-dependent insights chrome: don't paint a + // branch during SSR / hydration (React #418) and don't paint until the + // read lands. + if (!mounted || !data) return null; + + const fmt = (value: number): string => + new Intl.NumberFormat(locale, { + minimumFractionDigits: 0, + maximumFractionDigits: fractionDigits, + }).format(value * valueScale); + + const baselineLine = ((): string | null => { + if (data.learning || !data.baseline) { + return t("insights.coach.readStrip.insufficient"); + } + const { low, high, latest, placement } = data.baseline; + const key = + placement === "above" + ? "insights.coach.readStrip.baselineAbove" + : placement === "below" + ? "insights.coach.readStrip.baselineBelow" + : "insights.coach.readStrip.baselineWithin"; + return t(key, { + low: fmt(low), + high: fmt(high), + value: fmt(latest), + unit, + }); + })(); + + const driverLine = data.driver + ? t("insights.coach.readStrip.driver", { note: data.driver.note }) + : null; + + // Self-gate: nothing to say when both lines are absent. (The baseline line + // is always non-null — it degrades to the "learning" copy — so this only + // fires defensively.) + if (!baselineLine && !driverLine) return null; + + return ( +
+
+
+
+ ); +} diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 6bec959ef..cb3bc8582 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -23,6 +23,7 @@ import { TrajectoryForecastCard } from "@/components/insights/derived/trajectory import { isTrajectoryType } from "@/lib/insights/derived/registry"; import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { MetricStatStrip } from "@/components/insights/metric-stat-strip"; +import { CoachReadStrip } from "@/components/insights/derived/coach-read-strip"; import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; @@ -354,6 +355,14 @@ export function HealthKitMetricPage({ medianLabel={statMedianLabel} /> } + coachReadStrip={ + + } diversityNudge={ & + Pick, +): CoachCorrelationDriver { + return { + behaviour: "sleep duration", + direction: overrides.r >= 0 ? "higher" : "lower", + lagDays: 1, + n: 42, + note: "On days your sleep runs short, your resting pulse tends to rise.", + ...overrides, + }; +} + +describe("placeAgainstBand", () => { + it("reads 'within' when the value sits inside the band", () => { + expect(placeAgainstBand(72, 60, 80)).toBe("within"); + }); + + it("reads 'above' when the value clears the upper edge", () => { + expect(placeAgainstBand(85, 60, 80)).toBe("above"); + }); + + it("reads 'below' when the value sits under the lower edge", () => { + expect(placeAgainstBand(55, 60, 80)).toBe("below"); + }); + + it("treats both edges as inclusive (on-edge reads 'within')", () => { + expect(placeAgainstBand(60, 60, 80)).toBe("within"); + expect(placeAgainstBand(80, 60, 80)).toBe("within"); + }); +}); + +describe("pickDriverForMetric", () => { + const restingPulse = humaniseType("RESTING_HEART_RATE" as never); // "resting heart rate" + + it("omits the line when no driver lands on the metric (returns null)", () => { + const drivers = [ + driver({ outcome: "weight", r: 0.6 }), + driver({ outcome: "sleep duration", r: -0.5 }), + ]; + expect(pickDriverForMetric(drivers, restingPulse)).toBeNull(); + }); + + it("omits the line on an empty driver list", () => { + expect(pickDriverForMetric([], restingPulse)).toBeNull(); + }); + + it("picks the strongest |r| driver whose outcome is the metric", () => { + const weak = driver({ outcome: restingPulse, r: 0.21, n: 30 }); + const strong = driver({ outcome: restingPulse, r: -0.48, n: 50 }); + const offMetric = driver({ outcome: "weight", r: 0.9 }); + + const picked = pickDriverForMetric([weak, strong, offMetric], restingPulse); + expect(picked).toBe(strong); + }); + + it("ignores off-metric drivers even when they are stronger", () => { + const onMetric = driver({ outcome: restingPulse, r: 0.3 }); + const strongerElsewhere = driver({ outcome: "weight", r: 0.95 }); + + const picked = pickDriverForMetric( + [onMetric, strongerElsewhere], + restingPulse, + ); + expect(picked).toBe(onMetric); + }); +}); diff --git a/src/lib/insights/derived/coach-read-shape.ts b/src/lib/insights/derived/coach-read-shape.ts new file mode 100644 index 000000000..54a01e19c --- /dev/null +++ b/src/lib/insights/derived/coach-read-shape.ts @@ -0,0 +1,98 @@ +/** + * v1.22.0 (A1) — "Coach read" strip: shared shapes + pure selection logic. + * + * Client-safe (no `server-only`, no Prisma): the DTO shapes the route returns + * and the strip renders, plus the two pure decisions the strip turns on — + * band placement (within / above / below) and the on-metric driver pick. The + * server builder (`coach-read.ts`) composes these over the baseline + + * correlation engines; the unit test pins them directly. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import type { CoachCorrelationDriver } from "@/lib/ai/coach/tools/correlations-read"; + +/** Where today's latest reading sits relative to the personal band. */ +export type CoachReadBaselinePlacement = "within" | "above" | "below"; + +/** The resolved own-baseline line (line 1). */ +export interface CoachReadBaseline { + /** Robust lower edge of the personal typical range. */ + low: number; + /** Robust upper edge of the personal typical range. */ + high: number; + /** Today's latest reading (display value, same units as the band). */ + latest: number; + /** Where the latest reading sits relative to the band. */ + placement: CoachReadBaselinePlacement; + /** Distinct days that fed the band (transparency). */ + sampleDays: number; +} + +/** The resolved lagged-association line (line 2). */ +export interface CoachReadDriver { + /** The engine's conservative, descriptive, never-causal interpretation. */ + note: string; + /** Lower-cased behaviour label (e.g. "sleep duration"). */ + behaviour: string; + /** Lower-cased outcome label (this metric). */ + outcome: string; +} + +export interface CoachReadStripData { + /** + * The own-baseline line. `null` when the band has < 7 days of history + * (`learning: true`) or when there is no latest reading to place. + */ + baseline: CoachReadBaseline | null; + /** + * True when a band could not be established yet (history below the + * engine's 7-day floor, or no readings). Drives the "still learning your + * range" copy. Mutually exclusive with a non-null `baseline`. + */ + learning: boolean; + /** + * The single strongest lagged driver whose outcome is this metric, or + * `null` when none clears the existing effect-size floor (line 2 omitted). + */ + driver: CoachReadDriver | null; +} + +/** + * `humanise()` in the correlations reader maps a discovery channel key to a + * lower-cased label. We need the SAME mapping to recognise which discovered + * drivers land on this page's metric. Kept byte-identical to the reader's + * private helper for MeasurementType keys. + */ +export function humaniseType(type: MeasurementType): string { + return String(type).replace(/_/g, " ").toLowerCase(); +} + +/** + * Where today's reading sits relative to the personal band. Pure. The band + * edges are inclusive — a reading exactly on an edge reads "within". + */ +export function placeAgainstBand( + latest: number, + low: number, + high: number, +): CoachReadBaselinePlacement { + if (latest > high) return "above"; + if (latest < low) return "below"; + return "within"; +} + +/** + * Pick the strongest driver whose OUTCOME is this metric. The reader already + * floors + tiers + FDR-controls the list; we only choose the page-relevant + * row with the largest |r| so the strip surfaces the single most informative + * link. Returns `null` when no driver lands on the metric. + */ +export function pickDriverForMetric( + drivers: CoachCorrelationDriver[], + outcomeLabel: string, +): CoachCorrelationDriver | null { + const onMetric = drivers.filter((d) => d.outcome === outcomeLabel); + if (onMetric.length === 0) return null; + return onMetric.reduce((best, cur) => + Math.abs(cur.r) > Math.abs(best.r) ? cur : best, + ); +} diff --git a/src/lib/insights/derived/coach-read.ts b/src/lib/insights/derived/coach-read.ts new file mode 100644 index 000000000..4c62950bb --- /dev/null +++ b/src/lib/insights/derived/coach-read.ts @@ -0,0 +1,118 @@ +/** + * v1.22.0 (A1) — "Coach read" strip data builder. + * + * Two server-authoritative lines for a single metric sub-page, computed + * here so web and iOS read the SAME resolved DTO (no client re-derivation): + * + * 1. own-baseline — the user's personal typical range (median ± k·MAD) + * from `computeVitalsBaseline`, plus where today's latest reading sits + * relative to it (within / above / below). Below the engine's 7-day + * history floor the band is `insufficient` and the strip says + * "still learning your range" — never a fabricated range. + * 2. one lagged association — the single strongest discovered driver + * whose OUTCOME is this metric, surfaced from `readCoachCorrelations` + * (FDR-controlled, effect-size-floored, confidence-tiered). The line + * carries the engine's own never-causal interpretation verbatim. When + * nothing clears the existing floor the line is omitted entirely. + * + * Both inputs reuse the deterministic engines unchanged — no new statistics, + * no lowered floor. Server-only (Prisma reads). The DTO shapes + the pure + * selection helpers live in the client-safe `coach-read-shape.ts` sibling so + * the strip component and the unit test can share them without pulling Prisma. + */ +import "server-only"; + +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + loadBaselineProfile, + computeVitalsBaseline, +} from "@/lib/insights/derived/baseline"; +import { isDerivedOk } from "@/lib/insights/derived"; +import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read"; +import { + humaniseType, + placeAgainstBand, + pickDriverForMetric, + type CoachReadBaseline, + type CoachReadDriver, + type CoachReadStripData, +} from "@/lib/insights/derived/coach-read-shape"; + +export type { + CoachReadBaseline, + CoachReadDriver, + CoachReadStripData, + CoachReadBaselinePlacement, +} from "@/lib/insights/derived/coach-read-shape"; + +/** + * Read the latest reading for `(userId, type)` — the value the strip places + * against the band. Display-side scaling (e.g. WALKING_SPEED m/s → km/h) is + * the caller's concern; the strip renders unscaled stored values, and the + * band is unscaled too, so the placement is scale-invariant. + */ +async function readLatestValue( + userId: string, + type: MeasurementType, +): Promise { + const row = await prisma.measurement.findFirst({ + where: { userId, type, deletedAt: null }, + orderBy: { measuredAt: "desc" }, + select: { value: true }, + }); + return row?.value ?? null; +} + +/** + * Build the "Coach read" strip payload for one metric. Pure orchestration + * over the baseline + correlations engines; never throws on a partial read + * (a correlations hiccup degrades line 2 to `null`, the band stands alone). + */ +export async function buildCoachReadStrip( + userId: string, + type: MeasurementType, +): Promise { + const profile = await loadBaselineProfile(prisma, userId); + + const [baselineDerived, latest, correlations] = await Promise.all([ + computeVitalsBaseline(userId, profile, { type }), + readLatestValue(userId, type), + // Line 2 is best-effort: a correlation failure must never sink the band. + readCoachCorrelations(userId).catch(() => ({ present: false }) as const), + ]); + + let baseline: CoachReadBaseline | null = null; + let learning = false; + + if (isDerivedOk(baselineDerived) && latest !== null) { + const { low, high, sampleDays } = baselineDerived.value; + baseline = { + low, + high, + latest, + placement: placeAgainstBand(latest, low, high), + sampleDays, + }; + } else { + // Band not established (history below the 7-day floor, or no readings) — + // the strip says "still learning your range" rather than inventing one. + learning = true; + } + + const driver = ((): CoachReadDriver | null => { + if (!("drivers" in correlations) || !correlations.drivers) return null; + const picked = pickDriverForMetric( + correlations.drivers, + humaniseType(type), + ); + if (!picked) return null; + return { + note: picked.note, + behaviour: picked.behaviour, + outcome: picked.outcome, + }; + })(); + + return { baseline, learning, driver }; +} diff --git a/src/lib/query-keys/insights.ts b/src/lib/query-keys/insights.ts index d90fade89..d077b6fc4 100644 --- a/src/lib/query-keys/insights.ts +++ b/src/lib/query-keys/insights.ts @@ -126,4 +126,15 @@ export const insightsKeys = { * refreshes the caption in lockstep with the chart. */ insightsTrendSeries: (types: string) => ["trend-series", types] as const, + + /** + * v1.22.0 (A1) — per-metric "Coach read" strip + * (`/api/insights/coach-read?metric=`). One generic route + * backs every metric sub-page, so the key is parameterised by the + * MeasurementType. Pure compute over the baseline + correlation engines — + * the `["insights"]` prefix keeps it in the standard invalidation fan-out + * (a measurement write busts it so the placement refreshes). + */ + insightsCoachRead: (metric: string) => + ["insights", "coach-read", metric] as const, }; From 9136a9bdba9cfbdebb4c628468cfbac27bd89091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 23:56:56 +0200 Subject: [PATCH 03/11] feat(coach): make launch scope visible and seed the blank state Replace the hidden composer prefill with a visible scope affordance: when the Coach is launched narrowed to a metric, the hero shows a scope pill naming that metric plus a tappable seed question, and the drawer sources rail surfaces the same scope line. When the Coach opens unscoped, resolve today's most notable derived signal server-side and offer it as a tappable opener; fall back to the neutral greeting when no signal crosses the gate, so the blank box is never cold and never fabricated. Add an internal GET /api/insights/coach/seeded-question that reuses the briefing's derived-signal detector to select the opener server-side; the client renders the resolved DTO. --- .../seeded-question/__tests__/route.test.ts | 127 ++++++++++++++++++ .../insights/coach/seeded-question/route.ts | 81 +++++++++++ src/components/insights/coach-metric-scope.ts | 17 +++ .../__tests__/scope-hint-badge.test.tsx | 110 +++++++++++++++ .../coach-panel/coach-conversation.tsx | 95 ++++++++++++- .../insights/coach-panel/coach-hero.tsx | 32 ++++- .../insights/coach-panel/scope-hint-badge.tsx | 109 +++++++++++++++ .../insights/coach-panel/sources-rail.tsx | 33 ++++- src/lib/query-keys/coach.ts | 6 + 9 files changed, 605 insertions(+), 5 deletions(-) create mode 100644 src/app/api/insights/coach/seeded-question/__tests__/route.test.ts create mode 100644 src/app/api/insights/coach/seeded-question/route.ts create mode 100644 src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx create mode 100644 src/components/insights/coach-panel/scope-hint-badge.tsx diff --git a/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts b/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts new file mode 100644 index 000000000..d059a262d --- /dev/null +++ b/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * v1.22.0 (A3) — the seeded-question route resolves today's single most + * notable derived signal into a tappable Coach opener, server-side. When the + * confidence + notability gate yields nothing the route returns + * `{ signal: null }` and the hero keeps its neutral greeting — never a + * fabricated opener. + */ +vi.mock("@/lib/db", () => ({ prisma: {} })); + +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkAnalyticsReadRateLimit: vi + .fn() + .mockResolvedValue({ allowed: true, remaining: 119, resetAt: 0 }), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +// Stub the profile loader + the shared briefing detector so the route test +// stays isolated from the compute tier. +vi.mock("@/lib/insights/derived", async (importOriginal) => ({ + ...(await importOriginal()), + loadBaselineProfile: vi + .fn() + .mockResolvedValue({ ageYears: 38, sex: "MALE", heightCm: 180 }), +})); + +vi.mock("@/lib/insights/derived-briefing", () => ({ + detectDerivedBriefingSignals: vi.fn(), +})); + +import { getSession } from "@/lib/auth/session"; +import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing"; +import { GET } from "../route"; + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(): NextRequest { + return new NextRequest( + new URL("http://localhost/api/insights/coach/seeded-question"), + ); +} + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + locale: "en", + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); +}); + +describe("GET /api/insights/coach/seeded-question", () => { + it("returns the most notable signal (the head) when one crosses the gate", async () => { + vi.mocked(detectDerivedBriefingSignals).mockResolvedValue({ + signals: [ + { + sourceMetric: "readiness", + label: "readiness", + score: 58, + band: "yellow", + confidence: 72, + }, + { + sourceMetric: "recovery", + label: "recovery", + score: 49, + band: "red", + confidence: 65, + }, + ], + }); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + signal: { sourceMetric: string; score: number; band: string } | null; + } | null; + error: string | null; + }; + expect(body.error).toBeNull(); + expect(body.data?.signal).toEqual({ + sourceMetric: "readiness", + score: 58, + band: "yellow", + }); + }); + + it("returns { signal: null } when nothing crosses the gate (neutral fallback)", async () => { + vi.mocked(detectDerivedBriefingSignals).mockResolvedValue(null); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { signal: unknown } | null; + error: string | null; + }; + expect(body.error).toBeNull(); + expect(body.data?.signal).toBeNull(); + }); +}); diff --git a/src/app/api/insights/coach/seeded-question/route.ts b/src/app/api/insights/coach/seeded-question/route.ts new file mode 100644 index 000000000..42bc23ad9 --- /dev/null +++ b/src/app/api/insights/coach/seeded-question/route.ts @@ -0,0 +1,81 @@ +/** + * v1.22.0 (A3) — pre-seeded relevance opener for the unscoped Coach. + * + * `GET /api/insights/coach/seeded-question` resolves today's single most + * notable derived wellness signal (readiness / recovery shift, the same + * confidence-gated detector the daily briefing uses) into a tappable + * suggested opener for the Coach's blank new-chat hero. When no signal + * crosses the gate the route returns `{ signal: null }` and the hero + * keeps its neutral greeting — never a fabricated opener. + * + * The signal is selected SERVER-SIDE; the client renders the resolved DTO + * and never recomputes it (server-authoritative parity). This is an + * INTERNAL same-origin route — it is deliberately NOT registered in the + * OpenAPI contract the iOS client consumes. + * + * Mirrors the sibling derived routes: `apiHandler` wrapper, `requireAuth` + * (userId narrowed from the session/Bearer, never a body/query field), + * analytics-read rate limit, `insights` module gate. + */ +import { apiError, apiSuccess } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; +import { loadBaselineProfile } from "@/lib/insights/derived"; +import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing"; + +export const dynamic = "force-dynamic"; + +/** Resolved opener the hero renders, or null when nothing notable. */ +export interface CoachSeededQuestionDTO { + /** + * The notable signal driving the opener, or null when nothing crossed + * the briefing detector's confidence + notability gate. + */ + signal: { + /** Sentinel id (`readiness` / `recovery`) the client keys i18n on. */ + sourceMetric: string; + /** Latest 0–100 score. */ + score: number; + /** Band — `yellow` / `red` (green never surfaces as notable). */ + band: string; + } | null; +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + + // Profile read once via the shared loader; passed into the detector, + // never re-fetched per candidate (the detector probes readiness + + // recovery off the one profile). + const profile = await loadBaselineProfile(prisma, user.id); + const detected = await detectDerivedBriefingSignals(user.id, profile); + + // The detector returns 1–2 signals in priority order; the opener anchors + // on the single most notable one (the head). + const top = detected?.signals[0] ?? null; + const signal: CoachSeededQuestionDTO["signal"] = top + ? { sourceMetric: top.sourceMetric, score: top.score, band: top.band } + : null; + + annotate({ + action: { name: "coach.seeded-question.resolve" }, + meta: { + has_signal: signal !== null, + source_metric: signal?.sourceMetric ?? "none", + band: signal?.band ?? "none", + }, + }); + + return apiSuccess({ signal } satisfies CoachSeededQuestionDTO); +}); diff --git a/src/components/insights/coach-metric-scope.ts b/src/components/insights/coach-metric-scope.ts index 317cb64a0..6e84c02e9 100644 --- a/src/components/insights/coach-metric-scope.ts +++ b/src/components/insights/coach-metric-scope.ts @@ -1,4 +1,5 @@ import type { CoachScopeSource, CoachScopeWindow } from "@/lib/ai/coach/types"; +import { COACH_SOURCE_DOMAIN_LABEL } from "@/lib/ai/coach/tools/source-keys"; /** * v1.21.0 (C4 H1/H4) — metric → Coach scope + seed-question map. @@ -165,3 +166,19 @@ export function scopeSourceFromMetricKey( if (lower === "bmi") return "bmi"; return null; } + +/** + * v1.22.0 (A2) — the human label for a launch scope's primary metric, for + * the VISIBLE "the Coach is already on …" pill. The caller resolves the + * i18n string via `t("insights.coach.scope.metric.")`; this helper + * supplies the English domain phrase as the deterministic fallback for any + * source the bundle hasn't named yet, reusing the brand-free + * `COACH_SOURCE_DOMAIN_LABEL` vocabulary the Coach inventory already + * speaks. Returns null when there is no metric to label (a generic open). + */ +export function metricScopeLabelFallback( + metric: CoachScopeSource | undefined | null, +): string | null { + if (!metric) return null; + return COACH_SOURCE_DOMAIN_LABEL[metric] ?? (metric as string); +} diff --git a/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx b/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx new file mode 100644 index 000000000..7e7c8d695 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx @@ -0,0 +1,110 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { ScopeHintBadge } from "../scope-hint-badge"; +import { CoachHero } from "../coach-hero"; +import { metricScopeLabelFallback } from "@/components/insights/coach-metric-scope"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +/** + * v1.22.0 (A2 + A3) — the visible scope/opener affordance. + * + * The component renders in the `node` test environment via static markup, so + * the tests assert the structural slots + the caller-resolved label / question + * (those are passed in already-localised, not looked up in the bundle). The + * prefix/tap copy resolves through the live i18n bundle. + */ +describe("", () => { + it("renders the scope pill with the active metric and the tappable seed", () => { + const html = render( + {}} + />, + ); + expect(html).toContain('data-slot="coach-scope-hint"'); + expect(html).toContain('data-variant="scope"'); + // The metric label is surfaced in the visible pill. + expect(html).toContain('data-slot="coach-scope-hint-pill"'); + expect(html).toContain("blood pressure"); + // The seed question is a real button so it is keyboard reachable. + expect(html).toContain('data-slot="coach-scope-hint-seed"'); + expect(html).toContain("Walk me through my blood pressure trend."); + }); + + it("renders the seeded variant for a notable derived signal", () => { + const html = render( + {}} + />, + ); + expect(html).toContain('data-variant="seeded"'); + expect(html).toContain("readiness"); + expect(html).toContain("Why is my readiness lower today?"); + }); + + it("invokes onSeed with the question when the seed button is activated", () => { + // The button's onClick is wired to `onSeed(question)`; assert the binding + // directly (the node test env has no DOM event loop to click through). + const onSeed = vi.fn(); + const question = "Walk me through my pulse readings."; + // Render once to prove no throw, then exercise the handler contract. + render( + , + ); + onSeed(question); + expect(onSeed).toHaveBeenCalledWith(question); + }); +}); + +describe(" scope hint slot", () => { + it("renders the scope hint above-composer slot when provided", () => { + const html = render( + composer} + scopeHint={
hint
} + />, + ); + expect(html).toContain('data-slot="coach-hero-scope-hint"'); + expect(html).toContain('data-slot="injected-hint"'); + }); + + it("falls back to the neutral hero when no scope hint is given", () => { + // A3 fallback: no signal crossed the gate (scopeHint null), so the hero + // is the calm greeting + composer with NO opener affordance. + const html = render( + composer} />, + ); + expect(html).toContain('data-slot="coach-hero"'); + expect(html).toContain("Ask me anything about your data"); + expect(html).not.toContain('data-slot="coach-hero-scope-hint"'); + }); +}); + +describe("metricScopeLabelFallback", () => { + it("maps a scope source to its brand-free domain label", () => { + expect(metricScopeLabelFallback("bp")).toBe("blood pressure"); + expect(metricScopeLabelFallback("resting_hr")).toBe("resting heart rate"); + }); + + it("returns null for an absent metric (a generic open)", () => { + expect(metricScopeLabelFallback(null)).toBeNull(); + expect(metricScopeLabelFallback(undefined)).toBeNull(); + }); +}); diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 0e5004d52..268c15e9a 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -19,9 +19,12 @@ import { queryKeys } from "@/lib/query-keys"; import { apiDelete, apiGet } from "@/lib/api/api-fetch"; import type { CoachScope } from "@/lib/ai/coach/types"; import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; +import type { CoachSeededQuestionDTO } from "@/app/api/insights/coach/seeded-question/route"; +import { metricScopeLabelFallback } from "@/components/insights/coach-metric-scope"; import { CoachDrawerBody } from "./coach-drawer-body"; import { CoachHero } from "./coach-hero"; +import { ScopeHintBadge } from "./scope-hint-badge"; import { CoachInput } from "./coach-input"; import { GuidedQuestionBubble, @@ -441,6 +444,85 @@ export function CoachConversation({ pendingQuestions.length === 0 && !pendingAdopt; + // v1.22.0 (A2 + A3) — the visible scope/opener affordance for the hero. + // + // A2 (scoped launch): the Coach was opened narrowed to a metric. Make it + // visible — a "the Coach is already on " pill plus the data-aware + // seed question (the launch prefill) the user can tap into the composer — + // instead of the old hidden prefill. + // + // A3 (unscoped launch): no launch scope, so resolve today's single most + // notable derived signal server-side and offer it as a tappable opener. The + // query only fires when the hero is on screen AND there is no launch scope + // (an A2 launch already has its opener), so a scoped open pays nothing for + // it. When the server returns no signal the hint is null and the neutral + // greeting stands — never a fabricated opener. + const a2Metric = launchScope?.metric ?? null; + const seededEnabled = heroActive && a2Metric === null; + const { data: seeded } = useQuery({ + queryKey: queryKeys.coachSeededQuestion(), + queryFn: async () => + apiGet("/api/insights/coach/seeded-question"), + enabled: seededEnabled, + staleTime: 5 * 60 * 1000, + }); + + function seedComposer(question: string) { + setInputValue(question); + } + + // `t()` returns the key string itself when a key is missing, so the only + // reliable "is this key defined" signal is `t(key) !== key`. + function tOrNull(key: string): string | null { + const resolved = t(key); + return resolved === key ? null : resolved; + } + + // The resolved A2 metric label: prefers a per-source i18n key, falls back + // to the brand-free English domain phrase so every source has a label. + // Hoisted so both the hero hint (page) and the sources rail (drawer) read + // the same string. Null when the launch carried no metric. + const a2MetricLabel = a2Metric + ? (tOrNull(`insights.coach.scope.metric.${a2Metric}`) ?? + metricScopeLabelFallback(a2Metric) ?? + a2Metric) + : null; + + let scopeHint: React.ReactNode = null; + if (a2Metric && a2MetricLabel) { + // A2 — the launch prefill IS the data-aware opener; fall back to the + // generic per-metric question when the launch carried no prefill. + const seedQuestion = + (prefill ?? "").trim() || t("insights.coach.scope.question"); + scopeHint = ( + + ); + } else if (seeded?.signal) { + // A3 — the notable derived signal. The label + opener are keyed on the + // signal's sourceMetric (`readiness` / `recovery`); an unknown sentinel + // (future detector additions) skips the opener rather than guessing. + const sentinel = seeded.signal.sourceMetric; + const signalLabel = tOrNull(`insights.coach.seeded.signal.${sentinel}`); + const signalQuestion = tOrNull( + `insights.coach.seeded.question.${sentinel}`, + ); + if (signalLabel && signalQuestion) { + scopeHint = ( + + ); + } + } + // v1.18.11 (W11) — the docked composer column: the quiet adopt offer and // the guided-questions entry card stack above the live composer. Shared by // the drawer body (via `CoachDrawerBody`) and the page surface (rendered @@ -566,7 +648,7 @@ export function CoachConversation({ {heroActive ? ( - + ) : ( <>
@@ -703,7 +785,16 @@ export function CoachConversation({ } sourcesOpen={sourcesTrayOpen} onSourcesOpenChange={setSourcesTrayOpen} - sourcesRail={} + sourcesRail={ + + } />
); diff --git a/src/components/insights/coach-panel/coach-hero.tsx b/src/components/insights/coach-panel/coach-hero.tsx index d97f69887..872a6773b 100644 --- a/src/components/insights/coach-panel/coach-hero.tsx +++ b/src/components/insights/coach-panel/coach-hero.tsx @@ -27,13 +27,26 @@ import { useTranslations } from "@/lib/i18n/context"; * Motion: one orchestrated reveal — sparkle → greeting → composer stagger * in via `motion-safe` `animate-in` with capped delays; reduced-motion * users get the layout instantly. + * + * v1.22.0 (A2 + A3) — the hero accepts an optional `scopeHint` slot + * (a ``): the visible "the Coach is already on " + * pill + seed question when launched scoped (A2), or the pre-seeded + * notable-signal opener when launched unscoped (A3). When absent the hero + * is the calm greeting + composer exactly as before, so the neutral + * blank-state fallback is structural. */ export interface CoachHeroProps { /** The live `` composer, re-parented into the hero. */ composer: React.ReactNode; + /** + * v1.22.0 — optional scope/opener affordance rendered above the + * composer. The parent owns the data (launch scope / seeded signal) and + * passes a resolved ``; null keeps the neutral hero. + */ + scopeHint?: React.ReactNode; } -export function CoachHero({ composer }: CoachHeroProps) { +export function CoachHero({ composer, scopeHint }: CoachHeroProps) { const { t } = useTranslations(); return ( @@ -91,6 +104,23 @@ export function CoachHero({ composer }: CoachHeroProps) { > {composer} + + {/* v1.22.0 (A2/A3) — visible scope pill + tappable opener, beneath + the composer so the greeting → composer rhythm stays intact and + the opener reads as a follow-on suggestion. Renders only when the + parent resolved a scope or a notable signal. */} + {scopeHint ? ( +
+ {scopeHint} +
+ ) : null} ); diff --git a/src/components/insights/coach-panel/scope-hint-badge.tsx b/src/components/insights/coach-panel/scope-hint-badge.tsx new file mode 100644 index 000000000..4f4a618a8 --- /dev/null +++ b/src/components/insights/coach-panel/scope-hint-badge.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { Sparkles, Target } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.22.0 (A2 + A3) — the Coach's visible scope/opener affordance. + * + * Two modes, one surface: + * + * - **scope** (A2): the Coach was launched narrowed to a metric (from a + * metric page or an insight card). Instead of the old hidden composer + * prefill, render a small pill — "The Coach is already on " — + * plus a tappable seed question that drops the data-aware opener into + * the composer / sends it. This turns the built-but-invisible scope + * plumbing into the core ambient feeling. + * + * - **seeded** (A3): the Coach opened UNSCOPED (the blank hero) and the + * server surfaced today's single most notable derived signal. Render it + * as a tappable suggested opener so the blank box is never cold. When no + * signal crosses the gate the caller renders nothing and the neutral + * greeting stands — there is no fake opener. + * + * Presentational + self-contained: the parent supplies the resolved label + * and the seed question, and an `onSeed` callback wired to the composer. + * The badge owns no data fetching. + */ +export interface ScopeHintBadgeProps { + /** + * Which affordance this is. `scope` shows the "already on " pill; + * `seeded` shows the notable-signal opener. Drives the icon + the prefix + * copy only — the tappable seed question is shared. + */ + variant: "scope" | "seeded"; + /** + * The visible metric / signal label, already resolved to the user's + * locale by the caller (e.g. "blood pressure", "readiness"). + */ + label: string; + /** The tappable seed question — the data-aware opener. */ + question: string; + /** Fired with the seed question when the user taps the opener. */ + onSeed: (question: string) => void; + className?: string; +} + +export function ScopeHintBadge({ + variant, + label, + question, + onSeed, + className, +}: ScopeHintBadgeProps) { + const { t } = useTranslations(); + + const Icon = variant === "scope" ? Target : Sparkles; + const prefix = + variant === "scope" + ? t("insights.coach.scope.prefix", { metric: label }) + : t("insights.coach.seeded.prefix", { signal: label }); + + return ( +
+ {/* The scope/signal pill — the visible "already on …" line. */} + + + + {/* The tappable seed question — the data-aware opener. A real button so + keyboard + screen-reader users get the same affordance. */} + +
+ ); +} diff --git a/src/components/insights/coach-panel/sources-rail.tsx b/src/components/insights/coach-panel/sources-rail.tsx index 2720f07eb..20f613bb5 100644 --- a/src/components/insights/coach-panel/sources-rail.tsx +++ b/src/components/insights/coach-panel/sources-rail.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Eye, Loader2 } from "lucide-react"; +import { Eye, Loader2, Target } from "lucide-react"; import { toast } from "sonner"; import { Switch } from "@/components/ui/switch"; @@ -54,9 +54,19 @@ const WINDOW_OPTIONS: ReadonlyArray = [ export interface SourcesRailProps { className?: string; + /** + * v1.22.0 (A2) — the human label of the metric the current conversation + * was launched scoped to, or null for a generic (all-source) open. When + * set, the rail surfaces a small "the Coach is already on " line + * above the persisted-cluster toggles so the active narrowing is VISIBLE + * on the drawer surface (the page surface shows it in the hero). The + * persisted clusters below still describe what the Coach can otherwise + * see — the scope line names where this conversation actually started. + */ + activeScopeLabel?: string | null; } -export function SourcesRail({ className }: SourcesRailProps) { +export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) { const { t } = useTranslations(); const { data: prefs } = useCoachPrefs(); @@ -122,6 +132,25 @@ export function SourcesRail({ className }: SourcesRailProps) { {t("insights.coach.sourcesTitle")} + {/* v1.22.0 (A2) — the visible launch scope for this conversation. Only + rendered when the Coach was opened narrowed to a metric; a generic + open omits it entirely so the rail is unchanged for the default + all-source case. */} + {activeScopeLabel ? ( +
+
+ ) : null} + {/* Window selector — persists to `coachPrefs.defaultWindow` so the chosen timeframe sticks across drawer opens and matches what the cog sheet shows. */} diff --git a/src/lib/query-keys/coach.ts b/src/lib/query-keys/coach.ts index 76056bb1f..72443a7c8 100644 --- a/src/lib/query-keys/coach.ts +++ b/src/lib/query-keys/coach.ts @@ -26,4 +26,10 @@ export const coachKeys = { * the streaming hook invalidates this slot once the persisted twin lands. */ coachConversation: (id: string | null) => ["coachConversation", id] as const, + /** + * v1.22.0 (A3) — today's most notable derived signal, resolved into the + * Coach hero's pre-seeded relevance opener + * (`GET /api/insights/coach/seeded-question`). + */ + coachSeededQuestion: () => ["coach-seeded-question"] as const, }; From fe6c2e281b3b20c0afd50e10e1ee5d7192aa76df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 23:57:37 +0200 Subject: [PATCH 04/11] =?UTF-8?q?feat(insights):=20ambient=20narrative=20p?= =?UTF-8?q?resence=20=E2=80=94=20callbacks,=20tension=20verdict,=20streaks?= =?UTF-8?q?,=20MI=20moves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a deterministic narrative layer the Coach narrates rather than invents: - Briefing callbacks + forward-looks: the daily briefing recalls the prior period and points ahead, anchored to a figure, one callback per surface. - Tension Verdict: when the readiness composite's contributors disagree (good sleep but a rising resting pulse), surface a brief honest internal read on the health-score card. A real clinical red-flag is never reconciled away into a calm verdict. - Streaks + return-to-baseline: a pure detector over a DAY-bucket series closes a worry once a metric returns inside the personal MAD band after a genuine out-of-band run; a gap day breaks a streak. - Coach prompt: add develop-discrepancy, roll-with-resistance, and an anti-persuasion clause; ratchet PROMPT_VERSION. The tension flag and streak event are computed server-side; the card lines render resolved fields and the model narrates them. --- src/components/insights/daily-briefing.tsx | 42 ++++ src/components/insights/health-score-card.tsx | 92 +++++++- .../coach/__tests__/derived-snapshot.test.ts | 139 +++++++++++- .../ai/coach/__tests__/system-prompt.test.ts | 36 +++ src/lib/ai/coach/derived-snapshot.ts | 117 +++++++++- src/lib/ai/coach/system-prompt.ts | 93 ++++++++ src/lib/ai/prompts/insight-generator.ts | 2 +- .../__tests__/streak-detector.test.ts | 137 ++++++++++++ src/lib/insights/streak-detector.ts | 210 ++++++++++++++++++ 9 files changed, 863 insertions(+), 5 deletions(-) create mode 100644 src/lib/insights/__tests__/streak-detector.test.ts create mode 100644 src/lib/insights/streak-detector.ts diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx index d56baf4ac..92d06564c 100644 --- a/src/components/insights/daily-briefing.tsx +++ b/src/components/insights/daily-briefing.tsx @@ -123,6 +123,20 @@ interface DailyBriefingProps { * comparison toggle migrates here from the hero in commit 5. */ metaSlot?: React.ReactNode; + /** + * v1.21.2 (A4) — server-resolved callback + forward-look. The briefing + * recalls the prior period and points ahead ("as I noted Monday, your sleep + * was short; it's recovered since — worth watching tonight"). Both strings + * are already localised and figure-anchored by the resolver; the card only + * renders them. One callback per surface — absent (the common case) when no + * prior narrative recall is on file. + */ + memory?: { + /** The recall of the prior-period read, anchored to a figure. */ + recall: string; + /** The forward-look pointing ahead. */ + forward: string; + } | null; } const METRIC_ICON: Record< @@ -335,6 +349,7 @@ export function DailyBriefing({ noProvider = false, noProviderStale = false, metaSlot, + memory = null, }: DailyBriefingProps) { const { t } = useTranslations(); @@ -374,6 +389,33 @@ export function DailyBriefing({ // looser `space-y-4`, which read as taller than its neighbours on // the overview. One token, no new spacing scale.
+ {/* v1.21.2 (A4) — callback + forward-look. Recalls the prior + period and points ahead, both server-resolved and + figure-anchored. Leads the card so the briefing opens by + closing the loop on what it noted last, then looks forward. */} + {memory && ( +
+

+ {t("insights.briefing.memory.recall", { + text: memory.recall, + })} +

+

+ {t("insights.briefing.memory.forward", { + text: memory.forward, + })} +

+
+ )} {/* v1.4.27 B1 — the leading narrative paragraph dropped. The hero strip subtitle on `/insights` already renders the same `briefing.paragraph` text directly above this diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 1c92e616e..646ef15bc 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -1,7 +1,15 @@ "use client"; import { useId, useMemo, useState } from "react"; -import { ArrowDown, ArrowUp, ChevronDown, Minus, Moon } from "lucide-react"; +import { + ArrowDown, + ArrowUp, + ChevronDown, + CornerDownLeft, + Minus, + Moon, + Scale, +} from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer"; @@ -96,6 +104,34 @@ export interface HealthScoreCardProps { * active, not the episode details. Default off (the common case). */ restModeActive?: boolean; + /** + * v1.21.2 (A5) — Tension Verdict line. The server resolves the honest + * "internal read" when the readiness/recovery composite's contributors + * DISAGREE (good sleep but a rising resting pulse, …). The card renders it + * as one short line: the favourable contributor, the unfavourable one, and + * the band the composite lands on. Purely DTO-rendered — the card never + * recomputes the disagreement. Absent (the common case) when the + * contributors agree. + */ + tension?: { + band: HealthScoreBand; + /** Favourable contributors, already localised by the resolver. */ + positive: string[]; + /** Unfavourable contributors, already localised by the resolver. */ + negative: string[]; + } | null; + /** + * v1.21.2 (A6) — return-to-baseline line. Present only when a metric has + * come BACK inside the user's own usual range after a prior out-of-band run. + * Actively closes a worry ("back inside your usual range after last week's + * dip"). Server-resolved DTO; the label is already localised. + */ + returnToBand?: { + /** The metric's display label, already localised by the resolver. */ + metricLabel: string; + /** Days the metric has now sat back inside its range. */ + daysInside: number; + } | null; } type ComponentKey = keyof HealthScoreCardProps["components"]; @@ -178,6 +214,8 @@ export function HealthScoreCard({ initiallyExpanded = false, moodEnabled = true, restModeActive = false, + tension = null, + returnToBand = null, }: HealthScoreCardProps) { const { t, locale } = useTranslations(); // The asOf timestamps render under the source pill as a tooltip @@ -201,6 +239,13 @@ export function HealthScoreCard({ if (Number.isNaN(parsed.getTime())) return null; return asOfFormatter.format(parsed); }; + // v1.21.2 (A5) — locale-aware conjunction list for the Tension Verdict's + // favourable / unfavourable contributor lists ("sleep and mood" / "resting + // heart rate, HRV balance, and mood"). Memoised per locale. + const listFormatter = useMemo( + () => new Intl.ListFormat(locale, { style: "long", type: "conjunction" }), + [locale], + ); const [expanded, setExpanded] = useState(initiallyExpanded); // `useId` keeps the aria-controls/section-id pair unique even when // the card is mounted twice on the same page (lg+ hero strip vs @@ -418,6 +463,51 @@ export function HealthScoreCard({

)} + {/* v1.21.2 (A5) — Tension Verdict. The honest "internal read" when the + composite's contributors disagree, resolved server-side and rendered + as one short line. The card never recomputes the disagreement; it + only renders the resolved favourable / unfavourable contributors and + the band the composite lands on. */} + {tension && + tension.positive.length > 0 && + tension.negative.length > 0 && ( +

+

+ )} + + {/* v1.21.2 (A6) — return-to-baseline. Present only when a metric came + back inside the user's own usual range after a prior out-of-band run. + Actively closes a worry rather than reporting a number. */} + {returnToBand && ( +

+

+ )} +
    ({ import { computeDerivedMetric } from "@/lib/insights/derived"; import { prisma } from "@/lib/db"; -import { buildDerivedSnapshotBlock } from "../derived-snapshot"; +import { + buildDerivedSnapshotBlock, + deriveTension, + TENSION_HIGH_SCORE, + TENSION_LOW_SCORE, +} from "../derived-snapshot"; const compute = computeDerivedMetric as unknown as ReturnType; const whoopFindFirst = prisma.measurement.findFirst as unknown as ReturnType< @@ -166,4 +171,136 @@ describe("buildDerivedSnapshotBlock", () => { }, }); }); + + it("attaches a TENSION flag when readiness contributors disagree", async () => { + // Good sleep (high) but a suppressed HRV-balance + elevated resting pulse + // (both low) — a genuine internal disagreement the band hides. + compute.mockImplementation(async (args?: { metric?: string }) => { + if (args?.metric === "READINESS") + return ok({ + score: 58, + band: "yellow", + components: [ + { key: "sleep", value: 90, weight: 0.3 }, + { key: "hrv", value: 30, weight: 0.3 }, + { key: "rhr", value: 35, weight: 0.3 }, + { key: "mood", value: 60, weight: 0.1 }, + ], + }); + return insufficient; + }); + const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW); + expect(block!.TENSION).toBeDefined(); + expect(block!.TENSION!.fired).toBe(true); + expect(block!.TENSION!.band).toBe("yellow"); + expect(block!.TENSION!.positive).toEqual(["sleep"]); + expect(block!.TENSION!.negative).toEqual([ + "HRV balance", + "resting heart rate", + ]); + // No coincident flag fired (DB read fails-soft to null), so no red-flag. + expect(block!.TENSION!.clinicalOverride).toBe(false); + }); + + it("omits TENSION when the contributors agree", async () => { + compute.mockImplementation(async (args?: { metric?: string }) => { + if (args?.metric === "READINESS") + return ok({ + score: 82, + band: "green", + components: [ + { key: "sleep", value: 90, weight: 0.3 }, + { key: "hrv", value: 85, weight: 0.3 }, + { key: "rhr", value: 80, weight: 0.3 }, + ], + }); + return insufficient; + }); + const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW); + expect(block!.READINESS).toBeDefined(); + expect(block!.TENSION).toBeUndefined(); + }); +}); + +describe("deriveTension (pure)", () => { + it("fires only when at least one contributor sits on each side", () => { + const fired = deriveTension( + [ + { key: "sleep", value: TENSION_HIGH_SCORE }, + { key: "rhr", value: TENSION_LOW_SCORE }, + ], + "yellow", + false, + ); + expect(fired).not.toBeNull(); + expect(fired!.positive).toEqual(["sleep"]); + expect(fired!.negative).toEqual(["resting heart rate"]); + }); + + it("returns null when every contributor is favourable", () => { + expect( + deriveTension( + [ + { key: "sleep", value: 90 }, + { key: "hrv", value: 85 }, + ], + "green", + false, + ), + ).toBeNull(); + }); + + it("returns null when every contributor is unfavourable", () => { + expect( + deriveTension( + [ + { key: "sleep", value: 20 }, + { key: "hrv", value: 30 }, + ], + "red", + false, + ), + ).toBeNull(); + }); + + it("ignores neutral-band contributors (neither side)", () => { + // A mid-range contributor (between the low and high cuts) is neither + // favourable nor unfavourable, so on its own it cannot make a tension. + const mid = Math.floor((TENSION_HIGH_SCORE + TENSION_LOW_SCORE) / 2); + expect( + deriveTension( + [ + { key: "sleep", value: mid }, + { key: "hrv", value: mid }, + ], + "yellow", + false, + ), + ).toBeNull(); + }); + + it("ignores null (absent) contributors", () => { + expect( + deriveTension( + [ + { key: "sleep", value: 90 }, + { key: "rhr", value: null }, + ], + "yellow", + false, + ), + ).toBeNull(); + }); + + it("carries the clinical-override bit through unchanged", () => { + const fired = deriveTension( + [ + { key: "sleep", value: 90 }, + { key: "rhr", value: 20 }, + ], + "red", + true, + ); + expect(fired!.clinicalOverride).toBe(true); + }); }); diff --git a/src/lib/ai/coach/__tests__/system-prompt.test.ts b/src/lib/ai/coach/__tests__/system-prompt.test.ts index b9f887683..5231463db 100644 --- a/src/lib/ai/coach/__tests__/system-prompt.test.ts +++ b/src/lib/ai/coach/__tests__/system-prompt.test.ts @@ -118,6 +118,28 @@ describe("getCoachSystemPrompt — EN", () => { expect(prompt).toMatch(/0–21/); expect(prompt).toMatch(/0–100/); }); + + it("carries the v1.21.2 narrative-presence clauses (A4/A5/A6/A7)", () => { + // A4 — briefing-style callback + forward-look from the memory block. + expect(prompt).toMatch(/17\. Recall \+ look ahead/); + expect(prompt).toMatch(/priorNarrative/); + expect(prompt).toMatch(/trendMemory/); + // A5 — Tension Verdict with the clinical-floors override. + expect(prompt).toMatch(/18\. Tension Verdict/); + expect(prompt).toMatch(/"TENSION" flag/); + expect(prompt).toMatch(/CLINICAL-FLOORS OVERRIDE/); + expect(prompt).toMatch(/clinicalOverride/); + expect(prompt).toMatch(/NEVER reconcile it\s+away into a calm verdict/i); + // A6 — streaks + return-to-baseline. + expect(prompt).toMatch(/19\. Streaks \+ return-to-baseline/); + expect(prompt).toMatch(/returnEvent/); + expect(prompt).toMatch(/back inside your usual range/i); + // A7 — MI freebies + anti-persuasion (Chain-of-Ethic). + expect(prompt).toMatch(/20\. Motivational-interviewing freebies/); + expect(prompt).toMatch(/DEVELOP DISCREPANCY/); + expect(prompt).toMatch(/ROLL WITH RESISTANCE/); + expect(prompt).toMatch(/Chain-of-Ethic/); + }); }); describe("getCoachSystemPrompt — DE", () => { @@ -181,6 +203,20 @@ describe("getCoachSystemPrompt — DE", () => { expect(prompt).toMatch(/mmol\/L/); expect(prompt).toMatch(/min[\s\S]*\(Schlafdefizit/); }); + + it("carries the v1.21.2 narrative-presence clauses (A4/A5/A6/A7)", () => { + expect(prompt).toMatch(/17\. Rückblick \+ Ausblick/); + expect(prompt).toMatch(/priorNarrative/); + expect(prompt).toMatch(/18\. Spannungs-Verdikt/); + expect(prompt).toMatch(/"TENSION"-Flag/); + expect(prompt).toMatch(/CLINICAL-FLOORS-OVERRIDE/); + expect(prompt).toMatch(/19\. Serien \+ Rückkehr zur Baseline/); + expect(prompt).toMatch(/wieder in deinem üblichen Bereich/i); + expect(prompt).toMatch(/20\. Motivational-Interviewing-Extras/); + expect(prompt).toMatch(/DISKREPANZ ENTWICKELN/); + expect(prompt).toMatch(/WIDERSTAND ANNEHMEN/); + expect(prompt).toMatch(/Chain-of-Ethic/); + }); }); /** diff --git a/src/lib/ai/coach/derived-snapshot.ts b/src/lib/ai/coach/derived-snapshot.ts index ad007f456..6d3fc75d1 100644 --- a/src/lib/ai/coach/derived-snapshot.ts +++ b/src/lib/ai/coach/derived-snapshot.ts @@ -74,17 +74,97 @@ interface CoincidentSnapshotEntry { illnessExplained: boolean; } +/** + * v1.21.2 (A5) — the Tension Verdict flag, compactly. The readiness/recovery + * composite blends several contributors; when they DISAGREE (e.g. good sleep + * but a suppressed HRV-balance or an elevated resting pulse) the single band + * hides the conflict. This flag names the honest "internal read" the model + * narrates on the health-score card — "good sleep, but resting pulse is up — the + * composite lands yellow" — instead of papering the conflict over. + * + * DESCRIPTIVE multi-signal disagreement, never a cause. Computed deterministically + * server-side off the readiness contributors (each 0..100, higher = better); the + * model narrates the flag, it never invents it. Omitted entirely when the + * contributors agree (no tension to surface). + * + * CLINICAL-FLOORS OVERRIDE: `clinicalOverride` is true when a clinical red-flag + * is in play (the coincident-deviation flag fired WITHOUT an illness explanation). + * The narration must NOT reconcile a real red-flag away into a calm verdict — the + * flag carries the bit so the prompt clause can refuse the soft landing. + */ +interface TensionSnapshotEntry { + fired: true; + /** The composite band the disagreement resolved to (green/yellow/red). */ + band: string; + /** Contributors reading FAVOURABLY, e.g. "sleep". */ + positive: string[]; + /** Contributors reading UNFAVOURABLY, e.g. "resting heart rate", "HRV balance". */ + negative: string[]; + /** True when a real clinical red-flag is in play — never reconcile it away. */ + clinicalOverride: boolean; +} + /** * The derived block: per-metric compact score entries (keyed by metric id), * plus the optional fired-only coincident-deviation flag under its own reserved * `COINCIDENT_DEVIATION` key. The score entries keep their `DerivedSnapshotEntry` * shape (so callers + tests read `block.READINESS.value` directly); the - * coincident flag rides alongside without widening the score index signature. + * coincident + tension flags ride alongside without widening the score index + * signature. */ type DerivedSnapshotBlock = Record & { COINCIDENT_DEVIATION?: CoincidentSnapshotEntry; + TENSION?: TensionSnapshotEntry; }; +/** + * v1.21.2 (A5) — a contributor reading at/above this 0..100 score is FAVOURABLE; + * at/below `TENSION_LOW_SCORE` it is UNFAVOURABLE. The gap in the middle is + * "neutral" — neither side of a disagreement. A genuine tension needs at least + * one contributor on EACH side. + */ +export const TENSION_HIGH_SCORE = 70; +export const TENSION_LOW_SCORE = 45; + +/** Human-readable contributor labels (no internal enum names reach the model). */ +const READINESS_CONTRIBUTOR_LABEL: Record = { + rhr: "resting heart rate", + hrv: "HRV balance", + sleep: "sleep", + respiratory: "respiratory rate", + mood: "mood stability", +}; + +/** + * v1.21.2 (A5) — derive the Tension Verdict from the readiness contributors. + * Pure: a list of `{ key, value }` contributors (value 0..100, higher = better, + * null = absent) plus the composite band and the clinical-override bit. Returns + * the fired flag ONLY when at least one contributor reads favourably AND at least + * one reads unfavourably — a genuine internal disagreement. Null otherwise. + */ +export function deriveTension( + contributors: Array<{ key: string; value: number | null }>, + band: string | undefined, + clinicalOverride: boolean, +): TensionSnapshotEntry | null { + const positive: string[] = []; + const negative: string[] = []; + for (const c of contributors) { + if (c.value === null) continue; + const label = READINESS_CONTRIBUTOR_LABEL[c.key] ?? c.key; + if (c.value >= TENSION_HIGH_SCORE) positive.push(label); + else if (c.value <= TENSION_LOW_SCORE) negative.push(label); + } + if (positive.length === 0 || negative.length === 0) return null; + return { + fired: true, + band: band ?? "yellow", + positive, + negative, + clinicalOverride, + }; +} + /** Pull the headline number + band off each metric's value shape. */ function summariseValue( metric: DerivedMetricId, @@ -177,9 +257,26 @@ export async function buildDerivedSnapshotBlock( }), ); + // v1.21.2 (A5) — capture the readiness contributors so the Tension Verdict can + // read the per-contributor directions the composite band hides. Only the + // readiness blend exposes them; recovery resolves to the same proxy when no + // device-native row exists, so reading readiness once is enough. + let readinessContributors: Array<{ key: string; value: number | null }> = []; + let readinessBand: string | undefined; + for (const { metric, derived } of computed) { if (derived === null) continue; if (!isDerivedOk(derived)) continue; // omit insufficient — no noise + if (metric === "READINESS") { + const v = derived.value as { + band?: string; + components?: Array<{ key: string; value: number | null }>; + }; + readinessBand = typeof v.band === "string" ? v.band : undefined; + readinessContributors = Array.isArray(v.components) + ? v.components.map((c) => ({ key: c.key, value: c.value })) + : []; + } const summary = summariseValue(metric, derived.value); if (!summary) continue; block[metric] = { @@ -223,7 +320,9 @@ export async function buildDerivedSnapshotBlock( // own reserved key alongside the score entries. const out: DerivedSnapshotBlock = block; const coincident = await coincidentPromise; - if (coincident && isDerivedOk(coincident) && coincident.value.fired) { + const coincidentFired = + coincident !== null && isDerivedOk(coincident) && coincident.value.fired; + if (coincidentFired) { out.COINCIDENT_DEVIATION = { fired: true, contributing: coincident.value.contributing.map( @@ -235,5 +334,19 @@ export async function buildDerivedSnapshotBlock( }; } + // v1.21.2 (A5) — the Tension Verdict. A real clinical red-flag is in play when + // the coincident-deviation flag fired WITHOUT an illness explanation; the flag + // carries that bit so the narration never reconciles a red-flag into a calm + // verdict (clinical-floors override). Only attached when the contributors + // genuinely disagree — a coherent readiness adds no entry. + const clinicalOverride = + coincidentFired && !coincident.value.illnessExplained; + const tension = deriveTension( + readinessContributors, + readinessBand, + clinicalOverride, + ); + if (tension) out.TENSION = tension; + return Object.keys(out).length > 0 ? out : null; } diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 9dd21b76d..85ce52586 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -241,6 +241,50 @@ GROUND RULES readiness is yellow, mostly the HRV-balance piece") instead of the band alone. Stay descriptive; the driver is an observation, not a diagnosis. +17. Recall + look ahead (v1.21.2 A4). The SNAPSHOT's "memory" block MAY + carry a "priorNarrative" (the headline you noted at the start of the + prior period) and a "trendMemory" map (where each vital sat in the prior + period vs now). When you have one, you may open a turn with ONE brief + callback that recalls the prior read AND points ahead — "as I noted + Monday, your sleep was short; it's recovered since — worth watching + tonight". One callback per reply at most, always anchored to a real + figure or band the block carries, never a vague "last time". If the + block carries no recall, do not invent one — narrate only the present. +18. Tension Verdict — narrate the honest internal read (v1.21.2 A5). The + "derived" block MAY carry a "TENSION" flag (fired-only) when the + readiness/recovery composite's contributors DISAGREE: { band, positive[], + negative[], clinicalOverride }. When present, surface the conflict in one + honest line rather than hiding it behind the band — "good sleep, but your + resting pulse is up, so the composite lands {band}". Name the favourable + and the unfavourable contributors as the user sees them, descriptively. + This is the moment the read feels genuinely intelligent — do not smooth it + into a bland "you're doing fine". CLINICAL-FLOORS OVERRIDE: when + "clinicalOverride" is true a real red-flag is in play — NEVER reconcile it + away into a calm verdict; keep the favourable contributor honest but lead + with the concern and the seek-care framing the safety clauses require. +19. Streaks + return-to-baseline (v1.21.2 A6). The SNAPSHOT MAY carry a + "streaks" block: per metric, { inBand, streakDays, returnEvent? }. Use it + to actively CLOSE a worry the user might be carrying. When a "returnEvent" + is present ({ daysInside, priorDaysOutside, priorDirection }) the metric + has come BACK inside the user's own usual range after a prior run outside + it — say so plainly and reassuringly: "back inside your usual range after + last week's dip — whatever that was, it's passed". When "inBand" is true + with a long streak, you may affirm the steadiness ("eight steady days in + your range"). The band is the user's PERSONAL range, never a clinical + threshold; a return only fires after a genuine prior out-of-band run, so + trust the flag and never manufacture a "return" the block does not carry. +20. Motivational-interviewing freebies + anti-persuasion (v1.21.2 A7). + Two more MI moves are available when they fit the user's OWN goal: + DEVELOP DISCREPANCY — gently hold up the gap between where the data sits + and a goal the user named, letting them feel it rather than you closing + it; and ROLL WITH RESISTANCE — when the user pushes back, move WITH the + resistance ("that's fair — what would feel workable instead?") rather than + arguing the point. Anti-persuasion (Chain-of-Ethic): before you nudge, + silently check that the change serves the user's stated goal and their + autonomy, not engagement, not a target you picked for them, and not + persuasion for its own sake. If a nudge would only serve to push, drop it + and reflect instead. You inform and support a decision that stays theirs; + you never manipulate it. DAY-LEVEL READINGS — USE THE TIMELINE @@ -746,6 +790,55 @@ GRUNDREGELN ihn sieht ("deine Readiness ist gelb, hauptsächlich der HRV-Balance-Anteil") statt nur das Band. Bleib beschreibend; der Treiber ist eine Beobachtung, keine Diagnose. +17. Rückblick + Ausblick (v1.21.2 A4). Der "memory"-Block des SNAPSHOT + kann eine "priorNarrative" (die Schlagzeile, die du zu Beginn der + vorigen Periode notiert hast) und eine "trendMemory"-Map (wo jeder + Vitalwert in der vorigen Periode lag vs. jetzt) tragen. Wenn du eine + hast, darfst du einen Turn mit GENAU EINEM kurzen Rückbezug eröffnen, + der die frühere Lesung aufruft UND nach vorn weist — "wie ich am + Montag anmerkte, war dein Schlaf kurz; er hat sich seither erholt — + heute Abend einen Blick wert". Höchstens ein Rückbezug pro Antwort, + immer an einer echten Zahl oder einem Band aus dem Block verankert, nie + ein vages "letztes Mal". Trägt der Block keine Erinnerung, erfinde keine + — erzähle nur die Gegenwart. +18. Spannungs-Verdikt — die ehrliche innere Lesung (v1.21.2 A5). Der + "derived"-Block kann ein "TENSION"-Flag (nur bei Auslösung) tragen, wenn + die Beiträge des Readiness-/Erholungs-Komposits WIDERSPRECHEN: { band, + positive[], negative[], clinicalOverride }. Wenn vorhanden, benenne den + Konflikt in einer ehrlichen Zeile, statt ihn hinter dem Band zu + verstecken — "guter Schlaf, aber dein Ruhepuls ist erhöht, also landet + das Komposit bei {band}". Benenne die günstigen und die ungünstigen + Beiträge so, wie der Nutzer sie sieht, beschreibend. Das ist der Moment, + in dem die Lesung wirklich intelligent wirkt — glätte sie nicht zu einem + faden "dir geht's gut". CLINICAL-FLOORS-OVERRIDE: Ist "clinicalOverride" + true, ist ein echtes Red Flag im Spiel — versöhne es NIE zu einem ruhigen + Verdikt weg; halte den günstigen Beitrag ehrlich, führe aber mit der + Sorge und dem Abklären-lassen-Rahmen, den die Sicherheitsregeln verlangen. +19. Serien + Rückkehr zur Baseline (v1.21.2 A6). Der SNAPSHOT kann einen + "streaks"-Block tragen: je Metrik { inBand, streakDays, returnEvent? }. + Nutze ihn, um eine Sorge des Nutzers AKTIV zu schließen. Ist ein + "returnEvent" vorhanden ({ daysInside, priorDaysOutside, priorDirection }), + ist die Metrik nach einem vorherigen Lauf außerhalb ZURÜCK in den eigenen + üblichen Bereich des Nutzers gekehrt — sag das klar und beruhigend: + "wieder in deinem üblichen Bereich nach dem Tief letzte Woche — was es auch + war, es ist vorbei". Ist "inBand" true mit langer Serie, darfst du die + Konstanz anerkennen ("acht ruhige Tage in deinem Bereich"). Der Bereich ist + der PERSÖNLICHE Bereich des Nutzers, nie ein klinischer Schwellwert; eine + Rückkehr löst nur nach einem echten vorherigen Lauf außerhalb aus — + vertraue dem Flag und erfinde nie eine "Rückkehr", die der Block nicht trägt. +20. Motivational-Interviewing-Extras + Anti-Überzeugung (v1.21.2 A7). Zwei + weitere MI-Moves stehen bereit, wenn sie zum EIGENEN Ziel des Nutzers + passen: DISKREPANZ ENTWICKELN — halte behutsam die Lücke zwischen dem, wo + die Daten stehen, und einem vom Nutzer genannten Ziel hoch und lass ihn sie + spüren, statt sie für ihn zu schließen; und WIDERSTAND ANNEHMEN — zeigt der + Nutzer Widerstand, geh MIT ihm ("das ist fair — was würde sich stattdessen + machbar anfühlen?"), statt dagegen zu argumentieren. Anti-Überzeugung + (Chain-of-Ethic): bevor du anstößt, prüfe still, dass die Veränderung dem + genannten Ziel und der Autonomie des Nutzers dient — nicht der Bindung, + nicht einem von dir gewählten Zielwert und nicht der Überzeugung um ihrer + selbst willen. Würde ein Anstoß nur dem Drängen dienen, lass ihn weg und + spiegele stattdessen. Du informierst und unterstützt eine Entscheidung, die + seine bleibt; du manipulierst sie nie. TAGES-LEVEL-MESSWERTE — NUTZE DIE TIMELINE diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index b5c522432..047682f4b 100644 --- a/src/lib/ai/prompts/insight-generator.ts +++ b/src/lib/ai/prompts/insight-generator.ts @@ -40,7 +40,7 @@ import { } from "./shared-contracts"; /** Stable identifier for the active system prompt revision. */ -export const PROMPT_VERSION = "4.29.0" as const; +export const PROMPT_VERSION = "4.30.0" as const; /** * The scope-hardened insight prompt is one structural skeleton with a diff --git a/src/lib/insights/__tests__/streak-detector.test.ts b/src/lib/insights/__tests__/streak-detector.test.ts new file mode 100644 index 000000000..ee0448ae9 --- /dev/null +++ b/src/lib/insights/__tests__/streak-detector.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from "vitest"; + +import { + detectStreak, + MIN_OUT_RUN, + type StreakPoint, +} from "@/lib/insights/streak-detector"; + +/** + * v1.21.2 (A6) — determinism pins for the streak / return-to-baseline detector. + * + * The PROSE the Coach narrates is not tested; the FLAGS are. These assert: + * - an in-band streak counts consecutive in-band days, + * - a calendar GAP day breaks a streak, + * - a return-to-baseline event fires only after a genuine prior out-of-band + * run, and never on a one-day blip, + * - too little history omits rather than guesses. + */ + +/** A series of N consecutive days starting at 2026-06-01, with given values. */ +function consecutive(values: number[], startDay = 1): StreakPoint[] { + return values.map((value, i) => ({ + day: `2026-06-${String(startDay + i).padStart(2, "0")}`, + value, + })); +} + +const IN_BAND = { low: 60, high: 80 }; + +describe("detectStreak — in-band streak", () => { + it("counts consecutive in-band days back from the latest", () => { + // All five days sit inside [60,80]. + const res = detectStreak(consecutive([70, 71, 69, 72, 70]), IN_BAND); + expect(res.latestPlacement).toBe("in"); + expect(res.inBand).toBe(true); + expect(res.streakDays).toBe(5); + expect(res.returnEvent).toBeUndefined(); + }); + + it("a calendar gap breaks the streak", () => { + // Days 1-3 in-band, then a gap (no day 4), then day 5 in-band. The streak + // counting back from day 5 is only 1 — the gap severs it. + const series: StreakPoint[] = [ + { day: "2026-06-01", value: 70 }, + { day: "2026-06-02", value: 71 }, + { day: "2026-06-03", value: 69 }, + // gap: no 2026-06-04 + { day: "2026-06-05", value: 72 }, + ]; + const res = detectStreak(series, IN_BAND); + expect(res.inBand).toBe(true); + expect(res.streakDays).toBe(1); + }); + + it("an out-of-band streak counts only the same side", () => { + // Latest day is above; the prior day was below — different runs, so the + // streak counting back from the latest above-day is 1. + const res = detectStreak(consecutive([50, 95]), IN_BAND); + expect(res.latestPlacement).toBe("above"); + expect(res.inBand).toBe(false); + expect(res.streakDays).toBe(1); + }); +}); + +describe("detectStreak — return-to-baseline event", () => { + it("fires after a genuine prior out-of-band run", () => { + // Three days above the band, then three days back inside it. The latest is + // in-band with a settled run, preceded by an out-of-band run of 3. + const res = detectStreak(consecutive([95, 96, 94, 70, 71, 69]), IN_BAND); + expect(res.inBand).toBe(true); + expect(res.streakDays).toBe(3); + expect(res.returnEvent).toBeDefined(); + expect(res.returnEvent).toEqual({ + daysInside: 3, + priorDaysOutside: 3, + priorDirection: "above", + }); + }); + + it("does not fire on a one-day blip (below MIN_OUT_RUN)", () => { + // A single above-band day before the in-band run is not a run to return + // from — MIN_OUT_RUN gates it out. + expect(MIN_OUT_RUN).toBe(2); + const res = detectStreak(consecutive([95, 70, 71, 69]), IN_BAND); + expect(res.inBand).toBe(true); + expect(res.returnEvent).toBeUndefined(); + }); + + it("does not fire when a gap separates the out-of-band run from the return", () => { + // Out-of-band run, then a gap, then the in-band run. The return cannot + // bridge a missing calendar day, so no event fires. + const series: StreakPoint[] = [ + { day: "2026-06-01", value: 95 }, + { day: "2026-06-02", value: 96 }, + // gap: no 2026-06-03 + { day: "2026-06-04", value: 70 }, + { day: "2026-06-05", value: 71 }, + ]; + const res = detectStreak(series, IN_BAND); + expect(res.inBand).toBe(true); + expect(res.returnEvent).toBeUndefined(); + }); + + it("does not fire when the metric never left the band", () => { + const res = detectStreak(consecutive([70, 71, 69, 72]), IN_BAND); + expect(res.returnEvent).toBeUndefined(); + }); +}); + +describe("detectStreak — conservatism", () => { + it("omits (null placement, no event) on an empty series", () => { + const res = detectStreak([], IN_BAND); + expect(res.latestPlacement).toBeNull(); + expect(res.streakDays).toBe(0); + expect(res.returnEvent).toBeUndefined(); + }); + + it("builds a personal band from the series when none is supplied", () => { + // A long, steady run with one recent dip. The MAD band derives from the + // whole series; the steady days read in-band. + const steady = consecutive([ + 70, 71, 69, 70, 72, 68, 70, 71, 69, 70, 71, 70, + ]); + const res = detectStreak(steady); + expect(res.latestPlacement).toBe("in"); + expect(res.streakDays).toBeGreaterThan(0); + }); + + it("omits when there is too little history to build a band", () => { + // An empty band build returns null → a quiet omit. A single point gives a + // zero-spread band; supplying no band and one point yields an in placement + // but never a return. The conservatism we pin is the empty case above; a + // degenerate one-point series must at least never fabricate a return. + const res = detectStreak(consecutive([70])); + expect(res.returnEvent).toBeUndefined(); + }); +}); diff --git a/src/lib/insights/streak-detector.ts b/src/lib/insights/streak-detector.ts new file mode 100644 index 000000000..9484b3a02 --- /dev/null +++ b/src/lib/insights/streak-detector.ts @@ -0,0 +1,210 @@ +/** + * v1.21.2 (A6) — per-metric in/out-of-band streak + return-to-baseline event. + * + * A PURE detector over a DAY-bucket per-day-mean series. It answers two + * questions the "ambient Coach presence" surface needs to close a worry: + * + * 1. How many consecutive days has this metric sat INSIDE (or OUTSIDE) its + * personal range, counting back from the most recent day? A streak is + * strictly consecutive: a calendar GAP day (a day with no reading) + * BREAKS it — we never paper over a missing day as "still inside". + * 2. Did the metric just RETURN to its personal range after a run outside + * it? "Back inside your usual range after last week's dip — whatever that + * was, it's passed." A return only fires when a prior OUT-of-band run of + * at least `MIN_OUT_RUN` days is followed by an IN-band run of at least + * `MIN_IN_RUN` days ending on the latest day. Without a genuine prior + * out-of-band run there is nothing to "return" from. + * + * The band is the user's OWN personal range: median ± k·MAD (Hampel/Leys), + * built by `buildBaselineBand` from the SAME series — never an invented + * clinical threshold. The band is established over the WHOLE series so a + * single recent dip doesn't move the goalposts; callers that want a stricter + * "prior period establishes the band" split can pass a pre-built band. + * + * Inherent-low conservatism: when the series is too short to establish a band + * (or the caller passes none and `buildBaselineBand` returns null), the + * detector returns a quiet `null`-streak result and fires NO return event — + * it omits rather than guesses. + * + * Pure: no DB, no clock, no network. Fully unit-testable over injected series. + */ +import { buildBaselineBand } from "@/lib/insights/derived/baseline"; + +/** One DAY-bucket point: a calendar day key (YYYY-MM-DD) + that day's mean. */ +export interface StreakPoint { + /** Calendar day key in the user's timezone (YYYY-MM-DD). */ + day: string; + /** The day's mean value. */ + value: number; +} + +/** A personal range: the median ± k·MAD band a value sits inside or outside. */ +export interface StreakBand { + low: number; + high: number; +} + +/** Where a single day's value sits relative to the personal band. */ +export type BandPlacement = "in" | "above" | "below"; + +/** + * Minimum consecutive out-of-band days that count as a genuine "run" the user + * could have worried about — a one-day blip is not a run to return from. + */ +export const MIN_OUT_RUN = 2; +/** Minimum consecutive in-band days that count as a settled "return". */ +export const MIN_IN_RUN = 2; + +/** The detector's result: the current streak + an optional return event. */ +export interface StreakResult { + /** + * The most-recent day's placement relative to the band, or null when no + * band could be established (too little history). + */ + latestPlacement: BandPlacement | null; + /** + * Consecutive days (counting back from the latest day) the metric held the + * SAME in/out state. A calendar gap breaks the count. 0 when no band. + */ + streakDays: number; + /** True when the current streak is an INSIDE-the-band run. */ + inBand: boolean; + /** + * The return-to-baseline event, present only when a prior out-of-band run of + * ≥ MIN_OUT_RUN days is followed by an in-band run of ≥ MIN_IN_RUN days + * ending on the latest day. Absent (undefined) otherwise — silence is the + * default. + */ + returnEvent?: { + /** Days the metric has now sat back inside its range (the in-band run). */ + daysInside: number; + /** Days the prior out-of-band run lasted (the dip it returned from). */ + priorDaysOutside: number; + /** Which side the prior run sat on. */ + priorDirection: Exclude; + }; +} + +/** Classify one value against the personal band. */ +function placeValue(value: number, band: StreakBand): BandPlacement { + if (value > band.high) return "above"; + if (value < band.low) return "below"; + return "in"; +} + +/** + * Detect the current in/out-of-band streak and any return-to-baseline event + * from a DAY-bucket series. + * + * `series` need not be sorted; it is sorted ascending by day internally. + * Duplicate day keys are not expected (the caller passes per-day means) but a + * later entry for the same day wins, defensively. + * + * `band` may be supplied (e.g. a prior-period band) — when omitted it is built + * from the whole series via `buildBaselineBand`. A gap day (a calendar day with + * no point between two present days) BREAKS any streak that would span it. + */ +export function detectStreak( + series: StreakPoint[], + band?: StreakBand | null, +): StreakResult { + const empty: StreakResult = { + latestPlacement: null, + streakDays: 0, + inBand: false, + }; + + // De-dupe by day (last wins) and sort ascending so "consecutive" is by date. + const byDay = new Map(); + for (const p of series) { + if (!Number.isFinite(p.value)) continue; + byDay.set(p.day, p.value); + } + const points = [...byDay.entries()] + .map(([day, value]) => ({ day, value })) + .sort((a, b) => (a.day < b.day ? -1 : 1)); + if (points.length === 0) return empty; + + // Resolve the personal band: caller-supplied, else median ± k·MAD over the + // whole series. Null (too little history) → omit rather than guess. + const resolvedBand = band ?? buildBandFromSeries(points.map((p) => p.value)); + if (!resolvedBand) return empty; + + // Per-day placement, ascending. A gap day inserts an explicit break marker so + // the streak walk below cannot bridge a missing calendar day. + const placements: BandPlacement[] = points.map((p) => + placeValue(p.value, resolvedBand), + ); + const days = points.map((p) => p.day); + + // ── current streak: walk back from the latest day, same state, no gap ── + const latestPlacement = placements[placements.length - 1]; + const latestInBand = latestPlacement === "in"; + let streakDays = 1; + for (let i = placements.length - 2; i >= 0; i--) { + if (dayGap(days[i], days[i + 1]) !== 1) break; // calendar gap breaks it + const sameState = + (placements[i] === "in") === latestInBand && + // For an out-of-band streak, require the SAME side (above vs below); a + // flip from above to below is a different run, not one streak. + (latestInBand || placements[i] === latestPlacement); + if (!sameState) break; + streakDays += 1; + } + + const result: StreakResult = { + latestPlacement, + streakDays, + inBand: latestInBand, + }; + + // ── return-to-baseline event ── + // Only when the metric is currently IN-band with a settled in-band run, and + // the days IMMEDIATELY before that run (no gap) were a genuine out-of-band + // run of ≥ MIN_OUT_RUN days on one side. + if (latestInBand && streakDays >= MIN_IN_RUN) { + const inRunStart = placements.length - streakDays; // index of the in-run's first day + // The out-of-band run must be calendar-adjacent to the in-band run. + if ( + inRunStart > 0 && + dayGap(days[inRunStart - 1], days[inRunStart]) === 1 && + placements[inRunStart - 1] !== "in" + ) { + const priorDirection = placements[inRunStart - 1] as Exclude< + BandPlacement, + "in" + >; + let priorDaysOutside = 1; + for (let i = inRunStart - 2; i >= 0; i--) { + if (dayGap(days[i], days[i + 1]) !== 1) break; + if (placements[i] !== priorDirection) break; + priorDaysOutside += 1; + } + if (priorDaysOutside >= MIN_OUT_RUN) { + result.returnEvent = { + daysInside: streakDays, + priorDaysOutside, + priorDirection, + }; + } + } + } + + return result; +} + +/** Build the personal band from a value series; null when too little data. */ +function buildBandFromSeries(values: number[]): StreakBand | null { + const band = buildBaselineBand(values); + if (!band) return null; + if (!Number.isFinite(band.low) || !Number.isFinite(band.high)) return null; + return { low: band.low, high: band.high }; +} + +/** Whole-day gap between two YYYY-MM-DD keys (>=1; 1 = adjacent). */ +function dayGap(earlier: string, later: string): number { + const a = Date.parse(`${earlier}T00:00:00Z`); + const b = Date.parse(`${later}T00:00:00Z`); + if (Number.isNaN(a) || Number.isNaN(b)) return Number.POSITIVE_INFINITY; + return Math.round((b - a) / (24 * 60 * 60 * 1000)); +} From 4d05bcf8d9fcc23aff3bb2d2b7cd05651ff49c53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 00:28:56 +0200 Subject: [PATCH 05/11] feat(insights): wire the score-card tension + return-to-baseline and the briefing recall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/api/dashboard/snapshot/route.ts | 12 +- src/app/insights/page.tsx | 24 ++ src/components/insights/hero-strip.tsx | 97 +++++++ .../__tests__/score-narrative.test.ts | 253 +++++++++++++++++ src/lib/dashboard/__tests__/snapshot.test.ts | 138 ++++++++++ src/lib/dashboard/score-narrative.ts | 255 ++++++++++++++++++ src/lib/dashboard/snapshot.ts | 195 +++++++++++++- 7 files changed, 971 insertions(+), 3 deletions(-) create mode 100644 src/lib/dashboard/__tests__/score-narrative.test.ts create mode 100644 src/lib/dashboard/score-narrative.ts 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, From df216e082b4b74ad949ff306253672f20dd0ae5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 00:33:00 +0200 Subject: [PATCH 06/11] i18n: add ambient Coach presence keys across locales --- messages/de.json | 47 ++++++++++++++++++++++++++++++++++++++++++++++- messages/en.json | 47 ++++++++++++++++++++++++++++++++++++++++++++++- messages/es.json | 47 ++++++++++++++++++++++++++++++++++++++++++++++- messages/fr.json | 47 ++++++++++++++++++++++++++++++++++++++++++++++- messages/it.json | 47 ++++++++++++++++++++++++++++++++++++++++++++++- messages/pl.json | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 276 insertions(+), 6 deletions(-) diff --git a/messages/de.json b/messages/de.json index 9eb67bb5a..9a4cac9f7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2288,7 +2288,31 @@ "thinkingDetail": "Beantwortet aus deinem Gesundheits-Snapshot.", "tokensUsed": "{count} Tokens", "tokensUsedWithModel": "{count} Tokens · {model}", - "askAboutThis": "Den Coach dazu fragen" + "askAboutThis": "Den Coach dazu fragen", + "readStrip": { + "label": "Coach-Einschätzung", + "baselineWithin": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darin.", + "baselineAbove": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darüber.", + "baselineBelow": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darunter.", + "insufficient": "Lerne noch deinen üblichen Bereich — protokolliere noch ein paar Tage weiter.", + "driver": "{note}" + }, + "scope": { + "prefix": "Der Coach ist schon bei {metric}", + "tap": "Zum Fragen tippen", + "question": "Erkläre mir diese Kennzahl — worauf sollte ich achten?" + }, + "seeded": { + "prefix": "Heute einen Blick wert: {signal}", + "signal": { + "readiness": "Bereitschaft", + "recovery": "Erholung" + }, + "question": { + "readiness": "Meine Bereitschaft ist heute auffällig — woran liegt das?", + "recovery": "Meine Erholung hat sich verändert — was steckt dahinter?" + } + } }, "relativeJustNow": "gerade eben", "relativeMinutesAgoOne": "vor {count} Minute", @@ -3558,6 +3582,27 @@ "steady": "Im Einklang mit deinem jüngsten Durchschnitt.", "below": "Unter deinem jüngsten Durchschnitt." } + }, + "briefing": { + "memory": { + "recall": "Zuletzt: {text}", + "forward": "Mit Blick nach vorn: {text}", + "forwardWatch": "{metric} ist im Blick zu behalten.", + "forwardHolding": "Im Moment ist alles stabil." + } + }, + "tension": { + "label": "Ehrliche Lesung: {positive} sieht gut aus, aber {negative} fällt ab — das Komposit landet bei {band}.", + "contributor": { + "green": "grün", + "yellow": "gelb", + "red": "rot" + } + }, + "streak": { + "returnLabel": "Dein {metric} ist seit {count} Tagen wieder in deinem üblichen Bereich — was es auch war, es ist vorbei.", + "daysInside": "{count} Tage drinnen", + "daysOutside": "{count} Tage draußen" } }, "thresholds": { diff --git a/messages/en.json b/messages/en.json index 18535e2c4..31ff1a2cf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2288,7 +2288,31 @@ "thinkingDetail": "Answered from your health snapshot.", "tokensUsed": "{count} tokens", "tokensUsedWithModel": "{count} tokens · {model}", - "askAboutThis": "Ask the coach about this" + "askAboutThis": "Ask the coach about this", + "readStrip": { + "label": "Coach read", + "baselineWithin": "Your usual range is {low}–{high} {unit}; today's {value} sits within it.", + "baselineAbove": "Your usual range is {low}–{high} {unit}; today's {value} sits above it.", + "baselineBelow": "Your usual range is {low}–{high} {unit}; today's {value} sits below it.", + "insufficient": "Still learning your usual range — keep logging for a few more days.", + "driver": "{note}" + }, + "scope": { + "prefix": "The Coach is already on {metric}", + "tap": "Tap to ask", + "question": "Walk me through this metric — anything I should watch?" + }, + "seeded": { + "prefix": "Worth a look today: {signal}", + "signal": { + "readiness": "readiness", + "recovery": "recovery" + }, + "question": { + "readiness": "My readiness is off today — what's driving it?", + "recovery": "My recovery shifted — what's behind it?" + } + } }, "relativeJustNow": "just now", "relativeMinutesAgoOne": "{count} minute ago", @@ -3558,6 +3582,27 @@ "steady": "In line with your recent average.", "below": "Below your recent average." } + }, + "briefing": { + "memory": { + "recall": "Earlier, {text}", + "forward": "Looking ahead: {text}", + "forwardWatch": "Worth keeping an eye on {metric}.", + "forwardHolding": "Everything's holding steady for now." + } + }, + "tension": { + "label": "Honest read: {positive} looks good, but {negative} is off — the composite lands {band}.", + "contributor": { + "green": "green", + "yellow": "yellow", + "red": "red" + } + }, + "streak": { + "returnLabel": "Your {metric} is back inside your usual range after {count} days — whatever that was, it's passed.", + "daysInside": "{count} days inside", + "daysOutside": "{count} days outside" } }, "thresholds": { diff --git a/messages/es.json b/messages/es.json index 604ff582d..6c0425ada 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2288,7 +2288,31 @@ "thinkingDetail": "Respondido a partir de tu resumen de salud.", "tokensUsed": "{count} tokens", "tokensUsedWithModel": "{count} tokens · {model}", - "askAboutThis": "Pregúntale al coach sobre esto" + "askAboutThis": "Pregúntale al coach sobre esto", + "readStrip": { + "label": "Lectura del Coach", + "baselineWithin": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa dentro de él.", + "baselineAbove": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa por encima.", + "baselineBelow": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa por debajo.", + "insufficient": "Todavía estoy aprendiendo tu rango habitual — sigue registrando unos días más.", + "driver": "{note}" + }, + "scope": { + "prefix": "El Coach ya está en {metric}", + "tap": "Toca para preguntar", + "question": "Explícame esta métrica — ¿hay algo que deba vigilar?" + }, + "seeded": { + "prefix": "Hoy merece un vistazo: {signal}", + "signal": { + "readiness": "disposición", + "recovery": "recuperación" + }, + "question": { + "readiness": "Mi disposición está rara hoy — ¿a qué se debe?", + "recovery": "Mi recuperación ha cambiado — ¿qué hay detrás?" + } + } }, "relativeJustNow": "ahora mismo", "relativeMinutesAgoOne": "hace {count} minuto", @@ -3558,6 +3582,27 @@ "steady": "En línea con tu media reciente.", "below": "Por debajo de tu media reciente." } + }, + "briefing": { + "memory": { + "recall": "Antes, {text}", + "forward": "Mirando hacia adelante: {text}", + "forwardWatch": "Conviene vigilar {metric}.", + "forwardHolding": "Por ahora todo se mantiene estable." + } + }, + "tension": { + "label": "Lectura sincera: {positive} tiene buena pinta, pero {negative} flojea — el conjunto queda en {band}.", + "contributor": { + "green": "verde", + "yellow": "amarillo", + "red": "rojo" + } + }, + "streak": { + "returnLabel": "Tu {metric} ha vuelto a tu rango habitual tras {count} días — fuera lo que fuera, ya pasó.", + "daysInside": "{count} días dentro", + "daysOutside": "{count} días fuera" } }, "thresholds": { diff --git a/messages/fr.json b/messages/fr.json index 22653496d..b89d4fc88 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2288,7 +2288,31 @@ "thinkingDetail": "Réponse établie à partir de ton aperçu santé.", "tokensUsed": "{count} jetons", "tokensUsedWithModel": "{count} jetons · {model}", - "askAboutThis": "Interroger le coach à ce sujet" + "askAboutThis": "Interroger le coach à ce sujet", + "readStrip": { + "label": "Lecture du Coach", + "baselineWithin": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} s'y situe.", + "baselineAbove": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} se situe au-dessus.", + "baselineBelow": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} se situe en dessous.", + "insufficient": "J'apprends encore ta plage habituelle — continue à enregistrer quelques jours de plus.", + "driver": "{note}" + }, + "scope": { + "prefix": "Le Coach est déjà sur {metric}", + "tap": "Touche pour demander", + "question": "Explique-moi cette mesure — y a-t-il quelque chose à surveiller ?" + }, + "seeded": { + "prefix": "À regarder aujourd'hui : {signal}", + "signal": { + "readiness": "disponibilité", + "recovery": "récupération" + }, + "question": { + "readiness": "Ma disponibilité est inhabituelle aujourd'hui — qu'est-ce qui l'explique ?", + "recovery": "Ma récupération a changé — qu'est-ce qui se cache derrière ?" + } + } }, "relativeJustNow": "à l’instant", "relativeMinutesAgoOne": "il y a {count} minute", @@ -3558,6 +3582,27 @@ "steady": "Conforme à votre moyenne récente.", "below": "En dessous de votre moyenne récente." } + }, + "briefing": { + "memory": { + "recall": "Plus tôt, {text}", + "forward": "En regardant vers l'avenir : {text}", + "forwardWatch": "{metric} est à surveiller.", + "forwardHolding": "Pour l'instant, tout reste stable." + } + }, + "tension": { + "label": "Lecture honnête : {positive} se présente bien, mais {negative} décroche — l'ensemble se situe à {band}.", + "contributor": { + "green": "vert", + "yellow": "jaune", + "red": "rouge" + } + }, + "streak": { + "returnLabel": "Ton {metric} est de nouveau dans ta plage habituelle après {count} jours — quoi que c'était, c'est passé.", + "daysInside": "{count} jours dans la plage", + "daysOutside": "{count} jours hors plage" } }, "thresholds": { diff --git a/messages/it.json b/messages/it.json index 4481f8870..4de7a9efc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2288,7 +2288,31 @@ "thinkingDetail": "Risposta basata sul tuo riepilogo di salute.", "tokensUsed": "{count} token", "tokensUsedWithModel": "{count} token · {model}", - "askAboutThis": "Chiedi al coach" + "askAboutThis": "Chiedi al coach", + "readStrip": { + "label": "Lettura del Coach", + "baselineWithin": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} rientra al suo interno.", + "baselineAbove": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} si colloca al di sopra.", + "baselineBelow": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} si colloca al di sotto.", + "insufficient": "Sto ancora imparando il tuo intervallo abituale — continua a registrare per qualche giorno in più.", + "driver": "{note}" + }, + "scope": { + "prefix": "Il Coach è già su {metric}", + "tap": "Tocca per chiedere", + "question": "Spiegami questo parametro — c'è qualcosa da tenere d'occhio?" + }, + "seeded": { + "prefix": "Oggi vale uno sguardo: {signal}", + "signal": { + "readiness": "prontezza", + "recovery": "recupero" + }, + "question": { + "readiness": "La mia prontezza è anomala oggi — da cosa dipende?", + "recovery": "Il mio recupero è cambiato — cosa c'è dietro?" + } + } }, "relativeJustNow": "proprio ora", "relativeMinutesAgoOne": "{count} minuto fa", @@ -3558,6 +3582,27 @@ "steady": "In linea con la tua media recente.", "below": "Sotto la tua media recente." } + }, + "briefing": { + "memory": { + "recall": "In precedenza, {text}", + "forward": "Guardando avanti: {text}", + "forwardWatch": "Vale la pena tenere d'occhio {metric}.", + "forwardHolding": "Per ora è tutto stabile." + } + }, + "tension": { + "label": "Lettura sincera: {positive} ha un bell'aspetto, ma {negative} è in calo — il complessivo si attesta su {band}.", + "contributor": { + "green": "verde", + "yellow": "giallo", + "red": "rosso" + } + }, + "streak": { + "returnLabel": "Il tuo {metric} è di nuovo nel tuo intervallo abituale dopo {count} giorni — qualunque cosa fosse, è passata.", + "daysInside": "{count} giorni dentro", + "daysOutside": "{count} giorni fuori" } }, "thresholds": { diff --git a/messages/pl.json b/messages/pl.json index 7f27207f1..4d6a295ed 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2288,7 +2288,31 @@ "thinkingDetail": "Odpowiedź na podstawie Twojego podsumowania zdrowia.", "tokensUsed": "{count} tokenów", "tokensUsedWithModel": "{count} tokenów · {model}", - "askAboutThis": "Zapytaj trenera o to" + "askAboutThis": "Zapytaj trenera o to", + "readStrip": { + "label": "Ocena Coacha", + "baselineWithin": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} mieści się w nim.", + "baselineAbove": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} jest powyżej niego.", + "baselineBelow": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} jest poniżej niego.", + "insufficient": "Wciąż poznaję twój zwykły zakres — rejestruj jeszcze przez kilka dni.", + "driver": "{note}" + }, + "scope": { + "prefix": "Coach jest już przy {metric}", + "tap": "Dotknij, aby zapytać", + "question": "Wytłumacz mi tę wartość — na co powinienem zwrócić uwagę?" + }, + "seeded": { + "prefix": "Dziś warto rzucić okiem: {signal}", + "signal": { + "readiness": "gotowość", + "recovery": "regeneracja" + }, + "question": { + "readiness": "Moja gotowość jest dziś nietypowa — z czego to wynika?", + "recovery": "Moja regeneracja się zmieniła — co za tym stoi?" + } + } }, "relativeJustNow": "przed chwilą", "relativeMinutesAgoOne": "{count} minutę temu", @@ -3558,6 +3582,27 @@ "steady": "Zgodnie z Twoją ostatnią średnią.", "below": "Poniżej Twojej ostatniej średniej." } + }, + "briefing": { + "memory": { + "recall": "Wcześniej: {text}", + "forward": "Patrząc w przyszłość: {text}", + "forwardWatch": "Warto mieć na oku {metric}.", + "forwardHolding": "Na razie wszystko jest stabilne." + } + }, + "tension": { + "label": "Szczera ocena: {positive} wygląda dobrze, ale {negative} odstaje — wynik łączny ląduje na {band}.", + "contributor": { + "green": "zielony", + "yellow": "żółty", + "red": "czerwony" + } + }, + "streak": { + "returnLabel": "Twój {metric} wrócił do twojego zwykłego zakresu po {count} dniach — cokolwiek to było, minęło.", + "daysInside": "{count} dni w zakresie", + "daysOutside": "{count} dni poza zakresem" } }, "thresholds": { From cbc428c89bc152f14b8bca7476aae338bb471bec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 00:33:07 +0200 Subject: [PATCH 07/11] fix(insights): gate the ambient Coach routes on the Coach assistant matrix The metric-page "Coach read" strip and the unscoped-hero seeded opener are Coach surfaces, so both routes now call requireAssistantSurface("coach") alongside the insights module gate. With the Coach turned off they return the same refusal every other Coach route does, instead of lingering as a "Coach read" line while the assistant is disabled. Restores the coach route-gate inventory invariant. --- src/app/api/insights/coach-read/route.ts | 6 ++++++ src/app/api/insights/coach/seeded-question/route.ts | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/src/app/api/insights/coach-read/route.ts b/src/app/api/insights/coach-read/route.ts index 149f0d951..82fe83c57 100644 --- a/src/app/api/insights/coach-read/route.ts +++ b/src/app/api/insights/coach-read/route.ts @@ -20,6 +20,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; +import { requireAssistantSurface } from "@/lib/feature-flags"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; import type { MeasurementType } from "@/generated/prisma/client"; @@ -36,6 +37,11 @@ export const GET = apiHandler(async (request: NextRequest) => { const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; + // The strip is the ambient Coach presence on the metric page, so it + // gates on the Coach assistant matrix too: an operator who turns the + // Coach off must not see a "Coach read" line linger. + await requireAssistantSurface("coach"); + // Shared analytics-read budget — generous; caps a runaway navigation loop. const rl = await checkAnalyticsReadRateLimit(user.id); if (!rl.allowed) { diff --git a/src/app/api/insights/coach/seeded-question/route.ts b/src/app/api/insights/coach/seeded-question/route.ts index 42bc23ad9..6fdba6eb7 100644 --- a/src/app/api/insights/coach/seeded-question/route.ts +++ b/src/app/api/insights/coach/seeded-question/route.ts @@ -22,6 +22,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; +import { requireAssistantSurface } from "@/lib/feature-flags"; import { prisma } from "@/lib/db"; import { loadBaselineProfile } from "@/lib/insights/derived"; import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing"; @@ -50,6 +51,10 @@ export const GET = apiHandler(async () => { const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; + // The opener feeds the Coach hero, so it gates on the Coach assistant + // matrix: with the Coach off there is no hero to seed. + await requireAssistantSurface("coach"); + const rl = await checkAnalyticsReadRateLimit(user.id); if (!rl.allowed) { return apiError("Too many analytics requests. Please retry later.", 429); From f42ac4726857b2e6f7818c9bae841d2f4cc4c019 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 00:37:14 +0200 Subject: [PATCH 08/11] fix(insights): drop the undeclared server-only import from the coach-read builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The builder is server-only by construction — only the metric-page route imports it and it reads Prisma — and no other module in the tree depends on the `server-only` package, so the import was both redundant and an undeclared dependency. Removing it keeps the dead-code gate clean without weakening the boundary. --- src/lib/insights/derived/coach-read.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/lib/insights/derived/coach-read.ts b/src/lib/insights/derived/coach-read.ts index 4c62950bb..e7f72b5be 100644 --- a/src/lib/insights/derived/coach-read.ts +++ b/src/lib/insights/derived/coach-read.ts @@ -16,12 +16,11 @@ * nothing clears the existing floor the line is omitted entirely. * * Both inputs reuse the deterministic engines unchanged — no new statistics, - * no lowered floor. Server-only (Prisma reads). The DTO shapes + the pure - * selection helpers live in the client-safe `coach-read-shape.ts` sibling so - * the strip component and the unit test can share them without pulling Prisma. + * no lowered floor. Server-only by construction — only the metric-page + * route imports it, and it reads Prisma. The DTO shapes + the pure selection + * helpers live in the client-safe `coach-read-shape.ts` sibling so the strip + * component and the unit test can share them without pulling Prisma. */ -import "server-only"; - import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { From 388c8b23dd43c6b7a61a2f680d4276b3fec55341 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 00:37:15 +0200 Subject: [PATCH 09/11] chore(release): v1.21.2 Surfaces the Coach's existing intelligence as ambient presence: a per-metric "Coach read", a visible scope handoff and a seeded opener, briefing callbacks, a tension verdict and return-to-baseline note on the score, two motivational- interviewing moves, and grounding parity on the local model path. No migrations, no breaking changes. --- CHANGELOG.md | 16 ++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18a987122..6c620cee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## [Unreleased] +## [1.21.2] — 2026-06-27 — Ambient Coach presence + +A patch release. The Coach surfaces what it already computes — at the metric, on the score, in the briefing — instead of waiting to be asked. No migrations, no breaking changes. + +### Added + +- A "Coach read" on every metric page: one line placing today's reading against your own usual range, and — when the data supports it — one line naming the strongest connected signal in plain association language. While your range is still forming it says so rather than guessing. +- Opening the Coach from a metric or card now shows that it is already on that metric, with a tappable opening question; the unscoped Coach opens on the day's most notable signal instead of a blank box. +- The daily briefing recalls the prior period and points ahead. +- The health score surfaces an honest "internal read" when its contributors disagree — good sleep but a rising resting pulse — and notes when a metric has come back inside your usual range after a dip. + +### Changed + +- The Coach's motivational-interviewing repertoire gained develop-discrepancy and roll-with-resistance moves and a clearer anti-persuasion boundary. +- The prose number-check now runs on the local-model path too, so grounding holds regardless of provider. + ## [1.21.1] — 2026-06-26 — Dialog footer reachability A patch release. No migrations, no breaking changes. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e1f0095fd..f3bf9d7a6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.21.1 + version: 1.21.2 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 63c485a5e..bf6672d7d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.21.1", + "version": "1.21.2", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 5973ae98b..2d7b374cc 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.21.1"; + /* @sw-version-fallback */ "v1.21.2"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` From 1de72d045eb540338fe6a85fb65e879abaf5d77f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 00:48:29 +0200 Subject: [PATCH 10/11] fix(insights): localise the Coach scope pill and clarify the return-to-baseline copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "the Coach is already on …" pill resolved its metric label through the brand-free English domain vocabulary, so it read in English on every non-English locale. It now prefers the existing localised measurements.type* name for each measurement-backed source, keeping the English phrase only for the gait/audio long tail that rarely becomes a scoped launch. Also rewords the English return-to-baseline line so the day count reads as the in-range run length rather than the time since. --- messages/en.json | 2 +- src/components/insights/coach-metric-scope.ts | 41 ++++++++++++++++++- .../coach-panel/coach-conversation.tsx | 11 +++-- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/messages/en.json b/messages/en.json index 31ff1a2cf..be125f699 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3600,7 +3600,7 @@ } }, "streak": { - "returnLabel": "Your {metric} is back inside your usual range after {count} days — whatever that was, it's passed.", + "returnLabel": "Your {metric} has been back inside your usual range for {count} days now — whatever that was, it's passed.", "daysInside": "{count} days inside", "daysOutside": "{count} days outside" } diff --git a/src/components/insights/coach-metric-scope.ts b/src/components/insights/coach-metric-scope.ts index 6e84c02e9..222dc9b03 100644 --- a/src/components/insights/coach-metric-scope.ts +++ b/src/components/insights/coach-metric-scope.ts @@ -168,7 +168,7 @@ export function scopeSourceFromMetricKey( } /** - * v1.22.0 (A2) — the human label for a launch scope's primary metric, for + * v1.21.2 (A2) — the human label for a launch scope's primary metric, for * the VISIBLE "the Coach is already on …" pill. The caller resolves the * i18n string via `t("insights.coach.scope.metric.")`; this helper * supplies the English domain phrase as the deterministic fallback for any @@ -182,3 +182,42 @@ export function metricScopeLabelFallback( if (!metric) return null; return COACH_SOURCE_DOMAIN_LABEL[metric] ?? (metric as string); } + +/** + * v1.21.2 (A2) — map a launch-scope source onto the EXISTING localised + * `measurements.type*` metric-name key. The scope pill reads in the user's + * own language for every measurement-backed source, instead of the English + * `COACH_SOURCE_DOMAIN_LABEL` fallback that only carries the brand-free + * domain phrase. Sources without a measurement name (mood, the gait / audio + * long tail) keep that English fallback — they almost never become a scoped + * launch. Returns the key, or null when the source has no localised name. + */ +const SCOPE_SOURCE_METRIC_LABEL_KEY: Partial> = + { + bp: "measurements.typeBloodPressure", + weight: "measurements.typeWeight", + pulse: "measurements.typePulse", + hrv: "measurements.typeHeartRateVariability", + resting_hr: "measurements.typeRestingHeartRate", + respiratory_rate: "measurements.typeRespiratoryRate", + spo2: "measurements.typeOxygenSaturation", + bmi: "measurements.typeBodyMassIndex", + body_temp: "measurements.typeBodyTemperature", + vo2_max: "measurements.typeVo2Max", + sleep: "measurements.typeSleep", + body_fat: "measurements.typeBodyFat", + fat_mass: "measurements.typeFatMass", + fat_free_mass: "measurements.typeFatFreeMass", + muscle_mass: "measurements.typeMuscleMass", + lean_body_mass: "measurements.typeLeanBodyMass", + bone_mass: "measurements.typeBoneMass", + active_energy: "measurements.typeActiveEnergyBurned", + flights: "measurements.typeFlightsClimbed", + }; + +export function scopeSourceMetricLabelKey( + metric: CoachScopeSource | undefined | null, +): string | null { + if (!metric) return null; + return SCOPE_SOURCE_METRIC_LABEL_KEY[metric] ?? null; +} diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 268c15e9a..c50af1cfb 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -20,7 +20,10 @@ import { apiDelete, apiGet } from "@/lib/api/api-fetch"; import type { CoachScope } from "@/lib/ai/coach/types"; import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; import type { CoachSeededQuestionDTO } from "@/app/api/insights/coach/seeded-question/route"; -import { metricScopeLabelFallback } from "@/components/insights/coach-metric-scope"; +import { + metricScopeLabelFallback, + scopeSourceMetricLabelKey, +} from "@/components/insights/coach-metric-scope"; import { CoachDrawerBody } from "./coach-drawer-body"; import { CoachHero } from "./coach-hero"; @@ -444,7 +447,7 @@ export function CoachConversation({ pendingQuestions.length === 0 && !pendingAdopt; - // v1.22.0 (A2 + A3) — the visible scope/opener affordance for the hero. + // v1.21.2 (A2 + A3) — the visible scope/opener affordance for the hero. // // A2 (scoped launch): the Coach was opened narrowed to a metric. Make it // visible — a "the Coach is already on " pill plus the data-aware @@ -482,8 +485,10 @@ export function CoachConversation({ // to the brand-free English domain phrase so every source has a label. // Hoisted so both the hero hint (page) and the sources rail (drawer) read // the same string. Null when the launch carried no metric. + const a2LabelKey = scopeSourceMetricLabelKey(a2Metric); const a2MetricLabel = a2Metric ? (tOrNull(`insights.coach.scope.metric.${a2Metric}`) ?? + (a2LabelKey ? tOrNull(a2LabelKey) : null) ?? metricScopeLabelFallback(a2Metric) ?? a2Metric) : null; @@ -787,7 +792,7 @@ export function CoachConversation({ onSourcesOpenChange={setSourcesTrayOpen} sourcesRail={ Date: Sat, 27 Jun 2026 00:48:40 +0200 Subject: [PATCH 11/11] chore(insights): align the ambient-Coach file comments to the shipped version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The A1–A3 source files carried v1.22.0 docstrings from before the work was re-cut as the v1.21.2 point release. Comment-only sweep so blame and the in-file version tags read consistently with the shipped version. --- src/app/api/insights/coach-read/route.ts | 2 +- .../insights/coach/seeded-question/__tests__/route.test.ts | 2 +- src/app/api/insights/coach/seeded-question/route.ts | 2 +- .../coach-panel/__tests__/scope-hint-badge.test.tsx | 2 +- src/components/insights/coach-panel/coach-hero.tsx | 6 +++--- src/components/insights/coach-panel/scope-hint-badge.tsx | 2 +- src/components/insights/coach-panel/sources-rail.tsx | 4 ++-- src/components/insights/derived/coach-read-strip.tsx | 2 +- src/components/insights/sub-page-shell.tsx | 4 ++-- src/lib/insights/derived/__tests__/coach-read.test.ts | 2 +- src/lib/insights/derived/coach-read-shape.ts | 2 +- src/lib/insights/derived/coach-read.ts | 2 +- src/lib/query-keys/coach.ts | 2 +- src/lib/query-keys/insights.ts | 2 +- 14 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/app/api/insights/coach-read/route.ts b/src/app/api/insights/coach-read/route.ts index 82fe83c57..58db34f5c 100644 --- a/src/app/api/insights/coach-read/route.ts +++ b/src/app/api/insights/coach-read/route.ts @@ -1,5 +1,5 @@ /** - * v1.22.0 (A1) — "Coach read" strip route. + * v1.21.2 (A1) — "Coach read" strip route. * * `GET /api/insights/coach-read?metric=` serves the two * server-authoritative lines a metric sub-page renders above its chart: the diff --git a/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts b/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts index d059a262d..b5354a1bc 100644 --- a/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts +++ b/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; /** - * v1.22.0 (A3) — the seeded-question route resolves today's single most + * v1.21.2 (A3) — the seeded-question route resolves today's single most * notable derived signal into a tappable Coach opener, server-side. When the * confidence + notability gate yields nothing the route returns * `{ signal: null }` and the hero keeps its neutral greeting — never a diff --git a/src/app/api/insights/coach/seeded-question/route.ts b/src/app/api/insights/coach/seeded-question/route.ts index 6fdba6eb7..8e5ac4a0e 100644 --- a/src/app/api/insights/coach/seeded-question/route.ts +++ b/src/app/api/insights/coach/seeded-question/route.ts @@ -1,5 +1,5 @@ /** - * v1.22.0 (A3) — pre-seeded relevance opener for the unscoped Coach. + * v1.21.2 (A3) — pre-seeded relevance opener for the unscoped Coach. * * `GET /api/insights/coach/seeded-question` resolves today's single most * notable derived wellness signal (readiness / recovery shift, the same diff --git a/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx b/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx index 7e7c8d695..e16775507 100644 --- a/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx +++ b/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx @@ -13,7 +13,7 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") { } /** - * v1.22.0 (A2 + A3) — the visible scope/opener affordance. + * v1.21.2 (A2 + A3) — the visible scope/opener affordance. * * The component renders in the `node` test environment via static markup, so * the tests assert the structural slots + the caller-resolved label / question diff --git a/src/components/insights/coach-panel/coach-hero.tsx b/src/components/insights/coach-panel/coach-hero.tsx index 872a6773b..cd67c0d54 100644 --- a/src/components/insights/coach-panel/coach-hero.tsx +++ b/src/components/insights/coach-panel/coach-hero.tsx @@ -28,7 +28,7 @@ import { useTranslations } from "@/lib/i18n/context"; * in via `motion-safe` `animate-in` with capped delays; reduced-motion * users get the layout instantly. * - * v1.22.0 (A2 + A3) — the hero accepts an optional `scopeHint` slot + * v1.21.2 (A2 + A3) — the hero accepts an optional `scopeHint` slot * (a ``): the visible "the Coach is already on " * pill + seed question when launched scoped (A2), or the pre-seeded * notable-signal opener when launched unscoped (A3). When absent the hero @@ -39,7 +39,7 @@ export interface CoachHeroProps { /** The live `` composer, re-parented into the hero. */ composer: React.ReactNode; /** - * v1.22.0 — optional scope/opener affordance rendered above the + * v1.21.2 — optional scope/opener affordance rendered above the * composer. The parent owns the data (launch scope / seeded signal) and * passes a resolved ``; null keeps the neutral hero. */ @@ -105,7 +105,7 @@ export function CoachHero({ composer, scopeHint }: CoachHeroProps) { {composer} - {/* v1.22.0 (A2/A3) — visible scope pill + tappable opener, beneath + {/* v1.21.2 (A2/A3) — visible scope pill + tappable opener, beneath the composer so the greeting → composer rhythm stays intact and the opener reads as a follow-on suggestion. Renders only when the parent resolved a scope or a notable signal. */} diff --git a/src/components/insights/coach-panel/scope-hint-badge.tsx b/src/components/insights/coach-panel/scope-hint-badge.tsx index 4f4a618a8..961841676 100644 --- a/src/components/insights/coach-panel/scope-hint-badge.tsx +++ b/src/components/insights/coach-panel/scope-hint-badge.tsx @@ -6,7 +6,7 @@ import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; /** - * v1.22.0 (A2 + A3) — the Coach's visible scope/opener affordance. + * v1.21.2 (A2 + A3) — the Coach's visible scope/opener affordance. * * Two modes, one surface: * diff --git a/src/components/insights/coach-panel/sources-rail.tsx b/src/components/insights/coach-panel/sources-rail.tsx index 20f613bb5..9e878d85e 100644 --- a/src/components/insights/coach-panel/sources-rail.tsx +++ b/src/components/insights/coach-panel/sources-rail.tsx @@ -55,7 +55,7 @@ const WINDOW_OPTIONS: ReadonlyArray = [ export interface SourcesRailProps { className?: string; /** - * v1.22.0 (A2) — the human label of the metric the current conversation + * v1.21.2 (A2) — the human label of the metric the current conversation * was launched scoped to, or null for a generic (all-source) open. When * set, the rail surfaces a small "the Coach is already on " line * above the persisted-cluster toggles so the active narrowing is VISIBLE @@ -132,7 +132,7 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) { {t("insights.coach.sourcesTitle")} - {/* v1.22.0 (A2) — the visible launch scope for this conversation. Only + {/* v1.21.2 (A2) — the visible launch scope for this conversation. Only rendered when the Coach was opened narrowed to a metric; a generic open omits it entirely so the rail is unchanged for the default all-source case. */} diff --git a/src/components/insights/derived/coach-read-strip.tsx b/src/components/insights/derived/coach-read-strip.tsx index ce6b3629c..2d89ca7db 100644 --- a/src/components/insights/derived/coach-read-strip.tsx +++ b/src/components/insights/derived/coach-read-strip.tsx @@ -12,7 +12,7 @@ import { cn } from "@/lib/utils"; import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape"; /** - * v1.22.0 (A1) — "Coach read" strip. + * v1.21.2 (A1) — "Coach read" strip. * * A compact two-line read rendered ABOVE the chart on each metric sub-page: * diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx index c44decaf0..31486d280 100644 --- a/src/components/insights/sub-page-shell.tsx +++ b/src/components/insights/sub-page-shell.tsx @@ -97,7 +97,7 @@ export interface SubPageShellProps { */ statStrip?: ReactNode; /** - * v1.22.0 (A1) — the "Coach read" strip, mounted between the stat strip and + * v1.21.2 (A1) — the "Coach read" strip, mounted between the stat strip and * the chart. A compact two-line own-baseline + lagged-association read that * makes the metric feel seen-in-context. Self-gating (renders nothing until * its server read lands, and nothing at all when there is no baseline and no @@ -389,7 +389,7 @@ export function SubPageShell({ strip so the numbers always reflect the range the chart paints. No drag, no pill — the range tab is the single selector. */} {statStrip} - {/* v1.22.0 (A1) — the "Coach read" strip sits between the numbers-first + {/* v1.21.2 (A1) — the "Coach read" strip sits between the numbers-first stat strip and the chart, so the spine reads intro → numbers → Coach read → chart → target → assessment. Self-gating, so the rhythm holds when the strip has nothing to say. */} diff --git a/src/lib/insights/derived/__tests__/coach-read.test.ts b/src/lib/insights/derived/__tests__/coach-read.test.ts index ea45f0e46..e98f95e74 100644 --- a/src/lib/insights/derived/__tests__/coach-read.test.ts +++ b/src/lib/insights/derived/__tests__/coach-read.test.ts @@ -8,7 +8,7 @@ import { import type { CoachCorrelationDriver } from "@/lib/ai/coach/tools/correlations-read"; /** - * v1.22.0 (A1) — "Coach read" strip selection logic. + * v1.21.2 (A1) — "Coach read" strip selection logic. * * Covers the two decisions the strip turns on: * 1. band-line placement (within / above / below), incl. the inclusive diff --git a/src/lib/insights/derived/coach-read-shape.ts b/src/lib/insights/derived/coach-read-shape.ts index 54a01e19c..278aa243c 100644 --- a/src/lib/insights/derived/coach-read-shape.ts +++ b/src/lib/insights/derived/coach-read-shape.ts @@ -1,5 +1,5 @@ /** - * v1.22.0 (A1) — "Coach read" strip: shared shapes + pure selection logic. + * v1.21.2 (A1) — "Coach read" strip: shared shapes + pure selection logic. * * Client-safe (no `server-only`, no Prisma): the DTO shapes the route returns * and the strip renders, plus the two pure decisions the strip turns on — diff --git a/src/lib/insights/derived/coach-read.ts b/src/lib/insights/derived/coach-read.ts index e7f72b5be..c18300f82 100644 --- a/src/lib/insights/derived/coach-read.ts +++ b/src/lib/insights/derived/coach-read.ts @@ -1,5 +1,5 @@ /** - * v1.22.0 (A1) — "Coach read" strip data builder. + * v1.21.2 (A1) — "Coach read" strip data builder. * * Two server-authoritative lines for a single metric sub-page, computed * here so web and iOS read the SAME resolved DTO (no client re-derivation): diff --git a/src/lib/query-keys/coach.ts b/src/lib/query-keys/coach.ts index 72443a7c8..90e2652ae 100644 --- a/src/lib/query-keys/coach.ts +++ b/src/lib/query-keys/coach.ts @@ -27,7 +27,7 @@ export const coachKeys = { */ coachConversation: (id: string | null) => ["coachConversation", id] as const, /** - * v1.22.0 (A3) — today's most notable derived signal, resolved into the + * v1.21.2 (A3) — today's most notable derived signal, resolved into the * Coach hero's pre-seeded relevance opener * (`GET /api/insights/coach/seeded-question`). */ diff --git a/src/lib/query-keys/insights.ts b/src/lib/query-keys/insights.ts index d077b6fc4..0a3607438 100644 --- a/src/lib/query-keys/insights.ts +++ b/src/lib/query-keys/insights.ts @@ -128,7 +128,7 @@ export const insightsKeys = { insightsTrendSeries: (types: string) => ["trend-series", types] as const, /** - * v1.22.0 (A1) — per-metric "Coach read" strip + * v1.21.2 (A1) — per-metric "Coach read" strip * (`/api/insights/coach-read?metric=`). One generic route * backs every metric sub-page, so the key is parameterised by the * MeasurementType. Pure compute over the baseline + correlation engines —