mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
1 parent
acd2f29da2
commit
f5b0ec431e
15 files changed
+870
No files matched your search
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user