diff --git a/src/components/settings/__tests__/coach-memory-while-off.test.tsx b/src/components/settings/__tests__/coach-memory-while-off.test.tsx index 96eecaca1..ffa3ee4b0 100644 --- a/src/components/settings/__tests__/coach-memory-while-off.test.tsx +++ b/src/components/settings/__tests__/coach-memory-while-off.test.tsx @@ -24,6 +24,8 @@ vi.mock("@/hooks/use-auth", () => ({ role: "USER", timezone: "Europe/Berlin", disableCoach: authState.disableCoach, + // The server's module map mirrors the record's opt-out. + modules: { coach: !authState.disableCoach }, }, isAuthenticated: true, isLoading: false, diff --git a/src/components/settings/__tests__/coach-reminders-read-only.test.tsx b/src/components/settings/__tests__/coach-reminders-read-only.test.tsx index 6a2011886..21af3b076 100644 --- a/src/components/settings/__tests__/coach-reminders-read-only.test.tsx +++ b/src/components/settings/__tests__/coach-reminders-read-only.test.tsx @@ -34,6 +34,8 @@ vi.mock("@/hooks/use-auth", () => ({ role: "USER", timezone: "Europe/Berlin", disableCoach: !coach.available, + // The record's module map, which the section's gate reads. + modules: { coach: coach.available }, }, isAuthenticated: true, isLoading: false, diff --git a/src/components/settings/__tests__/coach-section.test.tsx b/src/components/settings/__tests__/coach-section.test.tsx index f24d59fff..20c9689d2 100644 --- a/src/components/settings/__tests__/coach-section.test.tsx +++ b/src/components/settings/__tests__/coach-section.test.tsx @@ -14,6 +14,13 @@ vi.mock("next/navigation", () => ({ usePathname: () => "/settings/coach", })); +// Past hydration only where a test says so: the gate reads the account once +// mounted, and the pre-hydration pass shows every card. +const mountState = { mounted: false }; +vi.mock("@/hooks/use-mounted", () => ({ + useMounted: () => mountState.mounted, +})); + vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }), useInfiniteQuery: () => ({ data: undefined, isLoading: false }), @@ -31,10 +38,18 @@ vi.mock("@/hooks/use-ai-capability", () => ({ }), })); -const authState: { disableCoach: boolean } = { disableCoach: false }; +const authState: { + disableCoach: boolean; + modules: { coach?: boolean } | undefined; +} = { disableCoach: false, modules: undefined }; vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ - user: { id: "u1", role: "USER", disableCoach: authState.disableCoach }, + user: { + id: "u1", + role: "USER", + disableCoach: authState.disableCoach, + modules: authState.modules, + }, isAuthenticated: true, isLoading: false, refetch: vi.fn(), @@ -83,3 +98,24 @@ describe(" — SSR smoke", () => { expect(html).not.toContain("settings.coach.activate."); }); }); + +describe(" — the tuning follows the record, not the account", () => { + it("hides the nudge when the record has the Coach off, whatever the viewer's own opt-out says", () => { + // A guardian with the Coach on, inside a managed record that has it off: + // `/me` publishes the viewer's own `disableCoach` and the RECORD's module + // map. The record's answer decides. + mountState.mounted = true; + authState.disableCoach = false; + authState.modules = { coach: false }; + try { + expect(render()).not.toContain('id="coach-nudge"'); + authState.disableCoach = true; + authState.modules = { coach: true }; + expect(render()).toContain('id="coach-nudge"'); + } finally { + mountState.mounted = false; + authState.disableCoach = false; + authState.modules = undefined; + } + }); +}); diff --git a/src/components/settings/coach-section.tsx b/src/components/settings/coach-section.tsx index e4a0ccc83..631029590 100644 --- a/src/components/settings/coach-section.tsx +++ b/src/components/settings/coach-section.tsx @@ -17,8 +17,12 @@ * 4. Coach memory — durable-fact review + forget controls. * * The nav entry is module-gated on `coach`. The preference + nudge cards - * keep their own content gate on `!user.disableCoach`: hiding the Coach hides - * its tuning. The memory (facts, stored conversations and reminders) is never + * keep their own content gate on the same published module state + * (`user.modules.coach`, resolved by the server for the record on screen + * from that record's opt-out and the operator's switches): hiding the Coach + * hides its tuning. The raw `disableCoach` column is the signed-in person's + * own, so inside a managed or shared record it answered for the wrong + * person. The memory (facts, stored conversations and reminders) is never * gated: it is the person's own record, readable and deletable whatever the * Coach's state. The reminders card turns read-only while the Coach is * unavailable and, with the Coach hidden, shows only when rows exist. @@ -39,6 +43,7 @@ import { CoachNudgeCard } from "@/components/settings/coach-nudge-card"; import { CoachPrefsSection } from "@/components/settings/coach-prefs-section"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; +import { isSurfaceVisible } from "@/lib/modules/surface"; export function CoachSection() { const { isAuthenticated, user } = useAuth(); @@ -47,11 +52,13 @@ export function CoachSection() { // `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; + // The preference + nudge cards ride the same content gate as the rest of + // the Coach surface: when the record has the Coach hidden there is no + // assistant to tune. Read from the server's module map for the record, + // never recomputed from the account's own columns. (`mounted`-gated so the + // SSR + hydration renders agree.) + const coachEnabled = + !mounted || isSurfaceVisible("settings:coach", user?.modules); return (