feat(coach): settings UI to review and forget what the Coach remembers

Add a 'What the Coach remembers' settings section: lists the user's
durable facts grouped by category with a learned-when stamp, forgets a
single fact or clears all (behind a confirm), and notes the rolling
conversation summary. Consumes the v1.11.1 CoachFact routes via the
query-key factory; gated on the Coach feature; localised in all six
locales. Closes the API-only gap from v1.11.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-04 12:16:26 +02:00
1 parent 1f3ec16844
commit fcb41b8780
10 files changed
+580 -1

No files matched your search

@@ -0,0 +1,153 @@
/**
* v1.11.2 — Settings → AI "What the Coach remembers" panel contract.
*
* Project convention is SSR-only tests (no `@testing-library/react`;
* the Vitest environment is `node`). We pre-seed the `coachFacts`
* query in a real `QueryClient` so `useQuery` resolves synchronously
* during the `renderToStaticMarkup` pass, then assert the rendered
* shape: facts grouped + labelled by category, the empty state, and
* the per-fact + bulk "forget" controls that drive the DELETE
* mutations.
*
* The mutation wiring itself (DELETE `/facts/{id}` and DELETE `/facts`
* → `queryKeys.coachFacts()` invalidation + toast) is pinned by the
* route-shape contract in the component plus the typecheck gate; this
* suite guards the render contract the way the sibling
* `disable-coach-card.test.tsx` does for the toggle.
*/
import { afterEach, beforeEach, describe, expect, it, vi } 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 { CoachMemorySection } from "../coach-memory-section";
interface SeedFact {
id: string;
category: "preference" | "condition" | "goal" | "constraint" | "context";
text: string;
confidence: number;
createdAt: string;
}
const NOW = "2026-06-04T12:00:00.000Z";
function buildFacts(): SeedFact[] {
return [
{
id: "fact-goal-1",
category: "goal",
text: "Wants to reach 80 kg by autumn",
confidence: 0.9,
createdAt: NOW,
},
{
id: "fact-pref-1",
category: "preference",
text: "Prefers morning workouts",
confidence: 0.8,
createdAt: NOW,
},
{
id: "fact-cond-1",
category: "condition",
text: "Takes ramipril for blood pressure",
confidence: 0.95,
createdAt: NOW,
},
];
}
beforeEach(() => {
// The component never fetches when the query is pre-seeded fresh, but
// stub fetch so any background refetch can't escape to the network.
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(JSON.stringify({ data: { facts: [] } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function render(facts: SeedFact[] | "skip"): string {
const client = new QueryClient({
defaultOptions: {
queries: { retry: 0, staleTime: Infinity, gcTime: Infinity },
},
});
if (facts !== "skip") {
client.setQueryData(queryKeys.coachFacts(), facts);
}
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<CoachMemorySection isAuthenticated />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("Settings — CoachMemorySection", () => {
it("renders the facts grouped by category", () => {
const html = render(buildFacts());
// Each populated category surfaces its group heading...
expect(html).toContain(
'data-testid="settings-coach-memory-group-preference"',
);
expect(html).toContain('data-testid="settings-coach-memory-group-goal"');
expect(html).toContain(
'data-testid="settings-coach-memory-group-condition"',
);
// ...and the localized category labels render.
expect(html).toContain("Preferences");
expect(html).toContain("Goals");
expect(html).toContain("Conditions");
// The fact text appears verbatim.
expect(html).toContain("Prefers morning workouts");
expect(html).toContain("Wants to reach 80 kg by autumn");
// No group rows for categories that have no facts.
expect(html).not.toContain(
'data-testid="settings-coach-memory-group-constraint"',
);
});
it("renders a per-fact forget control and the bulk forget-all action", () => {
const html = render(buildFacts());
expect(html).toContain('data-testid="settings-coach-memory-forget"');
expect(html).toContain('data-testid="settings-coach-memory-forget-all"');
// One forget button per fact (3 seeded).
const forgetButtons = html.match(
/data-testid="settings-coach-memory-forget"/g,
);
expect(forgetButtons).toHaveLength(3);
});
it("renders the empty state with the explainer when there are no facts", () => {
const html = render([]);
expect(html).toContain('data-testid="settings-coach-memory-empty"');
// The bulk action hides when there is nothing to forget.
expect(html).not.toContain(
'data-testid="settings-coach-memory-forget-all"',
);
// No fact rows.
expect(html).not.toContain('data-testid="settings-coach-memory-fact"');
});
it("always shows the rolling-summary note", () => {
const withFacts = render(buildFacts());
const empty = render([]);
// The summary explainer is present in both states (it has no
// per-row control — deleting a conversation removes its summary).
expect(withFacts).toContain("rolling summary");
expect(empty).toContain("rolling summary");
});
});
+12 -1
View File
@@ -34,6 +34,7 @@ import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { Switch } from "@/components/ui/switch";
import { PasswordInput } from "@/components/ui/password-input";
import { CoachMemorySection } from "@/components/settings/coach-memory-section";
import { useAuth } from "@/hooks/use-auth";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
@@ -170,7 +171,11 @@ function localiseTestReason(
export function AiSection() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { isAuthenticated, user } = useAuth();
// v1.11.2 — the Coach-memory panel rides the same gate as the rest of
// the Coach surface: when the user has hidden the Coach there is no
// assistant to remember anything, so the memory controls hide too.
const coachEnabled = !user?.disableCoach;
return (
<section aria-labelledby="settings-section-ai-title" className="space-y-6">
@@ -192,6 +197,12 @@ export function AiSection() {
card so users who want the surface gone never have to scroll
through provider configuration before finding the toggle. */}
<DisableCoachCard isAuthenticated={isAuthenticated} />
{/* v1.11.2 — "What the Coach remembers": durable-fact review +
forget controls. Gated on the Coach being enabled. */}
{coachEnabled && (
<CoachMemorySection isAuthenticated={isAuthenticated} />
)}
</section>
);
}
@@ -0,0 +1,270 @@
"use client";
/**
* v1.11.2 — Settings → AI "What the Coach remembers" panel.
*
* Surfaces the durable `CoachFact` rows the assistant has stored about
* the user (the v1.11.1 routes already ship the data), grouped by
* category, each with a relative "learned <when>" stamp and a "forget"
* control. A bulk "forget everything" action clears the lot.
*
* GET /api/insights/coach/facts → { data: { facts: [...] } }
* DELETE /api/insights/coach/facts/{id} → { data: { deleted } }
* DELETE /api/insights/coach/facts → { data: { cleared } }
*
* Reads unwrap `(await res.json()).data` per the envelope convention;
* every read/write routes its key through `queryKeys.coachFacts()` so
* a forget invalidates the list. Gated on `!user.disableCoach` so the
* panel mirrors the rest of the Coach surface — hiding the Coach hides
* its memory controls too.
*/
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { formatDateOrRelative } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { toast } from "sonner";
/** Closed enum mirrored from the server `CoachFact.category` column. */
const FACT_CATEGORIES = [
"preference",
"condition",
"goal",
"constraint",
"context",
] as const;
type FactCategory = (typeof FACT_CATEGORIES)[number];
interface CoachFact {
id: string;
category: FactCategory;
text: string;
confidence: number;
createdAt: string;
}
const CATEGORY_LABEL_KEY: Record<FactCategory, string> = {
preference: "settings.ai.coachMemory.categoryPreference",
condition: "settings.ai.coachMemory.categoryCondition",
goal: "settings.ai.coachMemory.categoryGoal",
constraint: "settings.ai.coachMemory.categoryConstraint",
context: "settings.ai.coachMemory.categoryContext",
};
async function fetchFacts(): Promise<CoachFact[]> {
const res = await fetch("/api/insights/coach/facts");
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const json = (await res.json()) as { data?: { facts?: CoachFact[] } };
return json.data?.facts ?? [];
}
export function CoachMemorySection({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const query = useQuery({
queryKey: queryKeys.coachFacts(),
queryFn: fetchFacts,
enabled: isAuthenticated,
});
const forgetOne = useMutation({
mutationKey: queryKeys.coachFacts(),
mutationFn: async (id: string) => {
const res = await fetch(
`/api/insights/coach/facts/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return id;
},
onSuccess: () => {
toast.success(t("settings.ai.coachMemory.forgotToast"));
queryClient.invalidateQueries({ queryKey: queryKeys.coachFacts() });
},
onError: () => {
toast.error(t("settings.ai.coachMemory.forgotError"));
},
});
const forgetAll = useMutation({
mutationKey: queryKeys.coachFacts(),
mutationFn: async () => {
const res = await fetch("/api/insights/coach/facts", {
method: "DELETE",
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const json = (await res.json()) as { data?: { cleared?: number } };
return json.data?.cleared ?? 0;
},
onSuccess: (cleared) => {
toast.success(
t("settings.ai.coachMemory.clearedToast", { count: cleared }),
);
queryClient.invalidateQueries({ queryKey: queryKeys.coachFacts() });
},
onError: () => {
toast.error(t("settings.ai.coachMemory.clearError"));
},
});
const facts = useMemo(() => query.data ?? [], [query.data]);
// Group by category in the canonical category order so the panel
// reads the same way every render regardless of insertion order.
const grouped = useMemo(() => {
return FACT_CATEGORIES.map((category) => ({
category,
items: facts.filter((f) => f.category === category),
})).filter((g) => g.items.length > 0);
}, [facts]);
return (
<section
aria-labelledby="settings-ai-coach-memory-title"
data-testid="settings-coach-memory-card"
className="bg-card border-border space-y-4 rounded-xl border p-6"
>
<div className="flex items-start justify-between gap-4">
<div className="pr-2">
<h2
id="settings-ai-coach-memory-title"
className="text-base font-semibold"
>
{t("settings.ai.coachMemory.title")}
</h2>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.ai.coachMemory.description")}
</p>
</div>
{facts.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
data-testid="settings-coach-memory-forget-all"
disabled={!isAuthenticated || forgetAll.isPending}
>
{forgetAll.isPending ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<Trash2 className="size-4" aria-hidden />
)}
{t("settings.ai.coachMemory.forgetAll")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.ai.coachMemory.forgetAllConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.ai.coachMemory.forgetAllConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("settings.ai.coachMemory.cancel")}
</AlertDialogCancel>
<AlertDialogAction
data-testid="settings-coach-memory-forget-all-confirm"
onClick={() => forgetAll.mutate()}
>
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{query.isError && (
<p role="status" aria-live="polite" className="text-destructive text-xs">
{t("settings.ai.coachMemory.loadError")}
</p>
)}
{!query.isError && facts.length === 0 ? (
<p
data-testid="settings-coach-memory-empty"
className="text-muted-foreground text-sm"
>
{t("settings.ai.coachMemory.empty")}
</p>
) : (
<div className="space-y-5">
{grouped.map((group) => (
<div key={group.category} className="space-y-2">
<h3
data-testid={`settings-coach-memory-group-${group.category}`}
className="text-muted-foreground text-xs font-medium tracking-wide uppercase"
>
{t(CATEGORY_LABEL_KEY[group.category])}
</h3>
<ul className="space-y-2">
{group.items.map((fact) => (
<li
key={fact.id}
data-testid="settings-coach-memory-fact"
className="border-border bg-background flex items-start justify-between gap-3 rounded-lg border p-3"
>
<div className="min-w-0">
<p className="text-sm break-words">{fact.text}</p>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.ai.coachMemory.learnedPrefix", {
when: formatDateOrRelative(fact.createdAt, t),
})}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
data-testid="settings-coach-memory-forget"
aria-label={t("settings.ai.coachMemory.forgetAria")}
disabled={!isAuthenticated || forgetOne.isPending}
onClick={() => forgetOne.mutate(fact.id)}
>
<Trash2 className="size-4" aria-hidden />
{t("settings.ai.coachMemory.forget")}
</Button>
</li>
))}
</ul>
</div>
))}
</div>
)}
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
{t("settings.ai.coachMemory.summaryNote")}
</p>
</section>
);
}