feat(coach): surface plan proposals for confirmation and add the plans page

The Coach has extracted goal / if-then plan proposals since v1.21.3 —
written as status "proposed", never activated without the user — but no
surface ever showed them, so nothing could be confirmed and the active-plan
memory block stayed permanently empty. This lands the missing user half:

- Chat thread: proposal confirm cards at the tail of the open conversation
  (proposals land asynchronously via the post-turn memory-refresh worker,
  so the block slow-polls the proposed list while the thread is mounted and
  only shows proposals born in that conversation). Accept activates the
  plan — it enters the Coach's snapshot memory from the next turn on;
  decline soft-deletes the proposal so it never activates and stops
  counting against the per-user cap. A settled card collapses to a
  one-line confirmation.
- /coach/plans: the management ledger, a sibling of /coach/conversations
  reachable from the composer's + menu. Groups proposals (confirm /
  decline), standing plans (goal met / end), and the settled history
  (remove). No prose editor by design — the extractor is the only writer
  of plan text and the PATCH contract only moves the lifecycle.
- Shared use-coach-plans hook + centralised coach-plans query keys, so the
  thread cards and the page invalidate each other's reads.
- Both coach-gate invariants (operator master flag, per-user opt-out)
  register the new page; i18n keys land in all six locales.

SSR tests pin the card contract: only same-conversation proposals render,
undecryptable rows are skipped, both actions are real buttons.
This commit is contained in:
Marc-André Bombeck committed 2026-07-05 20:00:38 +02:00
1 parent acd2f29da2
commit f5b0ec431e
15 files changed
+870

No files matched your search

@@ -0,0 +1,82 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { CoachPlanDTO } from "@/hooks/use-coach-plans";
import { PlanProposalCards } from "../plan-proposal-card";
/**
* The plan-proposal confirm cards at the thread tail. SSR string render
* against a pre-seeded query cache (the `status:proposed` slot the component
* reads), so the tests pin the render contract without a network layer:
* only proposals born in the OPEN conversation render, the prose reads in
* the card, and both actions are present as real buttons.
*/
function plan(overrides: Partial<CoachPlanDTO> = {}): CoachPlanDTO {
return {
id: "p1",
metric: "WEIGHT",
ifCue: "every morning",
thenAction: "weigh in before breakfast",
target: "steady trend by autumn",
status: "proposed",
reviewDate: null,
sourceConversationId: "conv-1",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
...overrides,
};
}
function render(conversationId: string, plans: CoachPlanDTO[]) {
const client = new QueryClient();
client.setQueryData(queryKeys.coachPlans("status:proposed"), plans);
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<PlanProposalCards conversationId={conversationId} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<PlanProposalCards>", () => {
it("renders nothing when the conversation has no open proposal", () => {
expect(render("conv-1", [])).toBe("");
});
it("renders a confirm card for a proposal born in the open conversation", () => {
const html = render("conv-1", [plan()]);
expect(html).toContain('data-slot="coach-plan-proposal-card"');
// The plan prose is content — visible in the card.
expect(html).toContain("every morning");
expect(html).toContain("weigh in before breakfast");
expect(html).toContain("steady trend by autumn");
expect(html).toContain("WEIGHT");
// Both actions render as real buttons.
expect(html).toContain('data-slot="coach-plan-proposal-accept"');
expect(html).toContain('data-slot="coach-plan-proposal-decline"');
});
it("hides proposals born in a different conversation", () => {
const html = render("conv-2", [plan()]);
expect(html).toBe("");
});
it("skips a proposal whose prose is undecryptable (null fields)", () => {
const html = render("conv-1", [plan({ ifCue: null, thenAction: null })]);
expect(html).toBe("");
});
it("renders one card per open proposal", () => {
const html = render("conv-1", [
plan(),
plan({ id: "p2", ifCue: "after dinner", thenAction: "short walk" }),
]);
expect(html.match(/coach-plan-proposal-card/g)?.length).toBe(2);
expect(html).toContain("after dinner");
});
});
@@ -18,6 +18,7 @@ import {
Send,
Settings,
Square,
Target,
} from "lucide-react";
import { toast } from "sonner";
@@ -517,6 +518,12 @@ export function CoachInput({
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</DropdownMenuItem>
<DropdownMenuItem asChild data-slot="coach-input-action-plans">
<Link href="/coach/plans">
<Target className="size-4" aria-hidden="true" />
{t("coach.plans.title")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild data-slot="coach-input-action-settings">
<Link href="/settings/ai">
<Settings className="size-4" aria-hidden="true" />
@@ -46,6 +46,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import { SourceChips } from "./source-chips";
import { PlanProposalCards } from "./plan-proposal-card";
import { ReminderSuggestionCard } from "./reminder-suggestion-card";
import { SuggestedActionCard } from "./suggested-action-card";
import { StreamedProse } from "./streamed-prose";
@@ -1026,6 +1027,14 @@ export function MessageThread({
{placement.tail.map((i) => (
<Fragment key={i.key}>{i.node}</Fragment>
))}
{/* Plan-proposal confirm cards for THIS conversation. The extractor
runs after the turn (memory-refresh worker), so the block appears at
the thread tail once the proposal lands rather than under a specific
bubble; the component itself slow-polls and renders nothing while
the conversation has no open proposal. */}
{conversation ? (
<PlanProposalCards conversationId={conversation.id} />
) : null}
</div>
);
}
@@ -0,0 +1,195 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Check, Loader2, Target, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import {
useCoachPlans,
useCoachPlanMutations,
type CoachPlanDTO,
} from "@/hooks/use-coach-plans";
/**
* v1.27.x — the confirm cards for Coach plan proposals, rendered at the tail
* of the open conversation's thread.
*
* The extractor (memory-refresh worker, runs AFTER the turn) writes a plan as
* `status: "proposed"`; nothing becomes a standing plan until the user
* confirms it here (or on `/coach/plans`). Because the proposal lands
* asynchronously, the block slow-polls the proposed list while the thread is
* mounted (TanStack pauses the interval in background tabs) and only shows
* proposals whose `sourceConversationId` is the OPEN conversation — a
* proposal born in an older thread stays on the management page instead of
* popping into an unrelated chat.
*
* Accept → PATCH `{status:"active"}` (the plan enters the Coach's snapshot
* memory block from the next turn on). Decline → DELETE (soft-delete; the
* proposal never activates and stops counting against the per-user cap).
* A settled card collapses to a one-line confirmation for the session so the
* refetch dropping the row from the proposed list cannot double-prompt.
*/
/** Slow poll (ms) — proposals arrive via the post-turn background worker. */
const PROPOSAL_POLL_MS = 20_000;
type SettledOutcome = "accepted" | "declined";
export function PlanProposalCards({
conversationId,
}: {
conversationId: string;
}) {
const { t } = useTranslations();
const [settled, setSettled] = useState<
Array<{ id: string; outcome: SettledOutcome }>
>([]);
const query = useCoachPlans({
filter: { status: "proposed" },
refetchInterval: PROPOSAL_POLL_MS,
});
// Quiet on error by design: the thread is the Coach's surface, and a failed
// enhancement read must never interrupt the conversation with an error card.
const proposals = (query.data ?? []).filter(
(p) =>
p.sourceConversationId === conversationId &&
p.ifCue !== null &&
p.thenAction !== null &&
!settled.some((s) => s.id === p.id),
);
if (proposals.length === 0 && settled.length === 0) return null;
return (
<div data-slot="coach-plan-proposals" className="flex flex-col gap-2.5">
{proposals.map((plan) => (
<PlanProposalCard
key={plan.id}
plan={plan}
onSettled={(outcome) =>
setSettled((prev) => [...prev, { id: plan.id, outcome }])
}
/>
))}
{settled.map((s) => (
<p
key={s.id}
role="status"
data-slot="coach-plan-proposal-done"
className="text-muted-foreground flex items-center gap-1.5 text-xs"
>
<Check className="text-success size-3.5" aria-hidden="true" />
{t(
s.outcome === "accepted"
? "coach.plans.accepted"
: "coach.plans.declined",
)}
</p>
))}
</div>
);
}
/** One proposal with confirm / decline actions. */
function PlanProposalCard({
plan,
onSettled,
}: {
plan: CoachPlanDTO;
onSettled: (outcome: SettledOutcome) => void;
}) {
const { t } = useTranslations();
const { setStatus, remove } = useCoachPlanMutations();
const busy = setStatus.isPending || remove.isPending;
return (
<div
data-slot="coach-plan-proposal-card"
className={cn(
"border-border/60 bg-muted/40 flex flex-col gap-2.5 rounded-xl border",
"px-3.5 py-3 text-sm",
)}
>
<div className="flex items-start gap-2">
<div
aria-hidden="true"
className="bg-primary/10 mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full"
>
<Target className="text-primary size-3.5" />
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-muted-foreground text-xs font-medium">
{t("coach.plans.proposalTitle")}
</span>
{/* The plan prose is the user's own committed intention — content,
never meta, so it reads in foreground per the text-colour rules. */}
<span className="text-foreground leading-relaxed break-words">
{t("coach.plans.ifThen", {
cue: plan.ifCue ?? "",
action: plan.thenAction ?? "",
})}
</span>
{plan.target ? (
<span className="text-foreground text-xs break-words">
{t("coach.plans.targetPrefix", { target: plan.target })}
</span>
) : null}
<span className="text-muted-foreground text-xs uppercase">
{plan.metric}
</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
data-slot="coach-plan-proposal-accept"
onClick={() =>
setStatus.mutate(
{ id: plan.id, status: "active" },
{
onSuccess: () => onSettled("accepted"),
onError: () => toast.error(t("coach.plans.failed")),
},
)
}
disabled={busy}
className={cn(
"bg-primary/90 text-primary-foreground hover:bg-primary",
"focus-visible:ring-ring/50 inline-flex min-h-9 items-center gap-1.5",
"rounded-md px-3 py-1.5 text-xs font-medium outline-none",
"focus-visible:ring-2 disabled:opacity-50",
)}
>
{busy && setStatus.isPending ? (
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<Check className="size-3.5" aria-hidden="true" />
)}
{t("coach.plans.accept")}
</button>
<button
type="button"
data-slot="coach-plan-proposal-decline"
onClick={() =>
remove.mutate(plan.id, {
onSuccess: () => onSettled("declined"),
onError: () => toast.error(t("coach.plans.failed")),
})
}
disabled={busy}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-9 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
>
<X className="size-3.5" aria-hidden="true" />
{t("coach.plans.decline")}
</button>
</div>
</div>
);
}