mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +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,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> = {}): 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(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<PlanProposalCards conversationId={conversationId} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<PlanProposalCards>", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Send,
|
||||
Settings,
|
||||
Square,
|
||||
Target,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -517,6 +518,12 @@ export function CoachInput({
|
||||
<MessagesSquare className="size-4" aria-hidden="true" />
|
||||
{t("insights.coach.historyTitle")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild data-slot="coach-input-action-plans">
|
||||
<Link href="/coach/plans">
|
||||
<Target className="size-4" aria-hidden="true" />
|
||||
{t("coach.plans.title")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild data-slot="coach-input-action-settings">
|
||||
<Link href="/settings/ai">
|
||||
<Settings className="size-4" aria-hidden="true" />
|
||||
|
||||
@@ -46,6 +46,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
|
||||
import { SourceChips } from "./source-chips";
|
||||
import { PlanProposalCards } from "./plan-proposal-card";
|
||||
import { ReminderSuggestionCard } from "./reminder-suggestion-card";
|
||||
import { SuggestedActionCard } from "./suggested-action-card";
|
||||
import { StreamedProse } from "./streamed-prose";
|
||||
@@ -1026,6 +1027,14 @@ export function MessageThread({
|
||||
{placement.tail.map((i) => (
|
||||
<Fragment key={i.key}>{i.node}</Fragment>
|
||||
))}
|
||||
{/* Plan-proposal confirm cards for THIS conversation. The extractor
|
||||
runs after the turn (memory-refresh worker), so the block appears at
|
||||
the thread tail once the proposal lands rather than under a specific
|
||||
bubble; the component itself slow-polls and renders nothing while
|
||||
the conversation has no open proposal. */}
|
||||
{conversation ? (
|
||||
<PlanProposalCards conversationId={conversation.id} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Check, Loader2, Target, X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
useCoachPlans,
|
||||
useCoachPlanMutations,
|
||||
type CoachPlanDTO,
|
||||
} from "@/hooks/use-coach-plans";
|
||||
|
||||
/**
|
||||
* v1.27.x — the confirm cards for Coach plan proposals, rendered at the tail
|
||||
* of the open conversation's thread.
|
||||
*
|
||||
* The extractor (memory-refresh worker, runs AFTER the turn) writes a plan as
|
||||
* `status: "proposed"`; nothing becomes a standing plan until the user
|
||||
* confirms it here (or on `/coach/plans`). Because the proposal lands
|
||||
* asynchronously, the block slow-polls the proposed list while the thread is
|
||||
* mounted (TanStack pauses the interval in background tabs) and only shows
|
||||
* proposals whose `sourceConversationId` is the OPEN conversation — a
|
||||
* proposal born in an older thread stays on the management page instead of
|
||||
* popping into an unrelated chat.
|
||||
*
|
||||
* Accept → PATCH `{status:"active"}` (the plan enters the Coach's snapshot
|
||||
* memory block from the next turn on). Decline → DELETE (soft-delete; the
|
||||
* proposal never activates and stops counting against the per-user cap).
|
||||
* A settled card collapses to a one-line confirmation for the session so the
|
||||
* refetch dropping the row from the proposed list cannot double-prompt.
|
||||
*/
|
||||
|
||||
/** Slow poll (ms) — proposals arrive via the post-turn background worker. */
|
||||
const PROPOSAL_POLL_MS = 20_000;
|
||||
|
||||
type SettledOutcome = "accepted" | "declined";
|
||||
|
||||
export function PlanProposalCards({
|
||||
conversationId,
|
||||
}: {
|
||||
conversationId: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [settled, setSettled] = useState<
|
||||
Array<{ id: string; outcome: SettledOutcome }>
|
||||
>([]);
|
||||
|
||||
const query = useCoachPlans({
|
||||
filter: { status: "proposed" },
|
||||
refetchInterval: PROPOSAL_POLL_MS,
|
||||
});
|
||||
|
||||
// Quiet on error by design: the thread is the Coach's surface, and a failed
|
||||
// enhancement read must never interrupt the conversation with an error card.
|
||||
const proposals = (query.data ?? []).filter(
|
||||
(p) =>
|
||||
p.sourceConversationId === conversationId &&
|
||||
p.ifCue !== null &&
|
||||
p.thenAction !== null &&
|
||||
!settled.some((s) => s.id === p.id),
|
||||
);
|
||||
|
||||
if (proposals.length === 0 && settled.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div data-slot="coach-plan-proposals" className="flex flex-col gap-2.5">
|
||||
{proposals.map((plan) => (
|
||||
<PlanProposalCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
onSettled={(outcome) =>
|
||||
setSettled((prev) => [...prev, { id: plan.id, outcome }])
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{settled.map((s) => (
|
||||
<p
|
||||
key={s.id}
|
||||
role="status"
|
||||
data-slot="coach-plan-proposal-done"
|
||||
className="text-muted-foreground flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<Check className="text-success size-3.5" aria-hidden="true" />
|
||||
{t(
|
||||
s.outcome === "accepted"
|
||||
? "coach.plans.accepted"
|
||||
: "coach.plans.declined",
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One proposal with confirm / decline actions. */
|
||||
function PlanProposalCard({
|
||||
plan,
|
||||
onSettled,
|
||||
}: {
|
||||
plan: CoachPlanDTO;
|
||||
onSettled: (outcome: SettledOutcome) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { setStatus, remove } = useCoachPlanMutations();
|
||||
const busy = setStatus.isPending || remove.isPending;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-plan-proposal-card"
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 flex flex-col gap-2.5 rounded-xl border",
|
||||
"px-3.5 py-3 text-sm",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="bg-primary/10 mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full"
|
||||
>
|
||||
<Target className="text-primary size-3.5" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
{t("coach.plans.proposalTitle")}
|
||||
</span>
|
||||
{/* The plan prose is the user's own committed intention — content,
|
||||
never meta, so it reads in foreground per the text-colour rules. */}
|
||||
<span className="text-foreground leading-relaxed break-words">
|
||||
{t("coach.plans.ifThen", {
|
||||
cue: plan.ifCue ?? "",
|
||||
action: plan.thenAction ?? "",
|
||||
})}
|
||||
</span>
|
||||
{plan.target ? (
|
||||
<span className="text-foreground text-xs break-words">
|
||||
{t("coach.plans.targetPrefix", { target: plan.target })}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-muted-foreground text-xs uppercase">
|
||||
{plan.metric}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-plan-proposal-accept"
|
||||
onClick={() =>
|
||||
setStatus.mutate(
|
||||
{ id: plan.id, status: "active" },
|
||||
{
|
||||
onSuccess: () => onSettled("accepted"),
|
||||
onError: () => toast.error(t("coach.plans.failed")),
|
||||
},
|
||||
)
|
||||
}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
"bg-primary/90 text-primary-foreground hover:bg-primary",
|
||||
"focus-visible:ring-ring/50 inline-flex min-h-9 items-center gap-1.5",
|
||||
"rounded-md px-3 py-1.5 text-xs font-medium outline-none",
|
||||
"focus-visible:ring-2 disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{busy && setStatus.isPending ? (
|
||||
<Loader2
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Check className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
{t("coach.plans.accept")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-plan-proposal-decline"
|
||||
onClick={() =>
|
||||
remove.mutate(plan.id, {
|
||||
onSuccess: () => onSettled("declined"),
|
||||
onError: () => toast.error(t("coach.plans.failed")),
|
||||
})
|
||||
}
|
||||
disabled={busy}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-9 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
|
||||
>
|
||||
<X className="size-3.5" aria-hidden="true" />
|
||||
{t("coach.plans.decline")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user