diff --git a/messages/de.json b/messages/de.json index f56ff12c3..a423c6f78 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4125,6 +4125,10 @@ "gesundheitsakte": { "title": "Gesundheitsakte", "description": "Vollständige Gesundheitsakte als PDF, FHIR R4 oder kombiniertes ZIP-Paket exportieren." + }, + "coach": { + "title": "Coach", + "description": "Coach aktivieren, Ton und Umfang, gespeicherte Erinnerungen." } }, "profile": "Profil", diff --git a/messages/en.json b/messages/en.json index 6e6ad8c2e..4efb384dc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4125,6 +4125,10 @@ "gesundheitsakte": { "title": "Health record", "description": "Export your full health record as PDF, FHIR R4, or a combined ZIP package." + }, + "coach": { + "title": "Coach", + "description": "Enable the Coach, tone and scope, stored memories." } }, "profile": "Profile", diff --git a/messages/es.json b/messages/es.json index 4e180cd70..e78370478 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4125,6 +4125,10 @@ "gesundheitsakte": { "title": "Historial médico", "description": "Exporta tu historial médico completo como PDF, FHIR R4 o un paquete ZIP combinado." + }, + "coach": { + "title": "Coach", + "description": "Activa el Coach, tono y alcance, recuerdos guardados." } }, "profile": "Perfil", diff --git a/messages/fr.json b/messages/fr.json index 26773b240..32f5c2b32 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4125,6 +4125,10 @@ "gesundheitsakte": { "title": "Dossier médical", "description": "Exportez votre dossier médical complet au format PDF, FHIR R4 ou en paquet ZIP combiné." + }, + "coach": { + "title": "Coach", + "description": "Activez le Coach, le ton et la portée, les souvenirs enregistrés." } }, "profile": "Profil", diff --git a/messages/it.json b/messages/it.json index 1ea49f7cc..162c586dd 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4125,6 +4125,10 @@ "gesundheitsakte": { "title": "Cartella clinica", "description": "Esporta la tua cartella clinica completa in PDF, FHIR R4 o in un pacchetto ZIP combinato." + }, + "coach": { + "title": "Coach", + "description": "Attiva il Coach, tono e ambito, ricordi salvati." } }, "profile": "Profilo", diff --git a/messages/pl.json b/messages/pl.json index d9572696e..c16c94efd 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4125,6 +4125,10 @@ "gesundheitsakte": { "title": "Dokumentacja zdrowotna", "description": "Wyeksportuj pełną dokumentację zdrowotną jako PDF, FHIR R4 lub łączony pakiet ZIP." + }, + "coach": { + "title": "Coach", + "description": "Włącz Coacha, ton i zakres, zapisane wspomnienia." } }, "profile": "Profil", diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index d8a0c6faa..151e90266 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -6,6 +6,7 @@ import { AboutSection } from "@/components/settings/about-section"; import { AdvancedSection } from "@/components/settings/advanced-section"; import { AiSection } from "@/components/settings/ai-section"; import { ApiSection } from "@/components/settings/api-section"; +import { CoachSection } from "@/components/settings/coach-section"; import { DashboardSection } from "@/components/settings/dashboard-section"; import { ExportSection } from "@/components/settings/export-section"; import { GesundheitsakteSection } from "@/components/settings/gesundheitsakte-section"; @@ -52,6 +53,7 @@ const SECTION_COMPONENTS: Record< modules: ModulesSection, about: AboutSection, ai: AiSection, + coach: CoachSection, integrations: IntegrationsSection, notifications: NotificationsSection, layout: LayoutSection, diff --git a/src/components/settings/__tests__/ai-section.test.tsx b/src/components/settings/__tests__/ai-section.test.tsx index 3df5f94a1..23b061286 100644 --- a/src/components/settings/__tests__/ai-section.test.tsx +++ b/src/components/settings/__tests__/ai-section.test.tsx @@ -196,27 +196,12 @@ describe(" — dropdown-driven UX (B2)", () => { expect(html).toContain("Primärer Provider"); }); - // v1.4.47 W3 — per-user "Hide Coach" toggle. The card lives at the - // bottom of the AI section so privacy-leaning users never scroll - // past the provider configuration to find it. - it("mounts the Hide-Coach toggle card", () => { + // v1.18.0 (S5) — the Coach preference cards (disable toggle, preferences, + // memory) moved out to the dedicated Coach section; the AI section keeps + // only provider / model / BYOK plus the "about me" context. + it("no longer mounts the Coach cards (moved to the Coach section)", () => { activeProviderQuery = "codex"; const html = render(); - expect(html).toContain('data-testid="settings-disable-coach-card"'); - expect(html).toContain('data-testid="settings-disable-coach-switch"'); - // The localised label + helper line are both surfaced — proves - // the i18n keys resolve and the description string ships with the - // bilingual copy required by the QoL audit (M2). - expect(html).toContain("Hide Coach"); - expect(html).toContain("Hides the Coach button and drawer everywhere."); - }); - - it("renders the German Hide-Coach copy", () => { - activeProviderQuery = "codex"; - const html = render("de"); - expect(html).toContain("Coach ausblenden"); - expect(html).toContain( - "Versteckt den Coach-Button und das Coach-Panel auf allen Seiten.", - ); + expect(html).not.toContain('data-testid="settings-disable-coach-card"'); }); }); diff --git a/src/components/settings/__tests__/coach-section.test.tsx b/src/components/settings/__tests__/coach-section.test.tsx new file mode 100644 index 000000000..09c8be1a4 --- /dev/null +++ b/src/components/settings/__tests__/coach-section.test.tsx @@ -0,0 +1,66 @@ +/** + * v1.18.0 (S5) — Settings → Coach section. + * + * The Coach preference cards (disable toggle, preferences, memory) moved + * out of the AI / Assistant section into their own dedicated, module-gated + * entry. SSR-only smoke test — interactive contracts live in the cards' + * own suites (`disable-coach-card.test.tsx`, `coach-memory-section.test.tsx`). + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/coach", +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +const authState: { disableCoach: boolean } = { disableCoach: false }; +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", role: "USER", disableCoach: authState.disableCoach }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { CoachSection } from "../coach-section"; + +function render(locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + + , + ); +} + +describe(" — SSR smoke", () => { + it("renders the section scaffold + the Hide-Coach toggle card", () => { + authState.disableCoach = false; + const html = render(); + expect(html).toContain('id="settings-section-coach-title"'); + expect(html).toContain('data-testid="settings-disable-coach-card"'); + expect(html).toContain('data-testid="settings-disable-coach-switch"'); + expect(html).toContain("Hide Coach"); + expect(html).toContain("Hides the Coach button and drawer everywhere."); + // Raw key never leaks past i18n. + expect(html).not.toContain("settings.sections.coach."); + }); + + it("renders the German Hide-Coach copy", () => { + authState.disableCoach = false; + const html = render("de"); + expect(html).toContain("Coach ausblenden"); + expect(html).toContain( + "Versteckt den Coach-Button und das Coach-Panel auf allen Seiten.", + ); + expect(html).not.toContain("settings.sections.coach."); + }); +}); diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx index de376e2e5..b93cbea27 100644 --- a/src/components/settings/__tests__/disable-coach-card.test.tsx +++ b/src/components/settings/__tests__/disable-coach-card.test.tsx @@ -32,12 +32,15 @@ vi.mock("@/hooks/use-auth", async () => { }; }); -// Stub every other query hook the parent `` triggers so -// the SSR pass doesn't blow up. The disable-coach card itself doesn't -// trigger any GET — it only writes through PATCH on click. +// Stub the navigation hooks the parent `` (and its child +// cards) reach for so the SSR pass doesn't blow up. The disable-coach card +// itself doesn't trigger any GET — it only writes through PATCH on click. +// +// v1.18.0 (S5) — the Coach cards moved from `` to the dedicated +// ``; this contract test renders the new parent. vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), - usePathname: () => "/settings/ai", + usePathname: () => "/settings/coach", useSearchParams: () => new URLSearchParams(""), })); @@ -67,7 +70,7 @@ function buildUser(disableCoach: boolean): AuthUser { }; } -import { AiSection } from "../ai-section"; +import { CoachSection } from "../coach-section"; function makeFetch() { return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { @@ -104,7 +107,7 @@ function render(): string { return renderToStaticMarkup( - + , ); diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index 6576ce5c3..eca579f0a 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -86,7 +86,9 @@ describe("SETTINGS_SECTION_SLUGS", () => { // v1.18.0 — `modules` ("Was du trackst") sits right after `account` as // the single front door for enabling/disabling secondary domains. // v1.18.0 (S5) — `gesundheitsakte` (the full health-record export) lifts - // out of Export & Import into its own top-level entry, before `export`. + // out of Export & Import into its own top-level entry, before `export`; + // `coach` gathers the Coach preference cards out of the AI section and + // sits right after `ai`. expect([...SETTINGS_SECTION_SLUGS]).toEqual([ "account", "modules", @@ -99,6 +101,7 @@ describe("SETTINGS_SECTION_SLUGS", () => { "mood", "thresholds", "ai", + "coach", "api", "gesundheitsakte", "export", @@ -219,6 +222,19 @@ describe("", () => { expect(enabled.match(moodActive)?.length ?? 0).toBe(2); }); + it("module-gates the Coach entry off `user.modules.coach` (v1.18.0 S5)", () => { + // Fail-open default: coach undefined → entry shown. + expect(renderShell({ active: "account" })).toContain( + 'href="/settings/coach"', + ); + // Explicitly disabled → entry hidden. + const disabled = renderShell({ + active: "account", + modules: { coach: false }, + }); + expect(disabled).not.toContain('href="/settings/coach"'); + }); + it("falls back to `account` when the pathname doesn't match a known slug", () => { const html = renderShell({ pathname: "/settings/totally-bogus" }); const activeLinkRegex = diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx index 9fe3b8e6c..d30e8c7e7 100644 --- a/src/components/settings/ai-section.tsx +++ b/src/components/settings/ai-section.tsx @@ -5,23 +5,24 @@ * * v1.16.4 — the former 2k-LOC monolith is split into per-provider * subcomponents under `src/components/settings/ai/` (one file per - * provider form, plus the chain / runtime / coach-toggle cards). This - * file only owns the section frame and the card order; behaviour lives - * in the parts. + * provider form, plus the chain / runtime cards). This file only owns + * the section frame and the card order; behaviour lives in the parts. + * + * v1.18.0 (S5) — the Coach preference cards (disable toggle, preferences, + * memory) moved out to the dedicated Coach section. The AI section keeps + * only provider / model / BYOK configuration plus the "about me" context + * (which the daily briefing reads too, so it is not Coach-only). */ import { AboutMeSection } from "@/components/settings/about-me-section"; import { AiInsightsCard } from "@/components/settings/ai/ai-insights-card"; -import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card"; -import { CoachMemorySection } from "@/components/settings/coach-memory-section"; -import { CoachPrefsSection } from "@/components/settings/coach-prefs-section"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; export function AiSection() { const { t } = useTranslations(); - const { isAuthenticated, user } = useAuth(); + const { isAuthenticated } = useAuth(); // v1.16.6 — the auth query can resolve before this boundary // hydrates; every `disabled={!isAuthenticated}` binding in the child // cards would then disagree with the SSR HTML (React #418). Gate the @@ -30,12 +31,6 @@ export function AiSection() { // on the first client re-render. const mounted = useMounted(); const authed = mounted && isAuthenticated; - // 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. - // (`mounted`-gated like `authed`: SSR renders the panels, so the - // hydration render must too, even when the user disabled the Coach.) - const coachEnabled = !mounted || !user?.disableCoach; return (
@@ -50,24 +45,10 @@ export function AiSection() { - {/* v1.4.47 W3 — per-user Coach opt-out. Lives below the provider - card so users who want the surface gone never have to scroll - through provider configuration before finding the toggle. */} - - - {/* v1.16.1 — Coach preferences (tone, verbosity, data scope) - moved here from the in-chat sheet; the chat header gear - deep-links to this section. Gated on the Coach being - enabled. */} - {coachEnabled && } - - {/* v1.11.2 — "What the Coach remembers": durable-fact review + - forget controls. Gated on the Coach being enabled. */} - {coachEnabled && } - {/* v1.15.20 — user-authored "about me" context for the Coach + daily briefing. Not gated on the Coach toggle: the briefing - reads it too. */} + reads it too, so it stays in the AI section rather than the + Coach section. */}
); diff --git a/src/components/settings/coach-section.tsx b/src/components/settings/coach-section.tsx new file mode 100644 index 000000000..fc9563edf --- /dev/null +++ b/src/components/settings/coach-section.tsx @@ -0,0 +1,65 @@ +"use client"; + +/** + * v1.18.0 (S5) — Settings → Coach. + * + * The Coach preference cards moved out of the AI / Assistant section into + * their own dedicated entry so the AI section owns only provider / model / + * BYOK configuration. This section gathers the Coach-specific surface: + * + * 1. Disable Coach — the per-user opt-out (kept first so a user who + * wants the surface gone finds it immediately). + * 2. Coach preferences — tone, verbosity, data scope (`useCoachPrefs`). + * 3. Coach memory — durable-fact review + forget controls. + * + * The nav entry is module-gated on `coach`. The preference + memory cards + * keep their own content gate on `!user.disableCoach` (mirrored from the + * old AI section): hiding the Coach hides its tuning + memory controls too. + * + * The "about me" context lives in the AI section, not here — the daily + * briefing reads it too, so it is not a Coach-only setting. + */ + +import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card"; +import { CoachMemorySection } from "@/components/settings/coach-memory-section"; +import { CoachPrefsSection } from "@/components/settings/coach-prefs-section"; +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { useTranslations } from "@/lib/i18n/context"; + +export function CoachSection() { + const { t } = useTranslations(); + const { isAuthenticated, user } = useAuth(); + // Mirror the AI section's hydration-safe gating: the auth query can + // resolve before this boundary hydrates, so gate the child props on + // `useMounted()` to keep the hydration render matching the SSR HTML. + const mounted = useMounted(); + const authed = mounted && isAuthenticated; + // The preference + memory cards ride the same content gate as the rest + // of the Coach surface: when the user has hidden the Coach there is no + // assistant to tune or remember anything. (`mounted`-gated so the SSR + + // hydration renders agree.) + const coachEnabled = !mounted || !user?.disableCoach; + + return ( +
+
+

+ {t("settings.sections.coach.title")} +

+

+ {t("settings.sections.coach.description")} +

+
+ + + + {coachEnabled && } + + {coachEnabled && } +
+ ); +} diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx index 977327aee..5dec2f479 100644 --- a/src/components/settings/section-placeholder.tsx +++ b/src/components/settings/section-placeholder.tsx @@ -14,6 +14,7 @@ import { Bell, Blocks, + Bot, Download, FileHeart, Info, @@ -46,6 +47,7 @@ const SLUG_ICON: Record = { mood: Smile, thresholds: SlidersHorizontal, ai: Sparkles, + coach: Bot, api: KeyRound, gesundheitsakte: FileHeart, export: Download, diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index e943bb479..33ee0ff6c 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -48,6 +48,10 @@ export const SETTINGS_SECTION_SLUGS = [ "mood", "thresholds", "ai", + // v1.18.0 (S5) — `coach` gathers the Coach preference cards (disable + // toggle, preferences, memory) out of the AI section, module-gated on + // the coach module. The `ai` entry keeps provider / model / BYOK only. + "coach", "api", "gesundheitsakte", "export", diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index df8d1e46e..d96b4160a 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -20,6 +20,7 @@ import { usePathname } from "next/navigation"; import { Bell, Blocks, + Bot, Download, FileHeart, Info, @@ -145,6 +146,15 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ icon: SlidersHorizontal, }, { slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles }, + // v1.18.0 (S5) — Coach: the Coach preference cards get their own entry, + // shown only when the coach module is enabled. The AI entry above keeps + // provider / model / BYOK configuration. + { + slug: "coach", + titleKey: "settings.sections.coach.title", + icon: Bot, + moduleGate: "coach", + }, { slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound }, // v1.18.0 (S5) — the full health record (PDF + FHIR R4 + zip package) // earns its own home, lifted out of Export & Import. Always available.