diff --git a/src/components/insights/__tests__/ai-setup-hint-target.test.tsx b/src/components/insights/__tests__/ai-setup-hint-target.test.tsx index 6e06357f6..c25d84aba 100644 --- a/src/components/insights/__tests__/ai-setup-hint-target.test.tsx +++ b/src/components/insights/__tests__/ai-setup-hint-target.test.tsx @@ -3,12 +3,21 @@ * phone it is the only control on the tile a thumb has to hit, so it carries * the 44 px floor every other mobile action does (`size-11`), stepping down * from `sm` like the rest of the app. + * + * The hint is also an invitation, so it stays away from a person who switched + * both the Coach and AI analysis off. */ import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +const auth = vi.hoisted(() => ({ + modules: {} as Record, +})); vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ user: { id: "u1" }, isAuthenticated: true }), + useAuth: () => ({ + user: { id: "u1", modules: auth.modules }, + isAuthenticated: true, + }), })); vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); vi.mock("@/hooks/use-ai-capability", () => ({ @@ -22,16 +31,21 @@ vi.mock("@/hooks/use-ai-capability", () => ({ import { I18nProvider } from "@/lib/i18n/context"; import { AiSetupHint } from "../ai-setup-hint"; -function dismissButton(): string { +function render(surface: "insights" | "settings" = "insights"): string { vi.stubGlobal("window", { localStorage: { getItem: () => null, setItem: () => undefined }, }); const html = renderToStaticMarkup( - + , ); vi.unstubAllGlobals(); + return html; +} + +function dismissButton(): string { + const html = render(); const match = html.match( /]*data-slot="ai-setup-hint-dismiss"[^>]*>/, ); @@ -46,3 +60,22 @@ describe("the setup hint's dismiss button", () => { expect(cls).toContain("size-11"); }); }); + +describe("the setup hint after an opt-out", () => { + afterEach(() => { + auth.modules = {}; + }); + + it("is gone on both surfaces once the Coach and AI analysis are off", () => { + auth.modules = { coach: false, insights: false }; + expect(render("insights")).not.toContain('data-slot="ai-setup-hint"'); + expect(render("settings")).not.toContain('data-slot="ai-setup-hint"'); + }); + + it("stays while either one is still on", () => { + auth.modules = { coach: false, insights: true }; + expect(render("insights")).toContain('data-slot="ai-setup-hint"'); + auth.modules = { coach: true, insights: false }; + expect(render("settings")).toContain('data-slot="ai-setup-hint"'); + }); +}); diff --git a/src/components/insights/ai-setup-hint.tsx b/src/components/insights/ai-setup-hint.tsx index 89bdb7a9b..c8fa9abdc 100644 --- a/src/components/insights/ai-setup-hint.tsx +++ b/src/components/insights/ai-setup-hint.tsx @@ -13,6 +13,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions"; import { useAiProviderState } from "@/hooks/use-ai-capability"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; +import { hasOptedOutOfAi } from "@/lib/ai/capabilities/types"; import { useTranslations } from "@/lib/i18n/context"; /** @@ -26,8 +27,9 @@ import { useTranslations } from "@/lib/i18n/context"; * It shows only while no provider is configured AND the person could set one * up here (`provider.canConfigure`: false inside somebody else's record and * while the operator has AI switched off, where the hint would point at a - * setting nobody on this screen can change). Dismissing it is remembered per - * viewer in this browser; the hint stays gone on both surfaces. + * setting nobody on this screen can change) AND they have not switched both + * the Coach and AI analysis off (`hasOptedOutOfAi`). Dismissing it is + * remembered per viewer in this browser; the hint stays gone on both surfaces. */ const AI_SETUP_HINT_STORAGE_PREFIX = "healthlog.ai-setup-hint.dismissed"; @@ -63,6 +65,7 @@ export function AiSetupHint({ const viewerId = user?.id ?? null; if (!mounted || viewerId === null) return null; if (provider.configured || !provider.canConfigure) return null; + if (hasOptedOutOfAi(user?.modules)) return null; if (dismissedNow || readDismissed(viewerId)) return null; const dismiss = () => { diff --git a/src/components/onboarding/__tests__/done-screen.test.tsx b/src/components/onboarding/__tests__/done-screen.test.tsx index 85725cd66..e704d891a 100644 --- a/src/components/onboarding/__tests__/done-screen.test.tsx +++ b/src/components/onboarding/__tests__/done-screen.test.tsx @@ -62,8 +62,9 @@ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: () => {}, replace: () => {} }), })); +const auth = vi.hoisted(() => ({ modules: {} as Record })); vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ user: { id: "u1" } }), + useAuth: () => ({ user: { id: "u1", modules: auth.modules } }), })); /** @@ -154,6 +155,13 @@ describe(" AI panel", () => { providerState.canConfigure = true; }); + it("leaves the panel out once the Coach and AI analysis are switched off", () => { + auth.modules = { coach: false, insights: false }; + aiProviderState.data = undefined; + expect(render()).not.toContain('data-slot="onboarding-ai-panel"'); + auth.modules = {}; + }); + it("keeps every exit so the panel is fully skippable", () => { aiProviderState.data = undefined; const html = render(); diff --git a/src/components/onboarding/__tests__/getting-started-checklist.test.tsx b/src/components/onboarding/__tests__/getting-started-checklist.test.tsx index 13d7733dc..0f9ac9a92 100644 --- a/src/components/onboarding/__tests__/getting-started-checklist.test.tsx +++ b/src/components/onboarding/__tests__/getting-started-checklist.test.tsx @@ -153,6 +153,28 @@ describe(" — rows for an established account", () => }; }); + it("leaves the AI row out once the Coach and AI analysis are switched off", () => { + // Nothing configured, so the row would be an open to-do: the person + // declined AI in Settings, Modules, and the list does not ask again. + providerState = { + configured: false, + managedBy: null, + canConfigure: true, + responseTimeoutMs: 60_000, + }; + currentUser = user({ modules: { coach: false, insights: false } }); + expect(render()).toContain("4 of 5 done"); + // Either one still on keeps the row. + currentUser = user({ modules: { coach: false, insights: true } }); + expect(render()).toContain("4 of 6 done"); + providerState = { + configured: true, + managedBy: "user", + canConfigure: true, + responseTimeoutMs: 60_000, + }; + }); + it("keeps the supporting queries enabled while the card can render", () => { currentUser = user(); render(); diff --git a/src/components/onboarding/done-screen.tsx b/src/components/onboarding/done-screen.tsx index 39a6d2f80..7e86bcc4e 100644 --- a/src/components/onboarding/done-screen.tsx +++ b/src/components/onboarding/done-screen.tsx @@ -28,6 +28,7 @@ import { setTourReferrer } from "@/components/onboarding/tour-launcher"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { useGrantAiConsent } from "@/hooks/use-grant-ai-consent"; +import { hasOptedOutOfAi } from "@/lib/ai/capabilities/types"; import { useTranslations } from "@/lib/i18n/context"; import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage"; import type { OnboardingStateDto } from "@/lib/onboarding/needs"; @@ -133,10 +134,12 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) { // The panel is one optional offer, shown only while this person could set // AI up here: `provider.canConfigure` is false while the operator has AI // switched off and inside a record somebody else owns. HealthLog is - // complete without it, so with nothing to offer the screen simply goes - // from "done" to the exits. + // complete without it, so with nothing to offer (or once the person + // switched the Coach and AI analysis off) the screen simply goes from + // "done" to the exits. const providerState = useAiProviderState(); - const showAiPanel = providerState.canConfigure; + const showAiPanel = + providerState.canConfigure && !hasOptedOutOfAi(user?.modules); const { data: aiProvider } = useQuery({ queryKey: queryKeys.userAiProvider(), queryFn: async () => { diff --git a/src/components/onboarding/getting-started-checklist.tsx b/src/components/onboarding/getting-started-checklist.tsx index e8bc4d4d0..ba5fcae95 100644 --- a/src/components/onboarding/getting-started-checklist.tsx +++ b/src/components/onboarding/getting-started-checklist.tsx @@ -21,6 +21,7 @@ import type { LucideIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useAccountOnceMounted } from "@/hooks/use-auth"; +import { hasOptedOutOfAi } from "@/lib/ai/capabilities/types"; import { useTranslations } from "@/lib/i18n/context"; import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; import { queryKeys } from "@/lib/query-keys"; @@ -263,9 +264,10 @@ export function GettingStartedChecklist() { }); // The AI row reads the provider state `/api/auth/me` already carries: - // present only while AI can be set up from here (`canConfigure`), done once - // any provider is configured (a personal key, a local model, an OAuth - // sign-in or the operator's shared key). No request of its own. + // present only while AI can be set up from here (`canConfigure`) and the + // person has not switched the Coach and AI analysis off, done once any + // provider is configured (a personal key, a local model, an OAuth sign-in or + // the operator's shared key). No request of its own. const aiProvider = useAiProviderState(); // v1.5: skip these two fetches once the card can't render at @@ -336,7 +338,8 @@ export function GettingStartedChecklist() { (channel) => channel?.enabled === true, ); const insightsConfigured = aiProvider.configured; - const aiConfigurable = aiProvider.canConfigure; + const aiConfigurable = + aiProvider.canConfigure && !hasOptedOutOfAi(user?.modules); const items = useMemo( () => diff --git a/src/lib/ai/capabilities/types.ts b/src/lib/ai/capabilities/types.ts index bb1397d80..6c3e411e4 100644 --- a/src/lib/ai/capabilities/types.ts +++ b/src/lib/ai/capabilities/types.ts @@ -189,6 +189,22 @@ export const AI_OPT_OUT_MODULES: ReadonlySet = new Set([ "insights", ]); +/** + * Whether the record has every opt-out module switched off, read from the + * module map on `/api/auth/me` (a key the map does not carry counts as on). + * An invitation to set AI up has nothing left to offer then. It does not gate + * the AI settings themselves: document reading, lab photos and medication + * extraction belong to other modules and still need a provider and consent. + */ +export function hasOptedOutOfAi( + modules: Partial> | null | undefined, +): boolean { + for (const key of AI_OPT_OUT_MODULES) { + if (modules?.[key] !== false) return false; + } + return true; +} + const COACH_KINDS: readonly AiConsentKind[] = ["ai_coach", "ai_full"]; const INSIGHTS_KINDS: readonly AiConsentKind[] = [ "ai_insights_only", diff --git a/src/lib/onboarding/checklist.ts b/src/lib/onboarding/checklist.ts index 3b5b24f02..cf1f3b4f1 100644 --- a/src/lib/onboarding/checklist.ts +++ b/src/lib/onboarding/checklist.ts @@ -83,11 +83,12 @@ export interface ChecklistInputs { */ insightsConfigured: boolean; /** - * Whether AI can be set up from here at all (`ai.provider.canConfigure`): - * false while the operator has AI switched off and inside a record - * somebody else owns. The AI row exists only when true. HealthLog is - * complete without AI, so the list never carries a to-do nobody on this - * screen can finish. + * Whether AI can be set up from here at all (`ai.provider.canConfigure`) + * and is still wanted: false while the operator has AI switched off, inside + * a record somebody else owns, and once the person switched the Coach and + * AI analysis off. The AI row exists only when true. HealthLog is complete + * without AI, so the list never carries a to-do nobody on this screen can + * finish or that was declined. */ aiConfigurable: boolean; /** Dismissed item ids (per-item localStorage state). */