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

+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 };
}