feat(settings): gather Coach settings into a dedicated entry

Move the Coach preference cards (disable toggle, preferences, durable-fact
memory) out of the AI / Assistant section into their own Coach entry,
module-gated on the coach module. The AI entry keeps its name and owns
provider / model / BYOK configuration plus the "about me" context (which
the daily briefing reads too, so it is not Coach-only).

The proactive Coach-nudge card stays in Notifications: it is a reminder
TYPE, already coach-module-gated there alongside the other reminder types,
and belongs with the reminder family rather than the Coach preferences.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 23:28:22 +02:00
1 parent fce9ca8f1a
commit 9c5e7a0186
16 files changed
+214 -56

No files matched your search

+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+2
View File
@@ -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,
@@ -196,27 +196,12 @@ describe("<AiSection> — 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"');
});
});
@@ -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(
<I18nProvider initialLocale={locale}>
<CoachSection />
</I18nProvider>,
);
}
describe("<CoachSection> — 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.");
});
});
@@ -32,12 +32,15 @@ vi.mock("@/hooks/use-auth", async () => {
};
});
// Stub every other query hook the parent `<AiSection>` 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 `<CoachSection>` (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 `<AiSection>` to the dedicated
// `<CoachSection>`; 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(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<AiSection />
<CoachSection />
</I18nProvider>
</QueryClientProvider>,
);
@@ -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("<SettingsShell>", () => {
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 =
+10 -29
View File
@@ -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 (
<section aria-labelledby="settings-section-ai-title" className="space-y-6">
@@ -50,24 +45,10 @@ export function AiSection() {
<AiInsightsCard isAuthenticated={authed} />
{/* 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. */}
<DisableCoachCard isAuthenticated={authed} />
{/* 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 && <CoachPrefsSection isAuthenticated={authed} />}
{/* v1.11.2 — "What the Coach remembers": durable-fact review +
forget controls. Gated on the Coach being enabled. */}
{coachEnabled && <CoachMemorySection isAuthenticated={authed} />}
{/* 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. */}
<AboutMeSection isAuthenticated={authed} />
</section>
);
+65
View File
@@ -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 (
<section
aria-labelledby="settings-section-coach-title"
className="space-y-6"
>
<header className="space-y-1">
<h1 id="settings-section-coach-title" className="sr-only">
{t("settings.sections.coach.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.coach.description")}
</p>
</header>
<DisableCoachCard isAuthenticated={authed} />
{coachEnabled && <CoachPrefsSection isAuthenticated={authed} />}
{coachEnabled && <CoachMemorySection isAuthenticated={authed} />}
</section>
);
}
@@ -14,6 +14,7 @@
import {
Bell,
Blocks,
Bot,
Download,
FileHeart,
Info,
@@ -46,6 +47,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
mood: Smile,
thresholds: SlidersHorizontal,
ai: Sparkles,
coach: Bot,
api: KeyRound,
gesundheitsakte: FileHeart,
export: Download,
+4
View File
@@ -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",
@@ -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.