mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +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,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<GroupId, CoachPlanDTO[]> = {
|
||||
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 (
|
||||
<li
|
||||
key={plan.id}
|
||||
data-slot="coach-plan-row"
|
||||
data-status={plan.status}
|
||||
className="border-border bg-card flex flex-col gap-2 rounded-lg border p-3"
|
||||
>
|
||||
{/* Plan prose is the user's own committed intention — content tier. */}
|
||||
<p className="text-sm leading-relaxed break-words">
|
||||
{t("coach.plans.ifThen", {
|
||||
cue: plan.ifCue ?? "",
|
||||
action: plan.thenAction ?? "",
|
||||
})}
|
||||
</p>
|
||||
{plan.target ? (
|
||||
<p className="text-xs break-words">
|
||||
{t("coach.plans.targetPrefix", { target: plan.target })}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span className="uppercase">{plan.metric}</span>
|
||||
<span>{t(`coach.plans.status.${plan.status}`)}</span>
|
||||
<span>{formatDateOrRelative(plan.updatedAt, t)}</span>
|
||||
{plan.reviewDate ? (
|
||||
<span>
|
||||
{t("coach.plans.reviewPrefix", {
|
||||
when: formatDateOrRelative(plan.reviewDate, t),
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{group === "proposed" && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
disabled={busy}
|
||||
data-slot="coach-plan-accept"
|
||||
onClick={() =>
|
||||
setStatus.mutate({ id: plan.id, status: "active" })
|
||||
}
|
||||
>
|
||||
{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"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
disabled={busy}
|
||||
data-slot="coach-plan-decline"
|
||||
onClick={() => remove.mutate(plan.id)}
|
||||
>
|
||||
<X className="size-3.5" aria-hidden="true" />
|
||||
{t("coach.plans.decline")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{group === "standing" && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
disabled={busy}
|
||||
data-slot="coach-plan-met"
|
||||
onClick={() => setStatus.mutate({ id: plan.id, status: "met" })}
|
||||
>
|
||||
<Check className="size-3.5" aria-hidden="true" />
|
||||
{t("coach.plans.markMet")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
disabled={busy}
|
||||
data-slot="coach-plan-abandon"
|
||||
onClick={() =>
|
||||
setStatus.mutate({ id: plan.id, status: "abandoned" })
|
||||
}
|
||||
>
|
||||
<X className="size-3.5" aria-hidden="true" />
|
||||
{t("coach.plans.abandon")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{group === "past" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
disabled={busy}
|
||||
data-slot="coach-plan-delete"
|
||||
onClick={() => remove.mutate(plan.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" aria-hidden="true" />
|
||||
{t("coach.plans.remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-screen-xl flex-col gap-4 px-4 pt-6 pb-4 md:px-6">
|
||||
<PageHeader
|
||||
title={
|
||||
<span data-slot="coach-plans-heading">{t("coach.plans.title")}</span>
|
||||
}
|
||||
description={t("coach.plans.pageDescription")}
|
||||
/>
|
||||
|
||||
<div
|
||||
data-slot="coach-plans-list"
|
||||
className={cn(
|
||||
"-mx-1 flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-1",
|
||||
COACH_SCROLLBAR,
|
||||
)}
|
||||
>
|
||||
{query.isError ? (
|
||||
<QueryErrorCard onRetry={() => query.refetch()} />
|
||||
) : query.isLoading ? (
|
||||
<p
|
||||
data-slot="coach-plans-loading"
|
||||
className="text-muted-foreground px-1 py-3 text-sm"
|
||||
>
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
) : groups.length === 0 ? (
|
||||
<div
|
||||
data-slot="coach-plans-empty"
|
||||
className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-12 text-center"
|
||||
>
|
||||
<span className="bg-muted/40 text-muted-foreground flex size-12 items-center justify-center rounded-full">
|
||||
<Target className="size-6" aria-hidden="true" />
|
||||
</span>
|
||||
<p className="text-muted-foreground max-w-xs text-sm leading-relaxed">
|
||||
{t("coach.plans.empty")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<section
|
||||
key={group.id}
|
||||
data-slot="coach-plans-group"
|
||||
data-group={group.id}
|
||||
className="flex flex-col gap-1.5"
|
||||
>
|
||||
<h2 className="text-muted-foreground px-1 text-xs font-medium tracking-wide uppercase">
|
||||
{t(group.labelKey)}
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-2">{group.plans.map(row)}</ul>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
data-slot="coach-plans-page"
|
||||
// Match `/coach/conversations`' full-bleed sizing: cancel the AuthShell
|
||||
// padding and claim the viewport height below the top bar (minus the
|
||||
// mobile-only BottomNav band).
|
||||
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
|
||||
>
|
||||
<CoachPlansBody />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user