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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 13:55:37 +02:00
1 parent f9112925d2
commit 096c8c799b
20 files changed
+672 -74

No files matched your search

@@ -39,6 +39,19 @@ export interface CoachLaunchButtonProps {
* 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;
/**
@@ -58,6 +71,8 @@ export function CoachLaunchButton({
label,
prefill,
scope,
workoutId,
autoSend,
className,
variant = "inline",
}: CoachLaunchButtonProps) {
@@ -92,7 +107,9 @@ export function CoachLaunchButton({
data-slot="coach-launch-icon"
aria-label={accessibleLabel}
title={accessibleLabel}
onClick={() => launch.askCoach(prefill ?? null, scope)}
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" />
@@ -110,7 +127,9 @@ export function CoachLaunchButton({
variant="outline"
size="sm"
data-slot="coach-launch-inline"
onClick={() => launch.askCoach(prefill ?? null, scope)}
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" />
@@ -232,6 +232,15 @@ export interface CoachConversationProps {
* the loaded conversation's `attachments` + sticky `fenced` flag instead.
*/
initialDocumentId?: string | null;
/**
* v1.31.0 — the workout a FRESH conversation is scoped to ("Ask why" on the
* workout-detail page, or `/coach?workout=<id>`). Threads onto the first
* turn only, exactly like `launchScope`: once the thread exists the server
* has already pinned the workout's evidence section onto its snapshot, so
* re-sending it would be per-turn work that buys nothing. That gate is what
* keeps snapshot-once intact.
*/
initialWorkoutId?: string | null;
/**
* v1.28.52 (Documents R3) — hand the chrome an imperative getter for the
* live conversation id (parallel to `registerReset`). The drawer reads it at
@@ -258,6 +267,7 @@ export function CoachConversation({
initialConversationId,
autoOpenMostRecent = false,
initialDocumentId,
initialWorkoutId,
registerConversationIdGetter,
}: CoachConversationProps) {
const { t } = useTranslations();
@@ -628,12 +638,16 @@ export function CoachConversation({
// `guidedQuestion`) are suppressed on a fenced turn — that endpoint has no
// snapshot and no guided flow. `pendingAttachmentIds` travels ONLY on a
// brand-new conversation (first-turn attach).
// Same first-turn gate as `scope` — see `initialWorkoutId`.
const workoutId =
currentConversationId === null ? (initialWorkoutId ?? undefined) : undefined;
const wasFreshFenced = currentConversationId === null && fenced;
const resolvedId = await send.send({
conversationId: currentConversationId ?? undefined,
message: trimmed,
guidedQuestion: fenced ? undefined : (guidedQuestion ?? undefined),
scope: fenced ? undefined : scope,
workoutId: fenced ? undefined : workoutId,
fenced,
pendingAttachmentIds:
currentConversationId === null ? pendingAttachmentIds : undefined,
@@ -706,6 +720,10 @@ export function CoachConversation({
conversationId: currentConversationId ?? undefined,
message: trimmed,
scope: fenced ? undefined : scope,
workoutId:
fenced || currentConversationId !== null
? undefined
: (initialWorkoutId ?? undefined),
fenced,
pendingAttachmentIds:
currentConversationId === null ? pendingAttachmentIds : undefined,
@@ -37,14 +37,22 @@ export {
* so the exact thread re-opens on the page, scope and all.
* - else a document scope (a doc-scoped drawer maximized before its first
* turn) → `/coach?doc=<id>` so the fresh page chat stays scoped.
* - else a workout scope (v1.31.0, same pre-first-turn case) →
* `/coach?workout=<id>`.
* - else the plain `/coach` new-chat surface.
*
* A document scope wins over a workout scope: the two are never set together
* by any launch call site, and a fenced document conversation has the stricter
* transport, so it is the safer arm to prefer if they ever collided.
*/
export function coachMaximizeHref(
conversationId: string | null,
documentId?: string | null,
workoutId?: string | null,
): string {
if (conversationId) return `/coach?c=${conversationId}`;
if (documentId) return `/coach?doc=${documentId}`;
if (workoutId) return `/coach?workout=${workoutId}`;
return "/coach";
}
@@ -87,6 +95,13 @@ export interface CoachDrawerProps {
* exists yet (`/coach?doc=<id>`).
*/
documentId?: string | null;
/**
* v1.31.0 — the workout this drawer chat is scoped to. Threads onto the
* FIRST turn only; the maximize control preserves it when no thread exists
* yet. Unlike `documentId` it does not switch the transport — the workout
* evidence is a numbers-only server projection on the normal Coach route.
*/
workoutId?: string | null;
}
export function CoachDrawer({
@@ -96,6 +111,7 @@ export function CoachDrawer({
scope,
autoSend,
documentId,
workoutId,
}: CoachDrawerProps) {
const { t } = useTranslations();
const router = useRouter();
@@ -146,13 +162,14 @@ export function CoachDrawer({
const href = coachMaximizeHref(
conversationIdGetterRef.current?.() ?? null,
documentId,
workoutId,
);
// Close the drawer (aborts any in-flight stream via `handleOpenChange`)
// then route to the dedicated page.
onOpenChange(false);
resetRef.current?.();
router.push(href);
}, [onOpenChange, router, documentId]);
}, [onOpenChange, router, documentId, workoutId]);
// v1.21.4 (Coach-UI B) — the drawer's "Conversations" affordance now hands
// off to the dedicated conversation-history page (`/coach/conversations`),
@@ -198,6 +215,7 @@ export function CoachDrawer({
// document (vault "Ask the Coach"); the send path is unchanged (the
// fenced document endpoint), only WHERE it renders (drawer vs page).
initialDocumentId={documentId}
initialWorkoutId={workoutId}
autoFocusComposer
registerReset={registerReset}
// Lets the maximize control read the live thread id at hand-off time.
@@ -495,6 +495,14 @@ export interface SendCoachMessageParams {
* `POST /api/insights/chat/{id}/attachments`.
*/
pendingAttachmentIds?: string[];
/**
* v1.31.0 — the workout a FRESH conversation is scoped to. Travels ONLY on
* the first turn (the caller gates on a null conversation id) and never on
* the fenced path, which has no snapshot to pin a section onto. The server
* ignores it on any later turn regardless, so snapshot-once holds even if a
* client re-sent it.
*/
workoutId?: string;
}
/**
@@ -541,6 +549,7 @@ export function resolveCoachSendTarget(params: SendCoachMessageParams): {
locale: params.locale,
scope: params.scope,
guidedQuestion: params.guidedQuestion,
workoutId: params.workoutId,
}),
};
}
@@ -16,6 +16,7 @@ import {
type AnatomyContributor,
} from "./score-anatomy-view";
import type { RingHue } from "./ring-hues";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import { METRIC_PROVENANCE } from "./standards";
/**
@@ -61,6 +62,36 @@ export interface CompositeScoreAnatomyProps {
* leans the same colour (continuity from the tile tap). Mirrors the per-metric
* `hue` the wellness strip passes its `RingTile`s.
*/
/**
* v1.31.0 — the Coach scope each derived-score sheet hands off to.
*
* The score sheets were the one assessment surface with NO outbound edge: they
* render the same `<InsightStatusCard>` as every metric page but passed
* neither an opener nor a scope, so the card painted no action at all and the
* sheet read one-way. A composite has no snapshot block of its own — it is a
* synthesis — so each maps to the INPUTS that drive it, the same way the
* recovery metric page already anchors on HRV + resting HR + sleep.
*/
const METRIC_COACH_SCOPE: Record<AnatomyMetricId, CoachLaunchScope> = {
SLEEP_SCORE: { metric: "sleep", also: ["hrv", "resting_hr"] },
READINESS: {
metric: "hrv",
also: ["resting_hr", "sleep"],
window: "last7days",
},
RECOVERY_SCORE: {
metric: "hrv",
also: ["resting_hr", "sleep"],
window: "last7days",
},
STRESS_SCORE: { metric: "hrv", also: ["resting_hr"], window: "last7days" },
STRAIN_SCORE: {
metric: "workouts",
also: ["active_energy", "pulse"],
window: "last7days",
},
};
const METRIC_HUE: Record<AnatomyMetricId, RingHue> = {
SLEEP_SCORE: "sleep",
READINESS: "readiness",
@@ -241,6 +272,14 @@ export function CompositeScoreAnatomy({
text={assessment.text}
hasProvider
updatedAt={assessment.updatedAt}
// The outbound edge. Same shared opener + auto-send hand-off the
// metric pages use, so the answer lands directly instead of only
// seeding the composer.
coachQuestion={t("insights.coach.assessmentPrompt", {
metric: title,
})}
coachScope={METRIC_COACH_SCOPE[metric]}
coachAutoSend
/>
) : null}
</div>
@@ -63,6 +63,10 @@ export function LayoutCoachMount() {
// "Ask the Coach" action opens the REAL fenced conversation in the drawer
// scoped to that document (maximizing then preserves the scope).
documentId={launch.documentId}
// v1.31.0 — carry the workout scope so the workout-detail "Ask why"
// action opens a conversation whose first turn reads that session's own
// numbers.
workoutId={launch.workoutId}
/>
);
}