mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
merge(COACHUI): v1.21.0
This commit is contained in:
commit
37dfce2104
26 files changed
+702
-43
No files matched your search
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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 `<AskCoachAction>` 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", () => ({
|
||||
|
||||
@@ -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 `<AskCoachAction>` 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", () => ({
|
||||
|
||||
@@ -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 `<AskCoachAction>` 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: () => ({
|
||||
|
||||
@@ -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 <CoachLaunchButton> / <LayoutCoachFab>: 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 (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot="ask-coach-action"
|
||||
onClick={() => launch.askCoach(question, scope)}
|
||||
className={cn(
|
||||
// Discreet: muted text, hover lifts to foreground, no fill. Tight
|
||||
// height so it sits as a quiet footer link, not a primary CTA.
|
||||
"text-muted-foreground hover:text-foreground h-8 gap-1.5 px-2 text-xs",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Sparkles className="size-3.5" aria-hidden="true" />
|
||||
<span>{accessibleLabel}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,9 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CorrelationResult } from "@/lib/insights/correlations";
|
||||
import type { CoachScopeSource } from "@/lib/ai/coach/types";
|
||||
import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge";
|
||||
import { AskCoachAction } from "./ask-coach-action";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B3 — single Correlation card.
|
||||
@@ -71,10 +73,36 @@ const CONFIDENCE_LABEL_KEY: Record<"low" | "moderate" | "high", string> = {
|
||||
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<CorrelationResult["kind"], string> = {
|
||||
"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 (
|
||||
<Card
|
||||
@@ -134,23 +162,31 @@ export function CorrelationCard({ result }: CorrelationCardProps) {
|
||||
window: t("insights.correlationRow.sourceWindowLast30"),
|
||||
})}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
// Outline variant so the disabled placeholder reads as
|
||||
// "this is on the roadmap" rather than "the system is
|
||||
// broken right now". Flip back to default when the
|
||||
// 7-day experiment feature ships in v1.5.
|
||||
variant="outline"
|
||||
disabled
|
||||
data-slot="correlation-card-cta"
|
||||
className="gap-1.5"
|
||||
title={t("insights.correlationRow.experimentTooltip")}
|
||||
aria-label={t("insights.correlationRow.experimentTooltip")}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("insights.correlationRow.experimentCta")}</span>
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
// Outline variant so the disabled placeholder reads as
|
||||
// "this is on the roadmap" rather than "the system is
|
||||
// broken right now". Flip back to default when the
|
||||
// 7-day experiment feature ships in v1.5.
|
||||
variant="outline"
|
||||
disabled
|
||||
data-slot="correlation-card-cta"
|
||||
className="gap-1.5"
|
||||
title={t("insights.correlationRow.experimentTooltip")}
|
||||
aria-label={t("insights.correlationRow.experimentTooltip")}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("insights.correlationRow.experimentCta")}</span>
|
||||
</Button>
|
||||
{/* v1.21.0 (C4 H2) — the live Coach hand-off sits beside the
|
||||
roadmap CTA: it scopes to both sources in the pair. */}
|
||||
<AskCoachAction
|
||||
question={COACH_QUESTION_BY_KIND[result.kind]}
|
||||
scope={coachScope}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
|
||||
@@ -25,6 +25,7 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ListRow } from "@/components/ui/list-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { AskCoachAction } from "@/components/insights/ask-coach-action";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
@@ -457,6 +458,12 @@ export function DailyBriefing({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 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. */}
|
||||
<div className="flex justify-end">
|
||||
<AskCoachAction question="Walk me through today's briefing — what should I focus on, and is anything worth acting on?" />
|
||||
</div>
|
||||
</div>
|
||||
) : noProvider ? (
|
||||
// v1.15.20 — no provider configured anywhere: a regenerate CTA
|
||||
|
||||
@@ -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. */}
|
||||
<div className="mt-auto flex justify-end pt-1">
|
||||
<AskCoachAction
|
||||
question={`Why is my health score ${score} out of 100, and what would move it the most?`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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. */}
|
||||
<StatusBody text={stripChartTokens(text)} />
|
||||
<LastUpdatedFooter updatedAt={updatedAt} />
|
||||
{/* 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 ? (
|
||||
<div className="flex justify-end">
|
||||
<AskCoachAction question={coachQuestion} scope={coachScope} />
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<InsightStatusCard
|
||||
title={t("insights.assessmentTitle")}
|
||||
@@ -46,6 +60,8 @@ export function MetricStatusCard({
|
||||
text={status?.text ?? null}
|
||||
hasProvider={status?.hasProvider ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
coachQuestion={coachQuestion}
|
||||
coachScope={coachSource ? { metric: coachSource } : undefined}
|
||||
// Same hydration gate as `<SlugInsightStatusCard>`: the hook is
|
||||
// `enabled: isAuthenticated && enabled`, and both inputs can settle
|
||||
// before a late-hydrating boundary replays its first render — the
|
||||
|
||||
@@ -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({
|
||||
})}
|
||||
</p>
|
||||
) : 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 ? (
|
||||
<div className="flex justify-end">
|
||||
<AskCoachAction
|
||||
question={
|
||||
period === "week"
|
||||
? "Walk me through how my last week went across my health metrics — what stands out?"
|
||||
: "Walk me through how my last month went across my health metrics — what stands out?"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
data-slot="rec-card"
|
||||
@@ -435,6 +445,15 @@ export function RecommendationCard({
|
||||
<CitationFootnote referenceId={norm.referenceId} locale={locale} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* v1.21.0 (C4 H2) — discreet "ask the Coach about this rec" link,
|
||||
aligned under the rec text (the number gutter is `ml-6`). */}
|
||||
<div className="mt-1 ml-5">
|
||||
<AskCoachAction
|
||||
question={coachQuestion}
|
||||
scope={coachScopeSource ? { metric: coachScopeSource } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -271,6 +271,10 @@ describe("Coach disable cascade invariant", () => {
|
||||
const KNOWN_COACH_GATE_SITES: ReadonlyArray<string> = [
|
||||
// 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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user