From acd2f29da2a08fb86b7a69d0eb99ca29dc51f2a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 5 Jul 2026 19:45:20 +0200 Subject: [PATCH 1/3] feat(coach): scope filter + conversation provenance on the plans list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for the plan confirm/manage UI. The CoachPlan pipeline has been complete on the server since v1.21.3/v1.22 — extractor writes proposals (status "proposed", per-user cap, de-dup, encrypted prose), the PATCH confirms proposed -> active, buildCoachPlansBlock injects the top-6 active plans into the snapshot, and the review sweep drives the reviewDate lifecycle — but no surface ever listed or confirmed a proposal, so the extraction spend had no user-visible outcome. Two additive contract changes the UI needs: - GET /api/coach/plans accepts ?scope=open|past|all (open = proposed + active + review_due, past = met + abandoned + reviewed, all = no status clause) so the management page reads its ledger in one round-trip. Mutually exclusive with ?status=; both omitted keeps today's non-terminal default unchanged. - Plan items now carry sourceConversationId so the chat thread can show only the proposals born in the open conversation. OpenAPI regenerated; route tests cover the scope mappings, the status+scope 422, and the provenance passthrough. --- docs/api/openapi.yaml | 30 +++++++-- src/app/api/coach/plans/[id]/route.ts | 3 + .../api/coach/plans/__tests__/route.test.ts | 62 +++++++++++++++++++ src/app/api/coach/plans/route.ts | 28 +++++++-- src/lib/openapi/routes/coach.ts | 21 ++++++- src/lib/validations/coach-plan.ts | 24 +++++-- 6 files changed, 152 insertions(+), 16 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 88bc29ec8..f1b0c17aa 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5205,10 +5205,11 @@ paths: description: v1.21.3 (B1) — returns the durable plans the Coach has proposed for the caller, newest first, each decrypted on the fly. A plan is an "if-then" implementation intention tied to one metric, with an optional target. The Coach extractor writes a plan as `proposed`; only `PATCH /api/coach/plans/{id}` activates it. Pass - `?status=` to filter (proposed | active | met | abandoned); omitted returns the non-terminal set (proposed + - active). Coach-gated (`requireModuleEnabled("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the - owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing - the read. + `?status=` to filter to one lifecycle status, or `?scope=` for a named group (open = proposed + active + + review_due, past = met + abandoned + reviewed, all = every non-deleted plan) — mutually exclusive. Both omitted + returns the non-terminal set (proposed + active). Coach-gated (`requireModuleEnabled("coach")`); a disabled + surface 403s. Auth via cookie or Bearer; the owner is always narrowed from the session, never the body. + Undecryptable rows are omitted rather than failing the read. parameters: - name: status in: query @@ -5222,7 +5223,19 @@ paths: - abandoned - review_due - reviewed - description: Filter to a single lifecycle status. Omit for the non-terminal set (proposed + active). + description: Filter to a single lifecycle status. Mutually exclusive with `scope`. Omit both for the non-terminal set + (proposed + active). + - name: scope + in: query + required: false + schema: + type: string + enum: + - open + - past + - all + description: "Named status group: open (proposed + active + review_due), past (met + abandoned + reviewed), or all. + Mutually exclusive with `status`." responses: "200": description: The caller's plans. @@ -20461,6 +20474,12 @@ components: pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: Optional check-in checkpoint; null when none. + sourceConversationId: + anyOf: + - type: string + - type: "null" + description: Id of the conversation the proposal was extracted from; null for a plan without conversation provenance. + Lets a chat surface show only the proposals born in the open thread. createdAt: type: string format: date-time @@ -20477,6 +20496,7 @@ components: - target - status - reviewDate + - sourceConversationId - createdAt - updatedAt additionalProperties: false diff --git a/src/app/api/coach/plans/[id]/route.ts b/src/app/api/coach/plans/[id]/route.ts index ceacd6df3..f89226ee8 100644 --- a/src/app/api/coach/plans/[id]/route.ts +++ b/src/app/api/coach/plans/[id]/route.ts @@ -117,6 +117,7 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => { targetEncrypted: true, status: true, reviewDate: true, + sourceConversationId: true, createdAt: true, updatedAt: true, }, @@ -148,6 +149,7 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => { target: null, status: row.status, reviewDate: row.reviewDate?.toISOString() ?? null, + sourceConversationId: row.sourceConversationId, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }, @@ -177,6 +179,7 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => { target, status: row.status, reviewDate: row.reviewDate?.toISOString() ?? null, + sourceConversationId: row.sourceConversationId, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }, diff --git a/src/app/api/coach/plans/__tests__/route.test.ts b/src/app/api/coach/plans/__tests__/route.test.ts index 53bfc41c1..a41f9d075 100644 --- a/src/app/api/coach/plans/__tests__/route.test.ts +++ b/src/app/api/coach/plans/__tests__/route.test.ts @@ -175,11 +175,73 @@ describe("GET /api/coach/plans", () => { expect(where.status).toBe("proposed"); }); + it("maps ?scope=open to the standing set incl. review_due", async () => { + vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never); + await callGet("http://localhost/api/coach/plans?scope=open"); + const where = vi.mocked(prisma.coachPlan.findMany).mock.calls[0]?.[0] + ?.where as { status: unknown }; + expect(where.status).toEqual({ + in: ["proposed", "active", "review_due"], + }); + }); + + it("maps ?scope=past to the settled set", async () => { + vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never); + await callGet("http://localhost/api/coach/plans?scope=past"); + const where = vi.mocked(prisma.coachPlan.findMany).mock.calls[0]?.[0] + ?.where as { status: unknown }; + expect(where.status).toEqual({ in: ["met", "abandoned", "reviewed"] }); + }); + + it("drops the status clause entirely for ?scope=all", async () => { + vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never); + await callGet("http://localhost/api/coach/plans?scope=all"); + const where = vi.mocked(prisma.coachPlan.findMany).mock.calls[0]?.[0] + ?.where as Record; + expect(where.userId).toBe("user-1"); + expect(where.deletedAt).toBeNull(); + expect("status" in where).toBe(false); + }); + + it("422s when both ?status= and ?scope= are given", async () => { + const res = await callGet( + "http://localhost/api/coach/plans?status=active&scope=all", + ); + expect(res.status).toBe(422); + }); + it("422s on an invalid ?status=", async () => { const res = await callGet("http://localhost/api/coach/plans?status=bogus"); expect(res.status).toBe(422); }); + it("422s on an invalid ?scope=", async () => { + const res = await callGet("http://localhost/api/coach/plans?scope=bogus"); + expect(res.status).toBe(422); + }); + + it("carries the source conversation id through to the item", async () => { + vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([ + { + id: "p1", + metric: "WEIGHT", + ifCueEncrypted: bytes("every morning"), + thenActionEncrypted: bytes("weigh in"), + targetEncrypted: null, + status: "proposed", + reviewDate: null, + sourceConversationId: "conv-9", + createdAt: new Date(), + updatedAt: new Date(), + }, + ] as never); + const res = await callGet(); + const body = (await res.json()) as { + data: { plans: Array<{ sourceConversationId: string | null }> }; + }; + expect(body.data.plans[0]?.sourceConversationId).toBe("conv-9"); + }); + it("skips an undecryptable row rather than 500ing", async () => { vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([ { diff --git a/src/app/api/coach/plans/route.ts b/src/app/api/coach/plans/route.ts index 3a2ccd2a7..998ed9fe5 100644 --- a/src/app/api/coach/plans/route.ts +++ b/src/app/api/coach/plans/route.ts @@ -17,7 +17,9 @@ * The owner is always narrowed from the session, never the body. * * `?status=proposed|active|met|abandoned` optionally filters the list (e.g. - * the composer fetches `proposed` to surface confirm cards). Omitted returns + * the chat thread fetches `proposed` to surface confirm cards). `?scope=` + * pulls a named status group instead (`open` | `past` | `all`) so the plans + * management page reads its whole ledger in one round-trip. Omitted returns * the non-terminal set (proposed + active), newest first. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; @@ -28,6 +30,12 @@ import { requireModuleEnabled } from "@/lib/modules/gate"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { coachPlansListQuerySchema } from "@/lib/validations/coach-plan"; +/** The status sets behind the named `?scope=` groups (`all` = no filter). */ +const SCOPE_STATUSES: Record<"open" | "past", string[]> = { + open: ["proposed", "active", "review_due"], + past: ["met", "abandoned", "reviewed"], +}; + export const GET = apiHandler(async (req: Request) => { const { user } = await requireAuth(); // Coach module gate (operator availability + disableCoach), mirroring the @@ -38,16 +46,23 @@ export const GET = apiHandler(async (req: Request) => { const url = new URL(req.url); const parsed = coachPlansListQuerySchema.safeParse({ status: url.searchParams.get("status") ?? undefined, + scope: url.searchParams.get("scope") ?? undefined, }); if (!parsed.success) { return returnAllZodIssues(parsed.error, 422); } - // No status filter → the non-terminal set (the actionable plans). An - // explicit status filters to exactly that status. + // No filter → the non-terminal set (the actionable plans). An explicit + // status filters to exactly that status; a scope selects a named group + // (`all` drops the status clause entirely). + const scope = parsed.data.scope; const statusWhere = parsed.data.status ? { status: parsed.data.status } - : { status: { in: ["proposed", "active"] } }; + : scope === "all" + ? {} + : scope + ? { status: { in: SCOPE_STATUSES[scope] } } + : { status: { in: ["proposed", "active"] } }; const rows = await prisma.coachPlan.findMany({ where: { userId: user.id, deletedAt: null, ...statusWhere }, @@ -60,6 +75,7 @@ export const GET = apiHandler(async (req: Request) => { targetEncrypted: true, status: true, reviewDate: true, + sourceConversationId: true, createdAt: true, updatedAt: true, }, @@ -73,6 +89,7 @@ export const GET = apiHandler(async (req: Request) => { target: string | null; status: string; reviewDate: string | null; + sourceConversationId: string | null; createdAt: string; updatedAt: string; }> = []; @@ -104,6 +121,9 @@ export const GET = apiHandler(async (req: Request) => { target, status: row.status, reviewDate: row.reviewDate?.toISOString() ?? null, + // Provenance for the chat surface: the thread only shows proposal + // cards born in the OPEN conversation, never strays from another one. + sourceConversationId: row.sourceConversationId, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }); diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index d8b172e8f..b33733c40 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -15,6 +15,7 @@ import { import { COACH_FACT_CATEGORIES } from "@/lib/ai/coach/facts"; import { COACH_PLAN_STATUSES } from "@/lib/ai/coach/plans"; import { + COACH_PLAN_SCOPES, coachPlanPatchSchema, coachPlansListQuerySchema, } from "@/lib/validations/coach-plan"; @@ -283,6 +284,12 @@ const coachPlanItem = z .datetime({ offset: true }) .nullable() .describe("Optional check-in checkpoint; null when none."), + sourceConversationId: z + .string() + .nullable() + .describe( + "Id of the conversation the proposal was extracted from; null for a plan without conversation provenance. Lets a chat surface show only the proposals born in the open thread.", + ), createdAt: z.iso.datetime({ offset: true }), updatedAt: z.iso.datetime({ offset: true }), }) @@ -321,7 +328,7 @@ coachPlanPatchSchema.meta({ coachPlansListQuerySchema.meta({ id: "CoachPlansListQuery", description: - "Optional `?status=` filter for the plans list (proposed | active | met | abandoned). Omitted returns the non-terminal set (proposed + active).", + "Optional `?status=` filter for the plans list (a single lifecycle status), or `?scope=` for a named group (open = proposed + active + review_due, past = met + abandoned + reviewed, all = every non-deleted plan). Mutually exclusive. Both omitted returns the non-terminal set (proposed + active).", }); // ── Coach episodic reminders (v1.22 B2/B6) ────────────────────────────── @@ -1021,7 +1028,7 @@ export const coachPaths: NonNullable = { tags: ["Insights"], summary: "List the caller's Coach goal / if-then plans", description: - 'v1.21.3 (B1) — returns the durable plans the Coach has proposed for the caller, newest first, each decrypted on the fly. A plan is an "if-then" implementation intention tied to one metric, with an optional target. The Coach extractor writes a plan as `proposed`; only `PATCH /api/coach/plans/{id}` activates it. Pass `?status=` to filter (proposed | active | met | abandoned); omitted returns the non-terminal set (proposed + active). Coach-gated (`requireModuleEnabled("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing the read.', + 'v1.21.3 (B1) — returns the durable plans the Coach has proposed for the caller, newest first, each decrypted on the fly. A plan is an "if-then" implementation intention tied to one metric, with an optional target. The Coach extractor writes a plan as `proposed`; only `PATCH /api/coach/plans/{id}` activates it. Pass `?status=` to filter to one lifecycle status, or `?scope=` for a named group (open = proposed + active + review_due, past = met + abandoned + reviewed, all = every non-deleted plan) — mutually exclusive. Both omitted returns the non-terminal set (proposed + active). Coach-gated (`requireModuleEnabled("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing the read.', parameters: [ { name: "status", @@ -1029,7 +1036,15 @@ export const coachPaths: NonNullable = { required: false, schema: { type: "string", enum: [...COACH_PLAN_STATUSES] }, description: - "Filter to a single lifecycle status. Omit for the non-terminal set (proposed + active).", + "Filter to a single lifecycle status. Mutually exclusive with `scope`. Omit both for the non-terminal set (proposed + active).", + }, + { + name: "scope", + in: "query", + required: false, + schema: { type: "string", enum: [...COACH_PLAN_SCOPES] }, + description: + "Named status group: open (proposed + active + review_due), past (met + abandoned + reviewed), or all. Mutually exclusive with `status`.", }, ], responses: { diff --git a/src/lib/validations/coach-plan.ts b/src/lib/validations/coach-plan.ts index d30f0c328..75b37f4a3 100644 --- a/src/lib/validations/coach-plan.ts +++ b/src/lib/validations/coach-plan.ts @@ -17,10 +17,26 @@ import { z } from "zod/v4"; import { COACH_PLAN_STATUSES } from "@/lib/ai/coach/plans"; -/** Optional `?status=` filter on the list endpoint. */ -export const coachPlansListQuerySchema = z.object({ - status: z.enum(COACH_PLAN_STATUSES).optional(), -}); +/** + * Optional `?scope=` filter on the list endpoint — a named status group so + * the plans management surface can pull its whole ledger in one read: + * - `open`: proposed + active + review_due (everything still standing) + * - `past`: met + abandoned + reviewed (the settled history) + * - `all`: every non-deleted plan + * Additive next to `?status=`; the two are mutually exclusive per request. + */ +export const COACH_PLAN_SCOPES = ["open", "past", "all"] as const; + +/** Optional `?status=` / `?scope=` filters on the list endpoint. */ +export const coachPlansListQuerySchema = z + .object({ + status: z.enum(COACH_PLAN_STATUSES).optional(), + scope: z.enum(COACH_PLAN_SCOPES).optional(), + }) + .refine( + (v) => !(v.status !== undefined && v.scope !== undefined), + "Provide either status or scope, not both", + ); /** * The user-facing PATCH body. `status` is the only mutable lifecycle field; a 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 2/3] 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({