mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
c0a91dbbcd
commit
f599ba9a92
5 files changed
+131
-17
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user