mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
fce9ca8f1a
commit
9c5e7a0186
16 files changed
+214
-56
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user