Files
HealthLog/src/hooks/use-coach-plans.ts
T
Marc-André Bombeck 61114fab90 Accelerate the chart range switch, prefetch two list pages, calm two polls
placeholderData keeps the previous range painted while a health-chart
range switch fetches, paired with its truth condition: the container
dims and goes inert while isPlaceholderData is true, so the old range
cannot masquerade as the new one. The mood chart needs no pairing — its
range is a client-side slice over one static-key query and never drops
to a skeleton.

/checkups and /mood gain the dashboard-template RSC prefetch: the
primary list read moves into a shared lib read (route + wrapper consume
one query, so filter/order/wire shape cannot drift), the wrapper honours
the DASHBOARD_SSR_PREFETCH kill-switch, resolves identity through
getUnswitchedSession, JSON-round-trips the dehydrated state, and fails
soft to the bare client on every error path. Page-prefetch unit tests
pin the seeded keys and the fail-soft arms; the SSR hydration spec
gains an assertion per page.

The achievement-unlock poll drops to five minutes (in-app writes already
invalidate the cell; the poll only covers out-of-band ingest), and the
plan-proposal poll gains the advisor-style bounded attempt ceiling so an
open thread stops polling after its budget.
2026-08-13 19:06:56 +02:00

115 lines
3.6 KiB
TypeScript

"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,
type UseQueryOptions,
} 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), or TanStack's interval callback so a caller
* can bound its poll (the chat thread caps attempts — see
* `nextProposalPollInterval` in `plan-proposal-card.tsx`). 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?: UseQueryOptions<CoachPlanDTO[]>["refetchInterval"];
}) {
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 };
}