feat(insights): move the assessment-card Coach hand-off to a shared top-right icon

The per-metric assessment card carried the Coach hand-off as a bottom-right
text button, present on only some pages. Pin it to the card header's top-right
as a single icon button (no text label) with an accessible name and a tooltip,
and wire every assessment surface to it: the bespoke slug pages
(weight, BMI, pulse, blood pressure, mood), the generic metric scaffold, the
medication-compliance card, and the biomarker detail page already on the shared
card. Each opens the Coach with a localised, auto-sent opener narrowed to that
metric's snapshot source.

The button self-gates on the Coach triple (provider mounted, operator flag,
per-user opt-out) and the card only mounts it on the populated branch, so it
never advertises a control that cannot work. The auto-sent opener now
interpolates the localised measurement name rather than a raw token.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 15:13:23 +02:00
1 parent c0a91dbbcd
commit f599ba9a92
5 files changed
+131 -17

No files matched your search

+5
View File
@@ -295,6 +295,11 @@ export default function InsightsMedikamentePage() {
text={status?.summary ?? null}
hasProvider={status?.hasProvider ?? false}
updatedAt={status?.updatedAt ?? null}
coachQuestion={t("insights.coach.assessmentPrompt", {
metric: t("insights.medicationCompliance"),
})}
coachScope={{ metric: "compliance" }}
coachAutoSend
loading={isStatusLoading}
preparing={status?.preparing ?? false}
/>
@@ -3,6 +3,12 @@
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import {
@@ -96,3 +102,59 @@ export function AskCoachAction({
</Button>
);
}
/**
* v1.25 — icon-only Coach hand-off pinned to the TOP-RIGHT of an assessment
* card header. Same launch + gating contract as `<AskCoachAction>`, but no
* visible text label: the affordance is a single ghost icon button with an
* accessible name and a tooltip carrying the same copy (a11y — an icon button
* must announce its purpose).
*
* Self-gates on the same triple every Coach entry point uses (provider mounted
* in the tree + operator flag + per-user opt-out), so the card never paints a
* dead control. The assessment card only mounts it on the populated branch,
* where the provider is known good — the "no advertised-but-broken control"
* rule holds end-to-end.
*/
export function AskCoachIconButton({
question,
scope,
autoSend,
label,
className,
}: AskCoachActionProps) {
const { t } = useTranslations();
const launch = useCoachLaunch();
const flags = useFeatureFlags();
const disableCoach = useDisableCoach();
if (!launch) return null;
if (!flags.coach) return null;
if (disableCoach) return null;
const accessibleLabel = label ?? t("insights.coach.askAboutThis");
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-slot="ask-coach-icon"
aria-label={accessibleLabel}
onClick={() => launch.askCoach(question, scope, autoSend)}
className={cn(
"text-muted-foreground hover:text-foreground size-8",
className,
)}
>
<Sparkles className="size-4" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>{accessibleLabel}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
+20 -15
View File
@@ -13,7 +13,7 @@ import { ProseBlocks } from "@/components/insights/prose-blocks";
import { cn } from "@/lib/utils";
import { useAuth } from "@/hooks/use-auth";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { AskCoachAction } from "@/components/insights/ask-coach-action";
import { AskCoachIconButton } from "@/components/insights/ask-coach-action";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
// ─── Types ────────────────────────────────────────────────
@@ -236,8 +236,25 @@ export function InsightStatusCard({
{/* v1.11.5 — the top-right "cached" label was removed: it surfaced
an implementation detail and devalued the assessment. The card
still consumes the warm cache; it just no longer announces it.
v1.12.6 — the icon + title row is the canonical `<TileHeader>`. */}
<TileHeader icon={nodeIcon(icon)} title={title} />
v1.12.6 — the icon + title row is the canonical `<TileHeader>`.
v1.25 — the Coach hand-off lives in the header's right slot as a
single icon button (no text label, tooltip + accessible name),
shared across every assessment card. Rendered only when the
caller supplied an opener; the button self-gates on the Coach
triple, so it never paints a dead control. */}
<TileHeader
icon={nodeIcon(icon)}
title={title}
right={
coachQuestion ? (
<AskCoachIconButton
question={coachQuestion}
scope={coachScope}
autoSend={coachAutoSend}
/>
) : undefined
}
/>
</CardHeader>
<CardContent className="space-y-2">
{/* v1.4.27 — defence-in-depth strip. Cached status text from
@@ -249,18 +266,6 @@ 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}
autoSend={coachAutoSend}
/>
</div>
) : null}
</CardContent>
</Card>
);
+11 -2
View File
@@ -7,7 +7,10 @@ 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";
import {
scopeSourceFromMetricKey,
scopeSourceMetricLabelKey,
} from "@/components/insights/coach-metric-scope";
interface MetricStatusCardProps {
/** The registry metric id the generic assessment route keys on. */
@@ -46,8 +49,14 @@ export function MetricStatusCard({
// carry a snapshot block. Unmapped metrics still get a metric-named
// opener, just against the default snapshot.
const coachSource = scopeSourceFromMetricKey(metric);
// Prefer the EXISTING localised measurement-name key for the auto-sent
// preset so the opener reads in the user's language; fall back to the
// humanised token only for metrics without a localised name.
const coachLabelKey = scopeSourceMetricLabelKey(coachSource);
const coachQuestion = t("insights.coach.assessmentPrompt", {
metric: metric.toLowerCase().replace(/_/g, " "),
metric: coachLabelKey
? t(coachLabelKey)
: metric.toLowerCase().replace(/_/g, " "),
});
return (
@@ -9,6 +9,7 @@ import {
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import type { CoachScopeSource } from "@/lib/ai/coach/types";
interface SlugInsightStatusCardProps {
/** The bespoke metric slug the `/api/insights/<slug>-status` route keys on. */
@@ -17,6 +18,31 @@ interface SlugInsightStatusCardProps {
icon: ReactNode;
}
/**
* v1.25 — each bespoke slug maps to the Coach snapshot source it narrows the
* hand-off to, plus the EXISTING localised metric-name key the auto-sent
* preset interpolates. Resolving the label through `t()` keeps the opener in
* the user's language (never a raw enum token), shared with the icon-only
* Coach affordance the assessment card renders.
*/
const SLUG_COACH: Record<
InsightStatusMetric,
{ source: CoachScopeSource; labelKey: string }
> = {
"blood-pressure": {
source: "bp",
labelKey: "measurements.typeBloodPressure",
},
weight: { source: "weight", labelKey: "measurements.typeWeight" },
pulse: { source: "pulse", labelKey: "measurements.typePulse" },
bmi: { source: "bmi", labelKey: "measurements.typeBodyMassIndex" },
mood: { source: "mood", labelKey: "insights.moodSectionTitle" },
"medication-compliance": {
source: "compliance",
labelKey: "insights.medicationCompliance",
},
};
/**
* Shared mount for the bespoke per-metric assessment card.
*
@@ -36,6 +62,8 @@ export function SlugInsightStatusCard({
const mounted = useMounted();
const { data: status, isLoading } = useInsightStatus(slug);
const coach = SLUG_COACH[slug];
return (
<InsightStatusCard
title={t("insights.assessmentTitle")}
@@ -43,6 +71,11 @@ export function SlugInsightStatusCard({
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
updatedAt={status?.updatedAt ?? null}
coachQuestion={t("insights.coach.assessmentPrompt", {
metric: t(coach.labelKey),
})}
coachScope={{ metric: coach.source }}
coachAutoSend
// `!mounted ||` keeps the SSR pass and the hydration render on the
// same skeleton branch. The status query is `enabled: isAuthenticated`,
// and the auth query resolves from the early-hydrating shell — a