mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
1f3ec16844
commit
fcb41b8780
10 files changed
+580
-1
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user