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

+24
View File
@@ -3336,6 +3336,30 @@
"testReasonRateLimited": "Der Anbieter hat die Anfrage gedrosselt — versuche es in Kürze erneut.",
"testReasonServerError": "Der KI-Anbieter hat einen Serverfehler zurückgegeben.",
"testReasonUnreachable": "Der KI-Anbieter war nicht erreichbar.",
"coachMemory": {
"title": "Woran sich der Coach erinnert",
"description": "Dauerhafte Angaben, die du dem Coach mitgeteilt hast. Er nutzt sie, damit seine Antworten relevant bleiben. Entferne alles, was er besser vergessen sollte.",
"empty": "Der Coach hat noch keine Angaben gespeichert. Im Gespräch merkt er sich dauerhafte Details, die du teilst – deine Ziele, Beschwerden, Vorlieben – und du kannst sie hier jederzeit löschen.",
"learnedPrefix": "gelernt {when}",
"categoryPreference": "Vorlieben",
"categoryCondition": "Beschwerden",
"categoryGoal": "Ziele",
"categoryConstraint": "Einschränkungen",
"categoryContext": "Kontext",
"forget": "Vergessen",
"forgetAria": "Diese Angabe vergessen",
"forgetAll": "Alles vergessen",
"forgetAllConfirmTitle": "Alles vergessen, woran sich der Coach erinnert?",
"forgetAllConfirmBody": "Dadurch werden alle gespeicherten Angaben entfernt. Der Coach beginnt beim nächsten Gespräch von vorn. Das lässt sich nicht rückgängig machen.",
"forgetAllConfirmAction": "Alles vergessen",
"cancel": "Abbrechen",
"summaryNote": "Der Coach behält außerdem eine kurze, fortlaufende Zusammenfassung jedes Gesprächs. Dafür gibt es keine Einzelsteuerung – wenn du ein Gespräch löschst, wird auch dessen Zusammenfassung entfernt.",
"loadError": "Konnte nicht laden, woran sich der Coach erinnert.",
"forgotToast": "Angabe vergessen.",
"forgotError": "Konnte diese Angabe nicht vergessen. Bitte erneut versuchen.",
"clearedToast": "{count} gespeicherte Angaben vergessen.",
"clearError": "Konnte die gespeicherten Angaben nicht löschen. Bitte erneut versuchen."
},
"disableCoach": {
"title": "Coach ausblenden",
"description": "Versteckt den Coach-Button und das Coach-Panel auf allen Seiten.",
+24
View File
@@ -3336,6 +3336,30 @@
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
"testReasonServerError": "The AI provider returned a server error.",
"testReasonUnreachable": "Could not reach the AI provider.",
"coachMemory": {
"title": "What the Coach remembers",
"description": "Durable facts you have told the Coach. It uses these to keep its replies relevant. Remove anything you would rather it forgot.",
"empty": "The Coach has not stored any facts yet. As you chat, it remembers durable details you share — your goals, conditions, preferences — and you can clear them here at any time.",
"learnedPrefix": "learned {when}",
"categoryPreference": "Preferences",
"categoryCondition": "Conditions",
"categoryGoal": "Goals",
"categoryConstraint": "Constraints",
"categoryContext": "Context",
"forget": "Forget",
"forgetAria": "Forget this fact",
"forgetAll": "Forget everything",
"forgetAllConfirmTitle": "Forget everything the Coach remembers?",
"forgetAllConfirmBody": "This removes every stored fact. The Coach will start fresh the next time you chat. This cannot be undone.",
"forgetAllConfirmAction": "Forget everything",
"cancel": "Cancel",
"summaryNote": "The Coach may also keep a short rolling summary of each conversation. There is no per-item control for it — deleting a conversation removes its summary.",
"loadError": "Could not load what the Coach remembers.",
"forgotToast": "Fact forgotten.",
"forgotError": "Could not forget that fact. Please try again.",
"clearedToast": "Forgot {count} stored facts.",
"clearError": "Could not clear the stored facts. Please try again."
},
"disableCoach": {
"title": "Hide Coach",
"description": "Hides the Coach button and drawer everywhere.",
+24
View File
@@ -3336,6 +3336,30 @@
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
"testReasonServerError": "The AI provider returned a server error.",
"testReasonUnreachable": "Could not reach the AI provider.",
"coachMemory": {
"title": "Lo que el Coach recuerda",
"description": "Datos duraderos que le has contado al Coach. Los usa para que sus respuestas sigan siendo relevantes. Elimina lo que prefieras que olvide.",
"empty": "El Coach aún no ha guardado ningún dato. A medida que conversas, recuerda detalles duraderos que compartes: tus objetivos, afecciones y preferencias, y puedes borrarlos aquí cuando quieras.",
"learnedPrefix": "aprendido {when}",
"categoryPreference": "Preferencias",
"categoryCondition": "Afecciones",
"categoryGoal": "Objetivos",
"categoryConstraint": "Restricciones",
"categoryContext": "Contexto",
"forget": "Olvidar",
"forgetAria": "Olvidar este dato",
"forgetAll": "Olvidar todo",
"forgetAllConfirmTitle": "¿Olvidar todo lo que recuerda el Coach?",
"forgetAllConfirmBody": "Esto elimina todos los datos guardados. El Coach empezará de cero la próxima vez que converses. No se puede deshacer.",
"forgetAllConfirmAction": "Olvidar todo",
"cancel": "Cancelar",
"summaryNote": "El Coach también puede mantener un breve resumen continuo de cada conversación. No hay un control individual para él: eliminar una conversación elimina su resumen.",
"loadError": "No se pudo cargar lo que recuerda el Coach.",
"forgotToast": "Dato olvidado.",
"forgotError": "No se pudo olvidar ese dato. Inténtalo de nuevo.",
"clearedToast": "Se olvidaron {count} datos guardados.",
"clearError": "No se pudieron borrar los datos guardados. Inténtalo de nuevo."
},
"disableCoach": {
"title": "Ocultar el Coach",
"description": "Oculta el botón del Coach y el panel en todas las páginas.",
+24
View File
@@ -3336,6 +3336,30 @@
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
"testReasonServerError": "The AI provider returned a server error.",
"testReasonUnreachable": "Could not reach the AI provider.",
"coachMemory": {
"title": "Ce dont le Coach se souvient",
"description": "Les informations durables que vous avez confiées au Coach. Il s'en sert pour garder ses réponses pertinentes. Supprimez tout ce qu'il vaut mieux qu'il oublie.",
"empty": "Le Coach n'a encore enregistré aucune information. Au fil de vos échanges, il retient les détails durables que vous partagez — vos objectifs, vos affections, vos préférences — et vous pouvez les effacer ici à tout moment.",
"learnedPrefix": "appris {when}",
"categoryPreference": "Préférences",
"categoryCondition": "Affections",
"categoryGoal": "Objectifs",
"categoryConstraint": "Contraintes",
"categoryContext": "Contexte",
"forget": "Oublier",
"forgetAria": "Oublier cette information",
"forgetAll": "Tout oublier",
"forgetAllConfirmTitle": "Oublier tout ce dont le Coach se souvient ?",
"forgetAllConfirmBody": "Cela supprime toutes les informations enregistrées. Le Coach repartira de zéro lors de votre prochaine conversation. Cette action est irréversible.",
"forgetAllConfirmAction": "Tout oublier",
"cancel": "Annuler",
"summaryNote": "Le Coach peut aussi conserver un bref résumé évolutif de chaque conversation. Il n'existe pas de commande individuelle pour cela — supprimer une conversation supprime son résumé.",
"loadError": "Impossible de charger ce dont le Coach se souvient.",
"forgotToast": "Information oubliée.",
"forgotError": "Impossible d'oublier cette information. Veuillez réessayer.",
"clearedToast": "{count} informations enregistrées oubliées.",
"clearError": "Impossible d'effacer les informations enregistrées. Veuillez réessayer."
},
"disableCoach": {
"title": "Masquer le Coach",
"description": "Masque le bouton et le panneau du Coach sur toutes les pages.",
+24
View File
@@ -3336,6 +3336,30 @@
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
"testReasonServerError": "The AI provider returned a server error.",
"testReasonUnreachable": "Could not reach the AI provider.",
"coachMemory": {
"title": "Ciò che il Coach ricorda",
"description": "Informazioni durature che hai comunicato al Coach. Le usa per mantenere pertinenti le sue risposte. Rimuovi tutto ciò che preferisci dimentichi.",
"empty": "Il Coach non ha ancora memorizzato alcuna informazione. Mentre chatti, ricorda i dettagli duraturi che condividi — i tuoi obiettivi, le condizioni, le preferenze — e puoi cancellarli qui in qualsiasi momento.",
"learnedPrefix": "appreso {when}",
"categoryPreference": "Preferenze",
"categoryCondition": "Condizioni",
"categoryGoal": "Obiettivi",
"categoryConstraint": "Vincoli",
"categoryContext": "Contesto",
"forget": "Dimentica",
"forgetAria": "Dimentica questa informazione",
"forgetAll": "Dimentica tutto",
"forgetAllConfirmTitle": "Dimenticare tutto ciò che il Coach ricorda?",
"forgetAllConfirmBody": "Questo rimuove tutte le informazioni memorizzate. Il Coach ripartirà da zero alla prossima chat. L'operazione non può essere annullata.",
"forgetAllConfirmAction": "Dimentica tutto",
"cancel": "Annulla",
"summaryNote": "Il Coach può anche conservare un breve riassunto progressivo di ogni conversazione. Non esiste un controllo per ogni voce — eliminando una conversazione si elimina anche il suo riassunto.",
"loadError": "Impossibile caricare ciò che il Coach ricorda.",
"forgotToast": "Informazione dimenticata.",
"forgotError": "Impossibile dimenticare questa informazione. Riprova.",
"clearedToast": "Dimenticate {count} informazioni memorizzate.",
"clearError": "Impossibile cancellare le informazioni memorizzate. Riprova."
},
"disableCoach": {
"title": "Nascondi il Coach",
"description": "Nasconde il pulsante e il pannello del Coach su tutte le pagine.",
+24
View File
@@ -3336,6 +3336,30 @@
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
"testReasonServerError": "The AI provider returned a server error.",
"testReasonUnreachable": "Could not reach the AI provider.",
"coachMemory": {
"title": "Co Coach pamięta",
"description": "Trwałe informacje, które przekazałeś Coachowi. Wykorzystuje je, aby jego odpowiedzi pozostawały trafne. Usuń wszystko, o czym wolisz, by zapomniał.",
"empty": "Coach nie zapisał jeszcze żadnych informacji. Podczas rozmowy zapamiętuje trwałe szczegóły, którymi się dzielisz — Twoje cele, dolegliwości, preferencje — i możesz je tutaj w każdej chwili usunąć.",
"learnedPrefix": "zapamiętano {when}",
"categoryPreference": "Preferencje",
"categoryCondition": "Dolegliwości",
"categoryGoal": "Cele",
"categoryConstraint": "Ograniczenia",
"categoryContext": "Kontekst",
"forget": "Zapomnij",
"forgetAria": "Zapomnij tę informację",
"forgetAll": "Zapomnij wszystko",
"forgetAllConfirmTitle": "Zapomnieć wszystko, co Coach pamięta?",
"forgetAllConfirmBody": "Spowoduje to usunięcie wszystkich zapisanych informacji. Coach zacznie od nowa przy następnej rozmowie. Tej operacji nie można cofnąć.",
"forgetAllConfirmAction": "Zapomnij wszystko",
"cancel": "Anuluj",
"summaryNote": "Coach może też przechowywać krótkie, bieżące podsumowanie każdej rozmowy. Nie ma dla niego osobnej kontrolki — usunięcie rozmowy usuwa jej podsumowanie.",
"loadError": "Nie udało się wczytać tego, co Coach pamięta.",
"forgotToast": "Informacja zapomniana.",
"forgotError": "Nie udało się zapomnieć tej informacji. Spróbuj ponownie.",
"clearedToast": "Zapomniano {count} zapisanych informacji.",
"clearError": "Nie udało się usunąć zapisanych informacji. Spróbuj ponownie."
},
"disableCoach": {
"title": "Ukryj Coacha",
"description": "Ukrywa przycisk Coacha i panel na wszystkich stronach.",
@@ -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>
);
}
+1
View File
@@ -299,6 +299,7 @@ export const queryKeys = {
featureFlags: () => ["feature-flags"] as const,
coachPrefs: () => ["coach-prefs"] as const,
coachFacts: () => ["coach-facts"] as const,
/**
* v1.4.41 — admin surfaces. Pre-fix every admin section declared its