From f5b0ec431ed353a20562b11f70ff8aad14ca134e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 5 Jul 2026 20:00:38 +0200 Subject: [PATCH] feat(coach): surface plan proposals for confirmation and add the plans page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- messages/de.json | 28 ++ messages/en.json | 28 ++ messages/es.json | 28 ++ messages/fr.json | 28 ++ messages/it.json | 28 ++ messages/pl.json | 28 ++ src/app/coach/plans/page.tsx | 286 ++++++++++++++++++ .../__tests__/plan-proposal-card.test.tsx | 82 +++++ .../insights/coach-panel/coach-input.tsx | 7 + .../insights/coach-panel/message-thread.tsx | 9 + .../coach-panel/plan-proposal-card.tsx | 195 ++++++++++++ src/hooks/use-coach-plans.ts | 107 +++++++ .../__tests__/coach-cascade.test.tsx | 3 + .../__tests__/coach-user-disable.test.tsx | 3 + src/lib/query-keys/coach.ts | 10 + 15 files changed, 870 insertions(+) create mode 100644 src/app/coach/plans/page.tsx create mode 100644 src/components/insights/coach-panel/__tests__/plan-proposal-card.test.tsx create mode 100644 src/components/insights/coach-panel/plan-proposal-card.tsx create mode 100644 src/hooks/use-coach-plans.ts diff --git a/messages/de.json b/messages/de.json index 23c44b9ca..26a0cc13e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 9c733b7c2..5d9a30932 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 08069dda2..7f44c1b69 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 29e16188a..c150ab3d4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index baafa256b..61a2d52e7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index d129e4f98..218e6eea5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/app/coach/plans/page.tsx b/src/app/coach/plans/page.tsx new file mode 100644 index 000000000..148172d64 --- /dev/null +++ b/src/app/coach/plans/page.tsx @@ -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 = { + 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 ( +
  • + {/* Plan prose is the user's own committed intention — content tier. */} +

    + {t("coach.plans.ifThen", { + cue: plan.ifCue ?? "", + action: plan.thenAction ?? "", + })} +

    + {plan.target ? ( +

    + {t("coach.plans.targetPrefix", { target: plan.target })} +

    + ) : null} +
    + {plan.metric} + {t(`coach.plans.status.${plan.status}`)} + {formatDateOrRelative(plan.updatedAt, t)} + {plan.reviewDate ? ( + + {t("coach.plans.reviewPrefix", { + when: formatDateOrRelative(plan.reviewDate, t), + })} + + ) : null} +
    +
    + {group === "proposed" && ( + <> + + + + )} + {group === "standing" && ( + <> + + + + )} + {group === "past" && ( + + )} +
    +
  • + ); + }; + + return ( +
    + {t("coach.plans.title")} + } + description={t("coach.plans.pageDescription")} + /> + +
    + {query.isError ? ( + query.refetch()} /> + ) : query.isLoading ? ( +

    + {t("common.loading")} +

    + ) : groups.length === 0 ? ( +
    + + +

    + {t("coach.plans.empty")} +

    +
    + ) : ( + groups.map((group) => ( +
    +

    + {t(group.labelKey)} +

    +
      {group.plans.map(row)}
    +
    + )) + )} +
    +
    + ); +} + +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 ( +
    + +
    + ); +} diff --git a/src/components/insights/coach-panel/__tests__/plan-proposal-card.test.tsx b/src/components/insights/coach-panel/__tests__/plan-proposal-card.test.tsx new file mode 100644 index 000000000..660c7fba0 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/plan-proposal-card.test.tsx @@ -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 { + 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( + + + + + , + ); +} + +describe("", () => { + 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"); + }); +}); diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index e830fe0c8..cd3954b6f 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -18,6 +18,7 @@ import { Send, Settings, Square, + Target, } from "lucide-react"; import { toast } from "sonner"; @@ -517,6 +518,12 @@ export function CoachInput({