Files
HealthLog/src/components/insights/coach-launch-button.tsx
T
Marc-André Bombeck 096c8c799b feat(coach): give every read-only assessment surface a way out
Status cards and derived-score sheets read one way: they state a verdict and
end there. The metric pages already carried the hand-off; the score sheets
passed neither an opener nor a scope, so they painted no action at all.

- Score sheets (sleep / readiness / recovery / stress / strain) gain the same
  auto-sent opener the metric pages use, each scoped to the inputs that drive
  the composite rather than to a block it has no equivalent of.
- Workout detail's coach launch carries the workout itself. The chat request
  gains an optional workoutId; on the FIRST turn the route narrows by
  { id, userId } and pins ONE bounded evidence section onto the snapshot.
  Later turns reuse the pinned snapshot, so per-turn work stays constant in
  conversation length.
- /coach?workout=<id> covers the full-page and maximize paths.

The workout block deliberately does not take the fenced transport the stored-
document path takes: a workout row is the user's own sensor record, and the
projection admits no free text. That is enforced, not assumed — sport_type is
a TEXT column, so it is re-asserted against the closed sport union, metadata
is excluded apart from its numeric zone durations read through a Zod slice,
and a recursive guard throws on any non-numeric leaf.

Own-history sport averages move to src/lib/workouts/sport-context.ts so the
narrative and the detail page cannot quote different figures.
2026-07-19 13:55:37 +02:00

140 lines
5.2 KiB
TypeScript

"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 type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
/**
* Inline desktop Coach launch button.
*
* v1.4.28 R3c — the legacy implementation rendered BOTH a sticky FAB
* (visible `<lg`) and the inline pill (`lg+`) from the same component.
* Every sub-page that mounted `<CoachLaunchButton>` therefore painted a
* second FAB into the DOM, collapsing to one visible button only
* because of `fixed` positioning — the duplicates still landed in the
* a11y tree. The FAB now lives once at the layout level (see
* `<LayoutCoachFab>` in `src/app/insights/layout.tsx`); this component
* keeps only the inline `lg+` pill that sub-pages mount inside their
* action rows.
*
* The button is a thin wrapper around `useCoachLaunch()` — it only
* renders when the context provider is mounted (so dropping the button
* onto a non-Insights page is a no-op rather than a crash).
*/
export interface CoachLaunchButtonProps {
/** Optional override for the visual label. Defaults to the shared CTA. */
label?: string;
/** Optional prefill seed for the next Coach turn. */
prefill?: string;
/**
* 2026-07-17 UX-flows audit F6-1 — optional scope so the conversation this
* button opens narrows to the relevant source(s) instead of starting from
* a blank, unscoped chat. Passed straight through to `askCoach`'s second
* argument (mirrors every other ambient-scope call site).
*/
scope?: CoachLaunchScope;
/**
* v1.31.0 — optional workout scope. When set, the opened conversation's
* FIRST turn carries one bounded, numbers-only evidence section about that
* session, so "Ask why" on a workout answers about THAT workout rather than
* about the user's training in general.
*/
workoutId?: string;
/**
* v1.31.0 — auto-send the prefill as the conversation's first turn instead
* of only seeding the composer, so the answer lands directly. Mirrors
* `<AskCoachAction>`'s flag; defaults to false (seed-only).
*/
autoSend?: boolean;
/** Optional className passthrough for inline overrides. */
className?: string;
/**
* v1.8.6 — render the launch as an icon-only affordance instead of the
* labelled pill. The sub-page shell mounts this variant at heading
* height in the page header so "Coach fragen" sits top-right, aligned
* with the title, rather than at the foot of the page. The accessible
* label still resolves the shared CTA copy via `aria-label`. The icon
* variant stays visible across breakpoints — it is the header's own
* Coach entry, not the `lg+`-only inline pill the old foot placement
* used (the mobile FAB covers `<lg` for that legacy surface).
*/
variant?: "inline" | "icon";
}
export function CoachLaunchButton({
label,
prefill,
scope,
workoutId,
autoSend,
className,
variant = "inline",
}: CoachLaunchButtonProps) {
const { t } = useTranslations();
const launch = useCoachLaunch();
const flags = useFeatureFlags();
const disableCoach = useDisableCoach();
if (!launch) {
// The button only makes sense beneath the provider. Render nothing
// so the sub-page doesn't paint a dead control.
return null;
}
// v1.4.31 — operator can hide the Coach surface app-wide.
if (!flags.coach) return null;
// v1.4.47 W3 — per-user opt-out is a peer gate to the operator's
// flag; either being off hides the pill entirely. See
// `<LayoutCoachFab>` for the matching FAB gate.
if (disableCoach) return null;
const accessibleLabel = label ?? t("insights.heroActionAskCoach");
if (variant === "icon") {
// v1.8.6 — heading-height icon button mounted by the sub-page shell.
// Icon-only so it reads as a compact action beside the title; the
// CTA copy moves to `aria-label` + a native tooltip.
return (
<Button
type="button"
variant="ghost"
size="icon"
data-slot="coach-launch-icon"
aria-label={accessibleLabel}
title={accessibleLabel}
onClick={() =>
launch.askCoach(prefill ?? null, scope, autoSend, null, workoutId)
}
className={cn("text-muted-foreground hover:text-foreground", className)}
>
<Sparkles className="size-4" aria-hidden="true" />
</Button>
);
}
// Inline header-style action. v1.16.1 — visible on every viewport:
// the layout-level FAB is no longer a permanent launcher (it only
// surfaces an unseen proactive nudge), so the pill is the everyday
// Coach entry point on mobile too.
return (
<Button
type="button"
variant="outline"
size="sm"
data-slot="coach-launch-inline"
onClick={() =>
launch.askCoach(prefill ?? null, scope, autoSend, null, workoutId)
}
className={cn("inline-flex h-10 gap-2 self-end", className)}
>
<Sparkles className="size-4" aria-hidden="true" />
<span>{accessibleLabel}</span>
</Button>
);
}