diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index e80f5133e..e95c6a306 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -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} /> diff --git a/src/components/insights/ask-coach-action.tsx b/src/components/insights/ask-coach-action.tsx index ffc97490d..08aa56d33 100644 --- a/src/components/insights/ask-coach-action.tsx +++ b/src/components/insights/ask-coach-action.tsx @@ -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({ ); } + +/** + * v1.25 — icon-only Coach hand-off pinned to the TOP-RIGHT of an assessment + * card header. Same launch + gating contract as ``, 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 ( + + + + + + {accessibleLabel} + + + ); +} diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx index 03eeca56e..d3793e74e 100644 --- a/src/components/insights/insight-status-card.tsx +++ b/src/components/insights/insight-status-card.tsx @@ -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 ``. */} - + v1.12.6 — the icon + title row is the canonical ``. + 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. */} + + ) : undefined + } + /> {/* v1.4.27 — defence-in-depth strip. Cached status text from @@ -249,18 +266,6 @@ 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/metric-status-card.tsx b/src/components/insights/metric-status-card.tsx index 524401a96..7a6a30956 100644 --- a/src/components/insights/metric-status-card.tsx +++ b/src/components/insights/metric-status-card.tsx @@ -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 ( diff --git a/src/components/insights/slug-insight-status-card.tsx b/src/components/insights/slug-insight-status-card.tsx index a0d30de28..d73bd2080 100644 --- a/src/components/insights/slug-insight-status-card.tsx +++ b/src/components/insights/slug-insight-status-card.tsx @@ -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/-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 (