feat(coach): carry metric scope into the Coach launch

Make the launch context's metric scope live instead of discarding it.
A conversation opened from a metric surface now narrows its snapshot to
that metric with a data-aware seed question, and the global FAB inherits
the page's ambient scope so drilling into a metric and tapping it no
longer opens a blank chat. The scope threads through the drawer into the
first turn of a fresh conversation.
This commit is contained in:
Marc-André Bombeck committed 2026-06-26 12:37:54 +02:00
1 parent 8c09970d4a
commit 44796d1c0e
7 files changed
+431 -20

No files matched your search

@@ -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:
* - `<SubPageShell>` 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.<metric>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<string, CoachMetricScope> = {
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;
}
@@ -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();
});
});
@@ -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 {
* `<SheetDescription>` (required for the dialog's accessible name) while
* the page mounts a plain `<h1>` / `<p>`.
*/
/**
* 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 `<SheetTitle>`, 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({
@@ -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) {
<CoachConversation
surface="drawer"
prefill={prefill}
launchScope={scope}
autoFocusComposer
registerReset={registerReset}
// v1.16.1 — the "Conversations" affordance hands off to the
@@ -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}
/>
);
}
+38 -7
View File
@@ -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 `<TargetEditSheet>` opened from the
+90 -12
View File
@@ -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. `<LayoutCoachMount>` 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<boolean>(false);
const [prefill, setPrefill] = useState<string | null>(null);
// Scope the open conversation is narrowed to. `null` → default snapshot.
const [scope, setScope] = useState<CoachLaunchScope | null>(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<CoachLaunchScope | null>(null);
const ambientPrefillRef = useRef<string | null>(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<CoachLaunchValue>(
() => ({ open, prefill, askCoach, setOpen: handleSetOpen }),
[open, prefill, askCoach, handleSetOpen],
() => ({
open,
prefill,
scope,
askCoach,
registerScope,
setOpen: handleSetOpen,
}),
[open, prefill, scope, askCoach, registerScope, handleSetOpen],
);
return (