diff --git a/messages/de.json b/messages/de.json index 8637a8d75..fbd360987 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index 6a8b15084..f82aea721 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index 9e4bd7cbf..a45cc6212 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/messages/fr.json b/messages/fr.json index 2cba90fc8..d79ae8d4d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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.", diff --git a/messages/it.json b/messages/it.json index 7e6f967ae..e25ee1368 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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.", diff --git a/messages/pl.json b/messages/pl.json index 89f4460a9..39097727f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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.", diff --git a/src/components/settings/__tests__/coach-memory-section.test.tsx b/src/components/settings/__tests__/coach-memory-section.test.tsx new file mode 100644 index 000000000..2ce1a0feb --- /dev/null +++ b/src/components/settings/__tests__/coach-memory-section.test.tsx @@ -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( + + + + + , + ); +} + +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"); + }); +}); diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx index cc235c30a..4e1cfb54e 100644 --- a/src/components/settings/ai-section.tsx +++ b/src/components/settings/ai-section.tsx @@ -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 (
@@ -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. */} + + {/* v1.11.2 — "What the Coach remembers": durable-fact review + + forget controls. Gated on the Coach being enabled. */} + {coachEnabled && ( + + )}
); } diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx new file mode 100644 index 000000000..2d52f59f5 --- /dev/null +++ b/src/components/settings/coach-memory-section.tsx @@ -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 " 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 = { + 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 { + 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 ( +
+
+
+

+ {t("settings.ai.coachMemory.title")} +

+

+ {t("settings.ai.coachMemory.description")} +

+
+ {facts.length > 0 && ( + + + + + + + + {t("settings.ai.coachMemory.forgetAllConfirmTitle")} + + + {t("settings.ai.coachMemory.forgetAllConfirmBody")} + + + + + {t("settings.ai.coachMemory.cancel")} + + forgetAll.mutate()} + > + {t("settings.ai.coachMemory.forgetAllConfirmAction")} + + + + + )} +
+ + {query.isError && ( +

+ {t("settings.ai.coachMemory.loadError")} +

+ )} + + {!query.isError && facts.length === 0 ? ( +

+ {t("settings.ai.coachMemory.empty")} +

+ ) : ( +
+ {grouped.map((group) => ( +
+

+ {t(CATEGORY_LABEL_KEY[group.category])} +

+
    + {group.items.map((fact) => ( +
  • +
    +

    {fact.text}

    +

    + {t("settings.ai.coachMemory.learnedPrefix", { + when: formatDateOrRelative(fact.createdAt, t), + })} +

    +
    + +
  • + ))} +
+
+ ))} +
+ )} + +

+ {t("settings.ai.coachMemory.summaryNote")} +

+
+ ); +} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index ed241217d..eae5f478e 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -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