diff --git a/messages/de.json b/messages/de.json index 2953dc13c..9d49cd290 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2286,7 +2286,8 @@ "thinkingExpandAria": "Zeigen, was der Coach berücksichtigt hat", "thinkingDetail": "Beantwortet aus deinem Gesundheits-Snapshot.", "tokensUsed": "{count} Tokens", - "tokensUsedWithModel": "{count} Tokens · {model}" + "tokensUsedWithModel": "{count} Tokens · {model}", + "askAboutThis": "Den Coach dazu fragen" }, "relativeJustNow": "gerade eben", "relativeMinutesAgoOne": "vor {count} Minute", diff --git a/messages/en.json b/messages/en.json index dddad7809..ba04271ee 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2286,7 +2286,8 @@ "thinkingExpandAria": "Show what the Coach considered", "thinkingDetail": "Answered from your health snapshot.", "tokensUsed": "{count} tokens", - "tokensUsedWithModel": "{count} tokens · {model}" + "tokensUsedWithModel": "{count} tokens · {model}", + "askAboutThis": "Ask the coach about this" }, "relativeJustNow": "just now", "relativeMinutesAgoOne": "{count} minute ago", diff --git a/messages/es.json b/messages/es.json index 9c7719abb..4596b221c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2286,7 +2286,8 @@ "thinkingExpandAria": "Mostrar lo que el Coach tuvo en cuenta", "thinkingDetail": "Respondido a partir de tu resumen de salud.", "tokensUsed": "{count} tokens", - "tokensUsedWithModel": "{count} tokens · {model}" + "tokensUsedWithModel": "{count} tokens · {model}", + "askAboutThis": "Pregúntale al coach sobre esto" }, "relativeJustNow": "ahora mismo", "relativeMinutesAgoOne": "hace {count} minuto", diff --git a/messages/fr.json b/messages/fr.json index dfabd04d3..82b1156d4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2286,7 +2286,8 @@ "thinkingExpandAria": "Montrer ce que le Coach a pris en compte", "thinkingDetail": "Réponse établie à partir de ton aperçu santé.", "tokensUsed": "{count} jetons", - "tokensUsedWithModel": "{count} jetons · {model}" + "tokensUsedWithModel": "{count} jetons · {model}", + "askAboutThis": "Interroger le coach à ce sujet" }, "relativeJustNow": "à l’instant", "relativeMinutesAgoOne": "il y a {count} minute", diff --git a/messages/it.json b/messages/it.json index 53fd5ec0d..4e611a6ee 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2286,7 +2286,8 @@ "thinkingExpandAria": "Mostra cosa ha considerato il Coach", "thinkingDetail": "Risposta basata sul tuo riepilogo di salute.", "tokensUsed": "{count} token", - "tokensUsedWithModel": "{count} token · {model}" + "tokensUsedWithModel": "{count} token · {model}", + "askAboutThis": "Chiedi al coach" }, "relativeJustNow": "proprio ora", "relativeMinutesAgoOne": "{count} minuto fa", diff --git a/messages/pl.json b/messages/pl.json index 5afe3bc9b..fda773d42 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2286,7 +2286,8 @@ "thinkingExpandAria": "Pokaż, co Coach wziął pod uwagę", "thinkingDetail": "Odpowiedź na podstawie Twojego podsumowania zdrowia.", "tokensUsed": "{count} tokenów", - "tokensUsedWithModel": "{count} tokenów · {model}" + "tokensUsedWithModel": "{count} tokenów · {model}", + "askAboutThis": "Zapytaj trenera o to" }, "relativeJustNow": "przed chwilą", "relativeMinutesAgoOne": "{count} minutę temu", diff --git a/src/components/insights/__tests__/recommendation-card-confidence.test.tsx b/src/components/insights/__tests__/recommendation-card-confidence.test.tsx index c45c48f1f..12f3edbaa 100644 --- a/src/components/insights/__tests__/recommendation-card-confidence.test.tsx +++ b/src/components/insights/__tests__/recommendation-card-confidence.test.tsx @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from "vitest"; +import { createContext } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; import { RecommendationCard } from "../recommendation-card"; @@ -24,6 +25,10 @@ vi.mock("@tanstack/react-query", () => ({ error: null, }), useQueryClient: () => ({ invalidateQueries: vi.fn() }), + // v1.21.0 — the card footer's `` reaches the + // query-client mount probe; a null-default context reads as "no client + // mounted" so the Coach hooks return their fail-open defaults. + QueryClientContext: createContext(null), })); vi.mock("@/hooks/use-auth", () => ({ diff --git a/src/components/insights/__tests__/recommendation-card.test.tsx b/src/components/insights/__tests__/recommendation-card.test.tsx index 952f1068f..1fddb563d 100644 --- a/src/components/insights/__tests__/recommendation-card.test.tsx +++ b/src/components/insights/__tests__/recommendation-card.test.tsx @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from "vitest"; +import { createContext } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; import { RecommendationCard } from "../recommendation-card"; @@ -66,6 +67,11 @@ vi.mock("@tanstack/react-query", () => ({ error: null, }), useQueryClient: () => ({ invalidateQueries: vi.fn() }), + // v1.21.0 — the card's footer `` reaches the + // query-client mount probe (`useFeatureFlags` / `useDisableCoach`); the + // probe only needs the context to exist. A null-default context reads + // as "no client mounted" so both hooks return their fail-open defaults. + QueryClientContext: createContext(null), })); vi.mock("@/hooks/use-auth", () => ({ diff --git a/src/components/insights/__tests__/recommendations-grid.test.tsx b/src/components/insights/__tests__/recommendations-grid.test.tsx index ce7400963..15dc74dcc 100644 --- a/src/components/insights/__tests__/recommendations-grid.test.tsx +++ b/src/components/insights/__tests__/recommendations-grid.test.tsx @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from "vitest"; +import { createContext } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; @@ -35,6 +36,10 @@ vi.mock("@tanstack/react-query", () => ({ error: null, }), useQueryClient: () => ({ invalidateQueries: vi.fn() }), + // v1.21.0 — the card footer's `` reaches the + // query-client mount probe; a null-default context reads as "no client + // mounted" so the Coach hooks return their fail-open defaults. + QueryClientContext: createContext(null), })); vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ diff --git a/src/components/insights/ask-coach-action.tsx b/src/components/insights/ask-coach-action.tsx new file mode 100644 index 000000000..1200ab309 --- /dev/null +++ b/src/components/insights/ask-coach-action.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { Sparkles } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; +import { + useCoachLaunch, + type CoachLaunchScope, +} from "@/lib/insights/coach-launch-context"; +import { useFeatureFlags } from "@/hooks/use-feature-flags"; +import { useDisableCoach } from "@/hooks/use-disable-coach"; + +/** + * v1.21.0 (C4 H2) — discreet "Ask the Coach about this" affordance for + * high-value insight / assessment cards (briefing, recommendation, + * correlation, status, health-score, period narrative). + * + * It is the reverse-direction entry point the C4 audit flagged as + * missing: the cards carry the richest context in the app, so a single + * small action per card opens the Coach pre-scoped to that card's topic + * with a seeded opener. Reuses the existing launch context — no parallel + * launch system. + * + * Subtle by construction: a ghost, muted-foreground text+icon button (no + * alarming colour, no card-tinting — the medication-card rule holds). One + * entry point per card, never a cluster. + * + * Self-gates on the same triple the FAB / inline pill use (provider + * mounted + operator flag + per-user opt-out) so a card never paints a + * dead Coach control. + */ +export interface AskCoachActionProps { + /** + * Composer seed question — a plain-English opener about the card's + * topic. The Coach treats it as composer seed text, not an i18n key + * (the chat route is EN/DE-gated), matching the empty-state convention. + */ + question: string; + /** + * Optional scope so the conversation narrows to the card's source(s). + * Omitted for cards that span the whole picture (e.g. the daily + * briefing), which read better against the default all-source snapshot. + */ + scope?: CoachLaunchScope; + /** Optional visible-label override; defaults to the shared CTA copy. */ + label?: string; + /** className passthrough for per-card alignment. */ + className?: string; +} + +export function AskCoachAction({ + question, + scope, + label, + className, +}: AskCoachActionProps) { + const { t } = useTranslations(); + const launch = useCoachLaunch(); + const flags = useFeatureFlags(); + const disableCoach = useDisableCoach(); + + // Same gate posture as / : render + // nothing unless the provider is mounted, the operator flag is on, and + // the user has not opted out. + if (!launch) return null; + if (!flags.coach) return null; + if (disableCoach) return null; + + const accessibleLabel = label ?? t("insights.coach.askAboutThis"); + + return ( + + ); +} diff --git a/src/components/insights/coach-metric-scope.ts b/src/components/insights/coach-metric-scope.ts new file mode 100644 index 000000000..317cb64a0 --- /dev/null +++ b/src/components/insights/coach-metric-scope.ts @@ -0,0 +1,167 @@ +import type { CoachScopeSource, CoachScopeWindow } from "@/lib/ai/coach/types"; + +/** + * v1.21.0 (C4 H1/H4) — metric → Coach scope + seed-question map. + * + * The Coach launch context can now carry a live `scope` (a + * `CoachScopeSource` + window) and a `prefill` seed question into the + * conversation. This module is the single place that resolves "the user + * is looking at metric X" into "open the Coach narrowed to source X with + * a data-aware opener." + * + * Two callers consume it: + * - `` registers the active metric's scope as the page's + * ambient scope so the global FAB opens contextual to that page + * (no per-metric header icon — CCH-04 stays intact). + * - the "Ask the Coach" affordance on insight/assessment cards passes + * an explicit scope so a card tap lands a pre-scoped conversation. + * + * Seed questions are plain English strings, mirroring the empty-state + * `coachPrefill` convention already in the tree — the Coach chat route + * is English/German-gated and treats the prefill as composer seed text, + * not an i18n key. + */ + +export interface CoachMetricScope { + /** Primary source the snapshot narrows to. */ + metric: CoachScopeSource; + /** Extra sources to include alongside `metric` (e.g. correlations). */ + also?: CoachScopeSource[]; + /** Optional day-window override; defaults to the route's `last30days`. */ + window?: CoachScopeWindow; + /** Composer seed question — a data-aware opener for the metric. */ + question: string; +} + +/** + * `SubPageShell` passes the metric's `explainerMetric` token (the key + * feeding `insights.subPage.explainer.Body`). Map the tokens that + * resolve to a snapshot source to a scope + opener. Tokens absent here + * (mobility / gait micro-metrics with no dedicated snapshot block) simply + * carry no ambient scope — the FAB opens the default snapshot, exactly as + * before, so this is purely additive. + */ +const EXPLAINER_METRIC_SCOPE: Record = { + bloodPressure: { + metric: "bp", + question: + "Walk me through my blood pressure trend over the last 30 days — anything I should keep an eye on?", + }, + weight: { + metric: "weight", + question: + "How has my weight been trending lately, and is the direction something to act on?", + }, + bmi: { + metric: "bmi", + question: "What does my BMI trend tell me, and how should I read it?", + }, + pulse: { + metric: "pulse", + question: + "Walk me through my pulse readings — is anything out of the ordinary?", + }, + restingHr: { + metric: "resting_hr", + question: + "How has my resting heart rate been trending, and what does it say about my fitness?", + }, + hrv: { + metric: "hrv", + question: + "What is my heart-rate variability telling me about recovery and stress lately?", + }, + sleep: { + metric: "sleep", + question: + "Walk me through my recent sleep — duration, consistency, and anything worth changing.", + }, + mood: { + metric: "mood", + question: "What patterns do you see in my mood over the last few weeks?", + }, + medications: { + metric: "compliance", + question: + "How is my medication adherence looking, and is it lined up with how I've been feeling?", + }, + steps: { + metric: "steps", + question: + "How active have I been lately based on my steps, and how does it compare to a healthy baseline?", + }, + activeEnergy: { + metric: "active_energy", + question: + "What does my active-energy trend say about my activity level lately?", + }, + cardioFitness: { + metric: "vo2_max", + question: + "What does my cardio fitness (VO₂ max) trend mean, and how do I move it in the right direction?", + }, + bloodGlucose: { + metric: "glucose", + question: + "Walk me through my recent glucose readings — are they in a healthy range?", + }, + oxygenSaturation: { + metric: "spo2", + question: "What does my blood-oxygen (SpO₂) trend tell me?", + }, + respiratoryRate: { + metric: "respiratory_rate", + question: "What does my respiratory-rate trend say about my health?", + }, + workouts: { + metric: "workouts", + question: + "Walk me through my recent workouts — load, frequency, and how I'm recovering between them.", + }, + // Recovery is a synthesis page; anchor on HRV + resting HR + sleep so the + // Coach reads the inputs that drive the recovery read. + recoveryPage: { + metric: "hrv", + also: ["resting_hr", "sleep"], + window: "last7days", + question: + "Why is my recovery where it is right now, and what's driving it?", + }, +}; + +/** Resolve a `SubPageShell` explainer token to a Coach scope, or null. */ +export function metricScopeFromExplainer( + explainerMetric: string | undefined, +): CoachMetricScope | null { + if (!explainerMetric) return null; + return EXPLAINER_METRIC_SCOPE[explainerMetric] ?? null; +} + +/** + * Resolve a recommendation / correlation `metricSource.type` — the + * model's snapshot-key vocabulary ("bloodPressure", "weight", "pulse", + * "mood", "medications.compliance30", "sleep", "steps", …) — to a single + * `CoachScopeSource`, or null when the key has no snapshot block to + * narrow to. Lets a card's "Ask the Coach" affordance pre-scope the + * conversation to the metric the card is about. + */ +export function scopeSourceFromMetricKey( + metricKey: string | undefined, +): CoachScopeSource | null { + if (!metricKey) return null; + const lower = metricKey.toLowerCase(); + if (lower.startsWith("medications.compliance") || lower === "medication") { + return "compliance"; + } + if (lower === "bloodpressure" || lower === "blood_pressure") return "bp"; + if (lower === "weight") return "weight"; + if (lower === "pulse") return "pulse"; + if (lower === "resting_hr" || lower === "restinghr") return "resting_hr"; + if (lower === "hrv") return "hrv"; + if (lower === "mood") return "mood"; + if (lower === "sleep" || lower.startsWith("sleep")) return "sleep"; + if (lower === "steps" || lower === "activity") return "steps"; + if (lower === "bloodglucose" || lower === "blood_glucose") return "glucose"; + if (lower === "bmi") return "bmi"; + return null; +} diff --git a/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts b/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts new file mode 100644 index 000000000..0619ce106 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; + +import { launchScopeToCoachScope } from "../coach-conversation"; +import { + metricScopeFromExplainer, + scopeSourceFromMetricKey, +} from "../../coach-metric-scope"; + +/** + * v1.21.0 (C4 H1/H4) — the Coach launch scope is now live. These tests pin + * the conversion from the UI launch scope to the chat route's wire + * `CoachScope`, plus the two metric→source resolvers that feed it. + */ +describe("launchScopeToCoachScope", () => { + it("returns undefined for an absent / metric-less scope (default snapshot)", () => { + expect(launchScopeToCoachScope(null)).toBeUndefined(); + expect(launchScopeToCoachScope(undefined)).toBeUndefined(); + expect(launchScopeToCoachScope({})).toBeUndefined(); + }); + + it("maps a single metric to a one-source scope", () => { + expect(launchScopeToCoachScope({ metric: "bp" })).toEqual({ + sources: ["bp"], + }); + }); + + it("includes `also` sources and dedupes against the primary", () => { + expect( + launchScopeToCoachScope({ metric: "bp", also: ["compliance", "bp"] }), + ).toEqual({ sources: ["bp", "compliance"] }); + }); + + it("threads the window through only when present", () => { + expect( + launchScopeToCoachScope({ metric: "hrv", window: "last7days" }), + ).toEqual({ sources: ["hrv"], window: "last7days" }); + expect(launchScopeToCoachScope({ metric: "hrv" })).not.toHaveProperty( + "window", + ); + }); +}); + +describe("metricScopeFromExplainer", () => { + it("resolves a mapped explainer token to a scope + opener", () => { + const resolved = metricScopeFromExplainer("bloodPressure"); + expect(resolved?.metric).toBe("bp"); + expect(resolved?.question).toMatch(/blood pressure/i); + }); + + it("anchors the recovery page on its driver sources + short window", () => { + const resolved = metricScopeFromExplainer("recoveryPage"); + expect(resolved?.metric).toBe("hrv"); + expect(resolved?.also).toEqual(["resting_hr", "sleep"]); + expect(resolved?.window).toBe("last7days"); + }); + + it("returns null for an unmapped / undefined token", () => { + expect(metricScopeFromExplainer("walkingAsymmetry")).toBeNull(); + expect(metricScopeFromExplainer(undefined)).toBeNull(); + }); +}); + +describe("scopeSourceFromMetricKey", () => { + it("maps the model's snapshot-key vocabulary to a scope source", () => { + expect(scopeSourceFromMetricKey("bloodPressure")).toBe("bp"); + expect(scopeSourceFromMetricKey("medications.compliance30")).toBe( + "compliance", + ); + expect(scopeSourceFromMetricKey("WEIGHT")).toBe("weight"); + }); + + it("returns null for an unknown / absent key", () => { + expect(scopeSourceFromMetricKey("vascular_age")).toBeNull(); + expect(scopeSourceFromMetricKey(undefined)).toBeNull(); + }); +}); diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index add693bda..84c074f18 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -17,6 +17,8 @@ import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; 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 { CoachDrawerBody } from "./coach-drawer-body"; import { CoachHero } from "./coach-hero"; @@ -64,6 +66,27 @@ import { * `` (required for the dialog's accessible name) while * the page mounts a plain `

` / `

`. */ + +/** + * v1.21.0 (C4 H1/H4) — collapse a UI launch scope ({ metric, also, + * window }) into the chat route's wire scope ({ sources, window }). + * Returns undefined when there is nothing to narrow, so the request + * falls back to the route's default all-source snapshot. Exported for + * the unit test that pins the source-dedup + window contract. + */ +export function launchScopeToCoachScope( + launchScope: CoachLaunchScope | null | undefined, +): CoachScope | undefined { + if (!launchScope?.metric) return undefined; + const sources = Array.from( + new Set([launchScope.metric, ...(launchScope.also ?? [])]), + ); + return { + sources, + ...(launchScope.window ? { window: launchScope.window } : {}), + }; +} + export interface CoachConversationProps { /** * Pre-fill for the composer (suggested-prompt chip click). Resets the @@ -71,6 +94,15 @@ export interface CoachConversationProps { * freely between prop changes. */ prefill?: string | null; + /** + * v1.21.0 (C4 H1/H4) — optional launch scope so a conversation opened + * from a metric surface or insight card narrows its snapshot to the + * relevant source(s) + window. Converted to the chat route's + * `CoachScope` and attached to the FIRST turn of a fresh conversation + * (`currentConversationId === null`); a continued thread keeps its own + * established scope. Null → the route's default all-source snapshot. + */ + launchScope?: CoachLaunchScope | null; /** * Renders the conversation title. The surface passes the resolved * title string; the drawer wraps it in ``, the page in an @@ -148,6 +180,7 @@ export interface CoachConversationProps { export function CoachConversation({ prefill, + launchScope, renderTitle, renderDescription, leadingHeaderActions, @@ -277,12 +310,21 @@ export function CoachConversation({ dismissQuestions.mutate(guidedQuestion); } setInputValue(""); + // v1.21.0 (C4 H1/H4) — attach the launch scope to the FIRST turn of a + // fresh conversation so a chat opened from a metric surface / insight + // card reads a snapshot narrowed to the relevant source(s). A continued + // thread (existing id) keeps its own established scope, so we omit it. + const scope = + currentConversationId === null + ? launchScopeToCoachScope(launchScope) + : undefined; // v1.16.6 — hand the question to the turn so the Coach reaction is // contextual (the question bubble itself is never persisted). const resolvedId = await send.send({ conversationId: currentConversationId ?? undefined, message: trimmed, guidedQuestion: guidedQuestion ?? undefined, + scope, }); if (guidedQuestion !== null && guidedIndex !== null) { setPendingAdopt({ diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index bcbfce7d3..06f2cf7bf 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -12,6 +12,7 @@ import { SheetDescription, SheetTitle, } from "@/components/ui/sheet"; +import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { useIsMobile } from "@/hooks/use-is-mobile"; @@ -47,9 +48,20 @@ export interface CoachDrawerProps { onOpenChange: (next: boolean) => void; /** Optional pre-fill for the input box (suggested-prompt chip click). */ prefill?: string | null; + /** + * v1.21.0 (C4 H1/H4) — optional launch scope so a conversation opened + * from a metric surface or insight card is pre-narrowed to the relevant + * source(s) + window. Null → the route's default all-source snapshot. + */ + scope?: CoachLaunchScope | null; } -export function CoachDrawer({ open, onOpenChange, prefill }: CoachDrawerProps) { +export function CoachDrawer({ + open, + onOpenChange, + prefill, + scope, +}: CoachDrawerProps) { const { t } = useTranslations(); const router = useRouter(); // v1.4.27 R3d MB1 — below the `sm` breakpoint (640 px) the Coach @@ -126,6 +138,7 @@ export function CoachDrawer({ open, onOpenChange, prefill }: CoachDrawerProps) { = { low: "insights.correlationRow.confidenceLow", }; +// v1.21.0 (C4 H2) — the two sources each correlation pair spans, so the +// Coach hand-off narrows its snapshot to exactly the relationship the +// card describes. +const COACH_SOURCES_BY_KIND: Record< + CorrelationResult["kind"], + CoachScopeSource[] +> = { + "bp-compliance": ["bp", "compliance"], + "mood-pulse": ["mood", "pulse"], + "weight-weekday": ["weight"], +}; + +const COACH_QUESTION_BY_KIND: Record = { + "bp-compliance": + "How does my medication adherence relate to my blood pressure? Walk me through what this correlation means for me.", + "mood-pulse": + "Is there a real link between my mood and my pulse? Help me read this correlation.", + "weight-weekday": + "Does my weight follow a weekly pattern? What does this day-of-week correlation tell me?", +}; + export function CorrelationCard({ result }: CorrelationCardProps) { const { t } = useTranslations(); const title = t(TITLE_KEY[result.kind]); const subtitle = t(SUBTITLE_KEY[result.kind]); + const coachSources = COACH_SOURCES_BY_KIND[result.kind]; + const coachScope = + coachSources.length > 0 + ? { metric: coachSources[0], also: coachSources.slice(1) } + : undefined; return ( - +

+ + {/* v1.21.0 (C4 H2) — the live Coach hand-off sits beside the + roadmap CTA: it scopes to both sources in the pair. */} + +
) : ( )} + {/* v1.21.0 (C4 H2) — hand off to the Coach for the whole + picture. The briefing spans every metric, so no scope: the + default all-source snapshot reads best here. */} +
+ +
) : noProvider ? ( // v1.15.20 — no provider configured anywhere: a regenerate CTA diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 558955bf8..1c92e616e 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -5,6 +5,7 @@ import { ArrowDown, ArrowUp, ChevronDown, Minus, Moon } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer"; +import { AskCoachAction } from "./ask-coach-action"; /** * v1.4.20 B5 — Personal Health Score panel. @@ -616,6 +617,16 @@ export function HealthScoreCard({ {/* v1.18.6 (DISC-01) — the "indicative, not a clinical assessment" card disclaimer is removed; the one-time onboarding acknowledgment now covers the not-a-diagnosis framing app-wide. */} + + {/* v1.21.0 (C4 H2) — the score is a whole-picture composite, so the + hand-off opens the Coach against the default snapshot (no scope) + seeded with the actual number. Mounts at the foot of the card as + a single discreet entry, not a primary CTA. */} +
+ +
); diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx index a4682ed07..9281606ba 100644 --- a/src/components/insights/insight-status-card.tsx +++ b/src/components/insights/insight-status-card.tsx @@ -11,6 +11,8 @@ import { formatRelativeTime } from "@/lib/i18n/relative-time"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { cn } from "@/lib/utils"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; +import { AskCoachAction } from "@/components/insights/ask-coach-action"; +import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; // ─── Types ──────────────────────────────────────────────── @@ -56,6 +58,15 @@ interface InsightStatusCardProps { * card is being assembled, not stuck. The client polls until text lands. */ preparing?: boolean; + /** + * v1.21.0 (C4 H2) — opt-in "Ask the Coach about this assessment" hand-off. + * When `coachQuestion` is set (the metric-aware caller supplies it), the + * populated card renders a discreet Coach action seeded with that opener + * and narrowed to `coachScope` when known. Callers that omit it keep the + * card exactly as before — additive. + */ + coachQuestion?: string; + coachScope?: CoachLaunchScope; } // ─── Main Component ─────────────────────────────────────── @@ -68,6 +79,8 @@ export function InsightStatusCard({ updatedAt, loading = false, preparing = false, + coachQuestion, + coachScope, }: InsightStatusCardProps) { const { t } = useTranslations(); const flags = useFeatureFlags(); @@ -228,6 +241,14 @@ export function InsightStatusCard({ they roll forward. */} + {/* v1.21.0 (C4 H2) — discreet hand-off so the user can take the + assessment into a scoped Coach conversation. Only when the + caller supplied an opener. */} + {coachQuestion ? ( +
+ +
+ ) : null} ); diff --git a/src/components/insights/layout-coach-mount.tsx b/src/components/insights/layout-coach-mount.tsx index 0afa14c0e..1b0947430 100644 --- a/src/components/insights/layout-coach-mount.tsx +++ b/src/components/insights/layout-coach-mount.tsx @@ -52,6 +52,10 @@ export function LayoutCoachMount() { open={launch.open} onOpenChange={launch.setOpen} prefill={launch.prefill} + // v1.21.0 (C4 H1/H4) — carry the launch scope so a conversation + // opened from a metric surface or insight card is pre-narrowed to + // the relevant source(s). + scope={launch.scope} /> ); } diff --git a/src/components/insights/metric-status-card.tsx b/src/components/insights/metric-status-card.tsx index d91311599..5644db56b 100644 --- a/src/components/insights/metric-status-card.tsx +++ b/src/components/insights/metric-status-card.tsx @@ -7,6 +7,7 @@ import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; +import { scopeSourceFromMetricKey } from "@/components/insights/coach-metric-scope"; interface MetricStatusCardProps { /** The registry metric id the generic assessment route keys on. */ @@ -39,6 +40,19 @@ export function MetricStatusCard({ const mounted = useMounted(); const { data: status, isLoading } = useInsightMetricStatus(metric, enabled); + // v1.21.0 (C4 H2) — narrow the Coach hand-off to this metric's snapshot + // source when one exists; the registry id is a `MeasurementType`-style + // token, which `scopeSourceFromMetricKey` resolves for the metrics that + // carry a snapshot block. Unmapped metrics still get a metric-named + // opener, just against the default snapshot. + const coachSource = scopeSourceFromMetricKey(metric); + const coachQuestion = `Walk me through my ${metric + .toLowerCase() + .replace( + /_/g, + " ", + )} assessment — what does it mean and what should I do about it?`; + return ( `: the hook is // `enabled: isAuthenticated && enabled`, and both inputs can settle // before a late-hydrating boundary replays its first render — the diff --git a/src/components/insights/period-narrative-card.tsx b/src/components/insights/period-narrative-card.tsx index 89647228f..dd2ccce1d 100644 --- a/src/components/insights/period-narrative-card.tsx +++ b/src/components/insights/period-narrative-card.tsx @@ -8,6 +8,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; import { SectionHeading } from "@/components/insights/section-heading"; +import { AskCoachAction } from "@/components/insights/ask-coach-action"; import { Popover, PopoverContent, @@ -225,6 +226,21 @@ export function PeriodNarrativeCard({ })}

) : null} + + {/* v1.21.0 (C4 H2) — hand the period summary to the Coach. It spans + the whole picture for the window, so no scope: the default + snapshot reads best. */} + {narrative ? ( +
+ +
+ ) : null} ); diff --git a/src/components/insights/recommendation-card.tsx b/src/components/insights/recommendation-card.tsx index 794d44ad3..3ec2a930f 100644 --- a/src/components/insights/recommendation-card.tsx +++ b/src/components/insights/recommendation-card.tsx @@ -16,6 +16,8 @@ import { importWithRetry } from "@/lib/retry-import"; import { getMedicalReferenceById } from "@/lib/ai/medical-references"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; import type { Locale } from "@/lib/i18n/config"; +import { AskCoachAction } from "./ask-coach-action"; +import { scopeSourceFromMetricKey } from "./coach-metric-scope"; import { RecommendationFeedback, type RecommendationFeedbackSeverity, @@ -349,6 +351,14 @@ export function RecommendationCard({ } : null; + // v1.21.0 (C4 H2) — reverse-direction Coach hand-off. The opener + // quotes the rec so the conversation lands on this exact suggestion; + // when the rec carries a metric source, scope the snapshot to it. + const coachScopeSource = scopeSourceFromMetricKey(norm.metricSource?.type); + const coachQuestion = `Tell me more about this recommendation: "${stripChartTokens( + norm.text, + )}" — why does it apply to me, and what should I do about it?`; + return (
)} + + {/* v1.21.0 (C4 H2) — discreet "ask the Coach about this rec" link, + aligned under the rec text (the number gutter is `ml-6`). */} +
+ +
); } diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx index 95e44c0ea..ec21ae39e 100644 --- a/src/components/insights/sub-page-shell.tsx +++ b/src/components/insights/sub-page-shell.tsx @@ -15,6 +15,8 @@ import { } from "@/components/ui/tooltip"; import { TargetAdjustButton } from "@/components/insights/target-adjust-button"; import { TargetAdjustProvider } from "@/lib/insights/target-adjust-context"; +import { metricScopeFromExplainer } from "@/components/insights/coach-metric-scope"; +import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; @@ -110,9 +112,15 @@ export interface SubPageShellProps { * v1.18.6 (CCH-04) — RETIRED as a rendered control. With the Coach FAB * anchored bottom-right on every authenticated page, the per-metric * Coach icon was a redundant second entry point, so the shell no longer - * paints anything for it. The prop stays on the signature (every routed - * category page still passes it) to avoid a churny rename across ~14 - * call sites; it is intentionally inert. + * paints anything for it. + * + * v1.21.0 (C4 H1) — re-purposed without re-adding any control. When set + * (and `explainerMetric` maps to a Coach source), the shell registers + * the page's metric as the launch context's ambient scope while it is + * mounted. The global FAB then opens a conversation pre-scoped to this + * metric with a data-aware seed question — so drilling into BP / weight + * / sleep and tapping the FAB no longer opens a blank chat. Still no + * second header affordance: the FAB stays the single entry point. */ coachLaunch?: boolean; /** @@ -137,14 +145,15 @@ export function SubPageShell({ focusOnMount = false, statStrip, diversityNudge, - // v1.18.6 (CCH-04) — `coachLaunch` is intentionally not destructured: - // the prop is still accepted on the type (call sites pass it) but the - // shell no longer renders a Coach control. The FAB is the single Coach - // entry point app-wide. + // v1.21.0 (C4 H1) — `coachLaunch` no longer renders a control (CCH-04 + // stands) but now gates whether the page registers its metric as the + // Coach launch context's ambient scope, so the FAB opens contextual. + coachLaunch = false, showAllValuesType, children, }: SubPageShellProps) { const { t } = useTranslations(); + const launch = useCoachLaunch(); // v1.10.2 — the "show all readings" link carries the originating metric // page as a `from` param so the values sub-page can offer a back-link to // where the user drilled in from (e.g. `weight → show all values → back to @@ -171,6 +180,28 @@ export function SubPageShell({ return () => window.cancelAnimationFrame(handle); }, [focusOnMount]); + // v1.21.0 (C4 H1) — register the page's metric as the Coach launch + // context's ambient scope while the sub-page is mounted, so the global + // FAB opens a conversation pre-scoped to this metric with a data-aware + // seed question. Only when the page opted in (`coachLaunch`) and its + // `explainerMetric` maps to a snapshot source; mobility / gait micro- + // metrics with no map entry leave the FAB on its default snapshot. The + // cleanup lifts the scope on navigation away. + const registerScope = launch?.registerScope; + useEffect(() => { + if (!coachLaunch || !registerScope) return; + const resolved = metricScopeFromExplainer(explainerMetric); + if (!resolved) return; + return registerScope( + { + metric: resolved.metric, + also: resolved.also, + window: resolved.window, + }, + resolved.question, + ); + }, [coachLaunch, registerScope, explainerMetric]); + return ( // The target-adjust provider bridges the header gear (rendered just // below) to the per-metric `` opened from the diff --git a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx index 9b400dcfd..b45b9faac 100644 --- a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx @@ -271,6 +271,10 @@ describe("Coach disable cascade invariant", () => { const KNOWN_COACH_GATE_SITES: ReadonlyArray = [ // Coach-bearing surfaces mounted directly by the cascade fixture. "src/components/insights/coach-launch-button.tsx", + // v1.21.0 (C4 H2) — the reverse-direction "Ask the Coach about this" + // card affordance gates on `flags.coach` (operator master) like the + // launch button, returning null when the operator turns the Coach off. + "src/components/insights/ask-coach-action.tsx", // v1.18.7 — hero-strip.tsx no longer reads `flags.coach`: its coach // action button + suggested-prompt strip were removed from the band. "src/components/insights/layout-coach-fab.tsx", diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index 2675bc92e..63942e5ec 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -282,6 +282,11 @@ describe("Coach per-user disableCoach invariant", () => { // fixture above; each one calls `useDisableCoach()` and short- // circuits to `null` when the per-user flag is on. "src/components/insights/coach-launch-button.tsx", + // v1.21.0 (C4 H2) — the reverse-direction "Ask the Coach about this" + // card affordance mirrors the launch-button gate: operator master flag + // OR per-user opt-out short-circuits it to `null` so a card never paints + // a dead Coach control. + "src/components/insights/ask-coach-action.tsx", // v1.18.7 — hero-strip.tsx no longer gates on the Coach flag: its // action button + suggested-prompt strip were removed, so it dropped // the `useDisableCoach` call entirely. diff --git a/src/lib/insights/coach-launch-context.tsx b/src/lib/insights/coach-launch-context.tsx index 1adfa9824..cf7754813 100644 --- a/src/lib/insights/coach-launch-context.tsx +++ b/src/lib/insights/coach-launch-context.tsx @@ -5,11 +5,12 @@ import { useCallback, useContext, useMemo, + useRef, useState, type ReactNode, } from "react"; -import type { CoachScopeSource } from "@/lib/ai/coach/types"; +import type { CoachScopeSource, CoachScopeWindow } from "@/lib/ai/coach/types"; /** * v1.4.27 R3d MB4 — Coach launch context. @@ -35,16 +36,30 @@ import type { CoachScopeSource } from "@/lib/ai/coach/types"; export interface CoachLaunchScope { /** * Optional metric the user is looking at when they open the Coach. - * Reserved for v1.4.28 so the drawer can pre-narrow the source rail - * to the active metric; ignored in v1.4.27. * * v1.4.28 R3c (BK-MED-2 / BK-F-M4) — narrowed from `string` to the * `CoachScopeSource` union so call sites cannot drift to a free-form * label that the sources rail then silently ignores. Adding a new * Apple-Health source to the rail (the union already covers the * v1.4.23 additions) automatically widens the allowed values here. + * + * v1.21.0 (C4 H1/H4) — now LIVE: the scope threads through the drawer + * into the chat request so a conversation opened from a metric surface + * or an insight card is pre-narrowed to the relevant source(s). The + * `void scope` discard is gone. */ metric?: CoachScopeSource; + /** + * Optional extra sources to include alongside `metric` — a correlation + * card spanning two metrics (e.g. weight × pulse) seeds both so the + * snapshot the Coach reads covers the relationship the card describes. + */ + also?: CoachScopeSource[]; + /** + * Optional day-window the conversation should anchor on. Defaults to + * the chat route's own `last30days` when omitted. + */ + window?: CoachScopeWindow; } interface CoachLaunchValue { @@ -52,8 +67,29 @@ interface CoachLaunchValue { open: boolean; /** Current prefill string (or null when the next open should start blank). */ prefill: string | null; + /** + * Scope the next/open conversation is narrowed to (or null for the + * default all-source snapshot). Set by `askCoach(prefill, scope)`, or + * inherited from the page's ambient scope when the FAB opens without an + * explicit one. `` forwards it to the drawer. + */ + scope: CoachLaunchScope | null; /** Open the drawer with an optional prefill + scope hint. */ askCoach: (prefill?: string | null, scope?: CoachLaunchScope) => void; + /** + * v1.21.0 (C4 H1) — register the metric surface the user is currently + * looking at so the global FAB (which calls `askCoach()` with no args) + * still opens the Coach pre-scoped to that page. The optional + * `seedPrefill` is the composer opener the FAB seeds when it inherits + * this ambient scope. Returns a cleanup that clears the ambient scope; a + * metric page calls it from an effect so the scope lifts when the user + * navigates away. This keeps the FAB the single app-wide entry point + * (CCH-04) while making it contextual. + */ + registerScope: ( + scope: CoachLaunchScope, + seedPrefill?: string | null, + ) => () => void; /** * Direct setter for the open state — the drawer's `onOpenChange` * consumes it on close. Kept (rather than collapsed into a @@ -74,30 +110,72 @@ export interface CoachLaunchProviderProps { export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) { const [open, setOpen] = useState(false); const [prefill, setPrefill] = useState(null); + // Scope the open conversation is narrowed to. `null` → default snapshot. + const [scope, setScope] = useState(null); + // Ambient scope + seed opener of the metric surface currently on screen. + // The FAB's `askCoach()` (no args) falls back to these so opening the + // Coach from a metric page still lands a pre-scoped, pre-seeded + // conversation without re-adding a per-metric header icon (CCH-04). Refs, + // not state — registering must not re-render the provider's subtree. + const ambientScopeRef = useRef(null); + const ambientPrefillRef = useRef(null); const askCoach = useCallback( - (nextPrefill?: string | null, scope?: CoachLaunchScope) => { - // `scope` is currently reserved for v1.4.28 (metric-narrow on the - // sources rail). Accept the parameter today so the call sites - // don't need to change shape when the rail starts honouring it. - void scope; - setPrefill(nextPrefill ?? null); + (nextPrefill?: string | null, nextScope?: CoachLaunchScope) => { + // v1.21.0 (C4 H1/H4) — scope is live. An explicit scope (insight + // card, metric-card affordance) wins; otherwise inherit the metric + // page's ambient scope so the FAB opens contextual to where the user + // is. `null` only when neither is present (true global launch). The + // composer seed follows the same precedence: explicit prefill, else + // the ambient page opener that pairs with the inherited scope. + const usingAmbientScope = nextScope === undefined; + setScope(nextScope ?? ambientScopeRef.current ?? null); + setPrefill( + nextPrefill ?? + (usingAmbientScope ? ambientPrefillRef.current : null) ?? + null, + ); setOpen(true); }, [], ); + const registerScope = useCallback( + (next: CoachLaunchScope, seedPrefill?: string | null) => { + ambientScopeRef.current = next; + ambientPrefillRef.current = seedPrefill ?? null; + return () => { + // Only clear if this registration is still the active one — guards + // against a fast route change where the next page registers before + // the previous page's cleanup runs. + if (ambientScopeRef.current === next) { + ambientScopeRef.current = null; + ambientPrefillRef.current = null; + } + }; + }, + [], + ); + const handleSetOpen = useCallback((next: boolean) => { setOpen(next); if (!next) { - // Drop the prefill on close so the next open starts blank. + // Drop the prefill + scope on close so the next open starts clean. setPrefill(null); + setScope(null); } }, []); const value = useMemo( - () => ({ open, prefill, askCoach, setOpen: handleSetOpen }), - [open, prefill, askCoach, handleSetOpen], + () => ({ + open, + prefill, + scope, + askCoach, + registerScope, + setOpen: handleSetOpen, + }), + [open, prefill, scope, askCoach, registerScope, handleSetOpen], ); return (