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

+28
View File
@@ -7609,6 +7609,34 @@
"settings": {
"reminderSuggestionsLabel": "Kadenz-Vorschläge",
"reminderSuggestionsDescription": "Lass den Coach gelegentlich eine evidenzbasierte Erinnerung anbieten, eine Metrik regelmäßiger zu messen."
},
"plans": {
"title": "Pläne",
"pageDescription": "Wenn-dann-Pläne aus deinen Coach-Gesprächen. Bestätige einen Vorschlag, damit der Coach ihn sich merkt.",
"proposalTitle": "Plan-Vorschlag",
"ifThen": "Wenn {cue}, dann {action}.",
"targetPrefix": "Ziel: {target}",
"reviewPrefix": "Check-in: {when}",
"accept": "Plan übernehmen",
"decline": "Ablehnen",
"accepted": "Plan übernommen — der Coach behält ihn im Blick.",
"declined": "Vorschlag abgelehnt.",
"failed": "Das hat nicht geklappt — bitte erneut versuchen.",
"markMet": "Ziel erreicht",
"abandon": "Plan beenden",
"remove": "Entfernen",
"empty": "Noch keine Pläne. Wenn du dich im Gespräch zu einer konkreten Wenn-dann-Absicht bekennst, schlägt der Coach sie hier zur Bestätigung vor.",
"groupProposed": "Vorschläge",
"groupActive": "Aktiv",
"groupPast": "Abgeschlossen",
"status": {
"proposed": "Vorgeschlagen",
"active": "Aktiv",
"met": "Erreicht",
"abandoned": "Beendet",
"review_due": "Check-in fällig",
"reviewed": "Ausgewertet"
}
}
},
"safety": {
+28
View File
@@ -7609,6 +7609,34 @@
"settings": {
"reminderSuggestionsLabel": "Cadence suggestions",
"reminderSuggestionsDescription": "Let the Coach offer an occasional, evidence-based reminder to measure a metric more regularly."
},
"plans": {
"title": "Plans",
"pageDescription": "If-then plans agreed in your Coach conversations. Confirm a proposal to make it part of the Coach's memory.",
"proposalTitle": "Plan proposal",
"ifThen": "If {cue}, then {action}.",
"targetPrefix": "Target: {target}",
"reviewPrefix": "Check-in: {when}",
"accept": "Accept plan",
"decline": "Decline",
"accepted": "Plan accepted — the Coach will keep it in mind.",
"declined": "Proposal declined.",
"failed": "That didn't work — please try again.",
"markMet": "Goal met",
"abandon": "End plan",
"remove": "Remove",
"empty": "No plans yet. When you commit to a concrete if-then intention in a conversation, the Coach proposes it here for confirmation.",
"groupProposed": "Suggestions",
"groupActive": "Active",
"groupPast": "Completed",
"status": {
"proposed": "Proposed",
"active": "Active",
"met": "Met",
"abandoned": "Ended",
"review_due": "Check-in due",
"reviewed": "Reviewed"
}
}
},
"safety": {
+28
View File
@@ -7609,6 +7609,34 @@
"settings": {
"reminderSuggestionsLabel": "Sugerencias de frecuencia",
"reminderSuggestionsDescription": "Permite que el Coach ofrezca de vez en cuando un recordatorio basado en evidencia para medir una métrica con más regularidad."
},
"plans": {
"title": "Planes",
"pageDescription": "Planes de tipo «si… entonces» acordados en tus conversaciones con el Coach. Confirma una propuesta para que el Coach la recuerde.",
"proposalTitle": "Propuesta de plan",
"ifThen": "Si {cue}, entonces {action}.",
"targetPrefix": "Objetivo: {target}",
"reviewPrefix": "Revisión: {when}",
"accept": "Aceptar plan",
"decline": "Rechazar",
"accepted": "Plan aceptado: el Coach lo tendrá en cuenta.",
"declined": "Propuesta rechazada.",
"failed": "No ha funcionado; inténtalo de nuevo.",
"markMet": "Objetivo cumplido",
"abandon": "Finalizar plan",
"remove": "Eliminar",
"empty": "Aún no hay planes. Cuando te comprometas con una intención concreta de tipo «si… entonces» en una conversación, el Coach la propondrá aquí para confirmarla.",
"groupProposed": "Propuestas",
"groupActive": "Activos",
"groupPast": "Completados",
"status": {
"proposed": "Propuesto",
"active": "Activo",
"met": "Cumplido",
"abandoned": "Finalizado",
"review_due": "Revisión pendiente",
"reviewed": "Revisado"
}
}
},
"safety": {
+28
View File
@@ -7609,6 +7609,34 @@
"settings": {
"reminderSuggestionsLabel": "Suggestions de fréquence",
"reminderSuggestionsDescription": "Laissez le Coach proposer de temps en temps un rappel fondé sur des preuves pour mesurer une donnée plus régulièrement."
},
"plans": {
"title": "Plans",
"pageDescription": "Plans « si… alors » convenus dans vos conversations avec le Coach. Confirmez une proposition pour que le Coach s'en souvienne.",
"proposalTitle": "Proposition de plan",
"ifThen": "Si {cue}, alors {action}.",
"targetPrefix": "Objectif : {target}",
"reviewPrefix": "Point d'étape : {when}",
"accept": "Adopter le plan",
"decline": "Refuser",
"accepted": "Plan adopté — le Coach le gardera en tête.",
"declined": "Proposition refusée.",
"failed": "Échec de l'action — veuillez réessayer.",
"markMet": "Objectif atteint",
"abandon": "Terminer le plan",
"remove": "Supprimer",
"empty": "Aucun plan pour l'instant. Lorsque vous vous engagez sur une intention concrète « si… alors » dans une conversation, le Coach la propose ici pour confirmation.",
"groupProposed": "Propositions",
"groupActive": "Actifs",
"groupPast": "Terminés",
"status": {
"proposed": "Proposé",
"active": "Actif",
"met": "Atteint",
"abandoned": "Terminé",
"review_due": "Point d'étape dû",
"reviewed": "Évalué"
}
}
},
"safety": {
+28
View File
@@ -7609,6 +7609,34 @@
"settings": {
"reminderSuggestionsLabel": "Suggerimenti di cadenza",
"reminderSuggestionsDescription": "Lascia che il Coach proponga ogni tanto un promemoria basato sull'evidenza per misurare una metrica più regolarmente."
},
"plans": {
"title": "Piani",
"pageDescription": "Piani «se… allora» concordati nelle conversazioni con il Coach. Conferma una proposta perché il Coach la tenga a mente.",
"proposalTitle": "Proposta di piano",
"ifThen": "Se {cue}, allora {action}.",
"targetPrefix": "Obiettivo: {target}",
"reviewPrefix": "Verifica: {when}",
"accept": "Adotta il piano",
"decline": "Rifiuta",
"accepted": "Piano adottato: il Coach lo terrà a mente.",
"declined": "Proposta rifiutata.",
"failed": "Operazione non riuscita: riprova.",
"markMet": "Obiettivo raggiunto",
"abandon": "Termina piano",
"remove": "Rimuovi",
"empty": "Ancora nessun piano. Quando in una conversazione ti impegni in un'intenzione concreta «se… allora», il Coach la propone qui per la conferma.",
"groupProposed": "Proposte",
"groupActive": "Attivi",
"groupPast": "Conclusi",
"status": {
"proposed": "Proposto",
"active": "Attivo",
"met": "Raggiunto",
"abandoned": "Terminato",
"review_due": "Verifica in scadenza",
"reviewed": "Valutato"
}
}
},
"safety": {
+28
View File
@@ -7609,6 +7609,34 @@
"settings": {
"reminderSuggestionsLabel": "Sugestie częstotliwości",
"reminderSuggestionsDescription": "Pozwól Coachowi od czasu do czasu zaproponować oparte na dowodach przypomnienie, aby mierzyć daną wartość bardziej regularnie."
},
"plans": {
"title": "Plany",
"pageDescription": "Plany typu „jeśli… to” uzgodnione w rozmowach z Coachem. Potwierdź propozycję, aby Coach ją zapamiętał.",
"proposalTitle": "Propozycja planu",
"ifThen": "Jeśli {cue}, to {action}.",
"targetPrefix": "Cel: {target}",
"reviewPrefix": "Przegląd: {when}",
"accept": "Przyjmij plan",
"decline": "Odrzuć",
"accepted": "Plan przyjęty — Coach będzie o nim pamiętał.",
"declined": "Propozycja odrzucona.",
"failed": "Nie udało się — spróbuj ponownie.",
"markMet": "Cel osiągnięty",
"abandon": "Zakończ plan",
"remove": "Usuń",
"empty": "Nie ma jeszcze planów. Gdy w rozmowie zobowiążesz się do konkretnej intencji „jeśli… to”, Coach zaproponuje ją tutaj do potwierdzenia.",
"groupProposed": "Propozycje",
"groupActive": "Aktywne",
"groupPast": "Zakończone",
"status": {
"proposed": "Zaproponowany",
"active": "Aktywny",
"met": "Osiągnięty",
"abandoned": "Zakończony",
"review_due": "Przegląd do zrobienia",
"reviewed": "Oceniony"
}
}
},
"safety": {
+286
View File
@@ -0,0 +1,286 @@
"use client";
import { useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { Check, Loader2, Target, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread";
import {
useCoachPlans,
useCoachPlanMutations,
type CoachPlanDTO,
} from "@/hooks/use-coach-plans";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
import { useTranslations } from "@/lib/i18n/context";
import { formatDateOrRelative } from "@/lib/format";
import { cn } from "@/lib/utils";
/**
* v1.27.x — the Coach plans management page (`/coach/plans`).
*
* The ledger for the durable goal / if-then plans the Coach proposes and the
* user confirms — a sibling of `/coach/conversations`, reachable from the
* composer's `+` actions menu. Three groups over one `?scope=all` read:
*
* - proposed: confirm (→ active) or decline (soft-delete)
* - standing: active + review_due — mark met or end (→ abandoned)
* - past: met + abandoned + reviewed — remove from the ledger
*
* There is deliberately NO prose editor: the extractor is the only writer of
* plan text, and the PATCH contract only moves the lifecycle. An active plan
* is injected into the Coach's snapshot memory (top-6, newest first), so
* follow-up conversations recall it; a proposed or declined plan never is.
*
* Gating mirrors `/coach`: the operator master flag OR a per-user opt-out
* redirects back to `/insights` rather than painting a dead shell.
*/
type GroupId = "proposed" | "standing" | "past";
const GROUPS: Array<{ id: GroupId; labelKey: string }> = [
{ id: "proposed", labelKey: "coach.plans.groupProposed" },
{ id: "standing", labelKey: "coach.plans.groupActive" },
{ id: "past", labelKey: "coach.plans.groupPast" },
];
function groupOf(status: string): GroupId {
if (status === "proposed") return "proposed";
if (status === "active" || status === "review_due") return "standing";
return "past";
}
function CoachPlansBody() {
const { t } = useTranslations();
const query = useCoachPlans({ filter: { scope: "all" } });
const { setStatus, remove } = useCoachPlanMutations();
const plans = useMemo(() => query.data ?? [], [query.data]);
const groups = useMemo(() => {
const buckets: Record<GroupId, CoachPlanDTO[]> = {
proposed: [],
standing: [],
past: [],
};
for (const p of plans) buckets[groupOf(p.status)].push(p);
return GROUPS.map((g) => ({ ...g, plans: buckets[g.id] })).filter(
(g) => g.plans.length > 0,
);
}, [plans]);
const pendingId =
(setStatus.isPending && setStatus.variables?.id) ||
(remove.isPending && remove.variables) ||
null;
const row = (plan: CoachPlanDTO) => {
const busy = pendingId === plan.id;
const group = groupOf(plan.status);
return (
<li
key={plan.id}
data-slot="coach-plan-row"
data-status={plan.status}
className="border-border bg-card flex flex-col gap-2 rounded-lg border p-3"
>
{/* Plan prose is the user's own committed intention — content tier. */}
<p className="text-sm leading-relaxed break-words">
{t("coach.plans.ifThen", {
cue: plan.ifCue ?? "",
action: plan.thenAction ?? "",
})}
</p>
{plan.target ? (
<p className="text-xs break-words">
{t("coach.plans.targetPrefix", { target: plan.target })}
</p>
) : null}
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span className="uppercase">{plan.metric}</span>
<span>{t(`coach.plans.status.${plan.status}`)}</span>
<span>{formatDateOrRelative(plan.updatedAt, t)}</span>
{plan.reviewDate ? (
<span>
{t("coach.plans.reviewPrefix", {
when: formatDateOrRelative(plan.reviewDate, t),
})}
</span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{group === "proposed" && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-9"
disabled={busy}
data-slot="coach-plan-accept"
onClick={() =>
setStatus.mutate({ id: plan.id, status: "active" })
}
>
{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"
variant="ghost"
size="sm"
className="min-h-9"
disabled={busy}
data-slot="coach-plan-decline"
onClick={() => remove.mutate(plan.id)}
>
<X className="size-3.5" aria-hidden="true" />
{t("coach.plans.decline")}
</Button>
</>
)}
{group === "standing" && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-9"
disabled={busy}
data-slot="coach-plan-met"
onClick={() => setStatus.mutate({ id: plan.id, status: "met" })}
>
<Check className="size-3.5" aria-hidden="true" />
{t("coach.plans.markMet")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-9"
disabled={busy}
data-slot="coach-plan-abandon"
onClick={() =>
setStatus.mutate({ id: plan.id, status: "abandoned" })
}
>
<X className="size-3.5" aria-hidden="true" />
{t("coach.plans.abandon")}
</Button>
</>
)}
{group === "past" && (
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-9"
disabled={busy}
data-slot="coach-plan-delete"
onClick={() => remove.mutate(plan.id)}
>
<Trash2 className="size-3.5" aria-hidden="true" />
{t("coach.plans.remove")}
</Button>
)}
</div>
</li>
);
};
return (
<div className="mx-auto flex h-full min-h-0 w-full max-w-screen-xl flex-col gap-4 px-4 pt-6 pb-4 md:px-6">
<PageHeader
title={
<span data-slot="coach-plans-heading">{t("coach.plans.title")}</span>
}
description={t("coach.plans.pageDescription")}
/>
<div
data-slot="coach-plans-list"
className={cn(
"-mx-1 flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-1",
COACH_SCROLLBAR,
)}
>
{query.isError ? (
<QueryErrorCard onRetry={() => query.refetch()} />
) : query.isLoading ? (
<p
data-slot="coach-plans-loading"
className="text-muted-foreground px-1 py-3 text-sm"
>
{t("common.loading")}
</p>
) : groups.length === 0 ? (
<div
data-slot="coach-plans-empty"
className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-12 text-center"
>
<span className="bg-muted/40 text-muted-foreground flex size-12 items-center justify-center rounded-full">
<Target className="size-6" aria-hidden="true" />
</span>
<p className="text-muted-foreground max-w-xs text-sm leading-relaxed">
{t("coach.plans.empty")}
</p>
</div>
) : (
groups.map((group) => (
<section
key={group.id}
data-slot="coach-plans-group"
data-group={group.id}
className="flex flex-col gap-1.5"
>
<h2 className="text-muted-foreground px-1 text-xs font-medium tracking-wide uppercase">
{t(group.labelKey)}
</h2>
<ul className="flex flex-col gap-2">{group.plans.map(row)}</ul>
</section>
))
)}
</div>
</div>
);
}
export default function CoachPlansPage() {
const router = useRouter();
const flags = useFeatureFlags();
const disableCoach = useDisableCoach();
const coachUnavailable = !flags.coach || disableCoach;
// Same gating as `/coach`: operator master flag OR per-user opt-out
// redirects back to the Insights mother page so the route is never a
// dead-end.
useEffect(() => {
if (coachUnavailable) {
router.replace("/insights");
}
}, [coachUnavailable, router]);
if (coachUnavailable) return null;
return (
<div
data-slot="coach-plans-page"
// Match `/coach/conversations`' full-bleed sizing: cancel the AuthShell
// padding and claim the viewport height below the top bar (minus the
// mobile-only BottomNav band).
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
>
<CoachPlansBody />
</div>
);
}
@@ -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>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
/**
* v1.27.x — read + mutate the user's Coach goal / if-then plans.
*
* One shared hook for the chat thread's proposal confirm cards and the
* `/coach/plans` management page. Reads unwrap `(await res.json()).data` per
* the envelope convention; every key routes through `queryKeys.coachPlans()`
* so a lifecycle mutation invalidates both list slots at once. Mirrors
* `use-coach-reminders.ts`, the sibling episodic-memory hook.
*
* The extractor is the only writer of plan TEXT — this surface only confirms
* (proposed → active), marks met / abandoned, or soft-deletes. There is no
* prose editor by design: the PATCH contract never accepts the encrypted
* free-text fields.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import { apiDelete, apiGet, apiPatch } from "@/lib/api/api-fetch";
export interface CoachPlanDTO {
id: string;
metric: string;
/** Null only when the row's encryption key id rotated out of the map. */
ifCue: string | null;
thenAction: string | null;
target: string | null;
status: string;
reviewDate: string | null;
sourceConversationId: string | null;
createdAt: string;
updatedAt: string;
}
interface PlanFilter {
/** Single lifecycle status (`proposed`, `active`, …). */
status?: string;
/** Named group: `open` | `past` | `all`. Mutually exclusive with status. */
scope?: string;
}
function filterKey(filter?: PlanFilter): string | undefined {
if (filter?.status) return `status:${filter.status}`;
if (filter?.scope) return `scope:${filter.scope}`;
return undefined;
}
async function fetchPlans(filter?: PlanFilter): Promise<CoachPlanDTO[]> {
const qs = filter?.status
? `?status=${encodeURIComponent(filter.status)}`
: filter?.scope
? `?scope=${encodeURIComponent(filter.scope)}`
: "";
const data = await apiGet<{ plans?: CoachPlanDTO[] } | undefined>(
`/api/coach/plans${qs}`,
);
return data?.plans ?? [];
}
/** List the caller's plans, optionally filtered by status or scope. */
export function useCoachPlans(opts?: {
filter?: PlanFilter;
enabled?: boolean;
/**
* Optional poll interval (ms). The chat thread uses a slow poll because
* proposals land asynchronously (the memory-refresh worker runs after the
* turn); the management page reads once. TanStack pauses the interval in
* background tabs by default, so the poll never runs unwatched.
*/
refetchInterval?: number;
}) {
const filter = opts?.filter;
return useQuery({
queryKey: queryKeys.coachPlans(filterKey(filter)),
queryFn: () => fetchPlans(filter),
enabled: opts?.enabled ?? true,
refetchInterval: opts?.refetchInterval,
});
}
/** Lifecycle + delete mutations, invalidating every plans list slot. */
export function useCoachPlanMutations() {
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: queryKeys.coachPlansAll() });
const setStatus = useMutation({
mutationFn: async (args: { id: string; status: string }) => {
await apiPatch(`/api/coach/plans/${encodeURIComponent(args.id)}`, {
status: args.status,
});
return args.id;
},
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: async (id: string) => {
await apiDelete(`/api/coach/plans/${encodeURIComponent(id)}`);
return id;
},
onSuccess: invalidate,
});
return { setStatus, remove };
}
@@ -288,6 +288,9 @@ describe("Coach disable cascade invariant", () => {
// `flags.coach` like the main Coach route and renders unavailable
// when the operator master flag is off.
"src/app/coach/conversations/page.tsx",
// The plans management page mirrors the conversation-history page's
// gate: operator master flag off redirects back to `/insights`.
"src/app/coach/plans/page.tsx",
// v1.15.20 — the proactive Coach-nudge cron short-circuits on
// `flags.coach` (gate 1) so an operator kill-switch also silences
// the nudges. Server-side cron, not a render path — the cascade
@@ -302,6 +302,9 @@ describe("Coach per-user disableCoach invariant", () => {
// Coach route gate: operator master flag OR per-user opt-out marks
// the page unavailable instead of painting a dead history shell.
"src/app/coach/conversations/page.tsx",
// The plans management page mirrors the same gate: operator master
// flag OR per-user opt-out redirects instead of a dead plans shell.
"src/app/coach/plans/page.tsx",
// The hook itself + its `useAuth`-backed reader.
"src/hooks/use-disable-coach.ts",
// Cross-cut gates owned by sibling invariants / route tests.
+10
View File
@@ -42,4 +42,14 @@ export const coachKeys = {
["coach-reminders", status ?? "all"] as const,
/** Prefix key — invalidates every `coachReminders(status)` slot at once. */
coachRemindersAll: () => ["coach-reminders"] as const,
/**
* v1.27.x — the user's Coach goal / if-then plans (`GET /api/coach/plans`).
* The `filter` arg keys the chat thread's proposal read (`status:proposed`)
* separately from the management page's ledger read (`scope:all`) so the
* two never share a cache slot.
*/
coachPlans: (filter?: string) =>
["coach-plans", filter ?? "default"] as const,
/** Prefix key — invalidates every `coachPlans(filter)` slot at once. */
coachPlansAll: () => ["coach-plans"] as const,
};