From e49997bf9794230123a3235c440e69d2ccd193e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 12:21:21 +0200 Subject: [PATCH] fix(settings): offer Coach links only while the Coach can open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Coach's sources pointer and the About-me follow-up questions linked to /coach whatever the Coach's state. Without a provider or consent the Coach page sends the visitor straight back to Insights, so the link only bounced. Both now follow the Coach capability; the questions themselves stay readable. --- .../records/about-me-note-manager.tsx | 22 +++-- .../coach-links-without-coach.test.tsx | 80 +++++++++++++++++++ .../settings/coach-prefs-section.tsx | 29 ++++--- 3 files changed, 111 insertions(+), 20 deletions(-) create mode 100644 src/components/settings/__tests__/coach-links-without-coach.test.tsx diff --git a/src/components/records/about-me-note-manager.tsx b/src/components/records/about-me-note-manager.tsx index d53bc11d7..f214d7460 100644 --- a/src/components/records/about-me-note-manager.tsx +++ b/src/components/records/about-me-note-manager.tsx @@ -36,6 +36,7 @@ import { QueryErrorRow } from "@/components/ui/query-error-row"; import { SettingsInfoTile } from "@/components/settings/_info-tile"; import { Label } from "@/components/ui/label"; import { ConfirmButton } from "@/components/ui/confirm-button"; +import { useAiCapability } from "@/hooks/use-ai-capability"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; @@ -61,6 +62,7 @@ const FIELD_CLASSES = export function AboutMeNoteManager() { const { t } = useTranslations(); const queryClient = useQueryClient(); + const coach = useAiCapability("coach"); const [draft, setDraft] = useState(null); const query = useQuery({ @@ -194,14 +196,18 @@ export function AboutMeNoteManager() {
  • {q}
  • ))} -

    - - {t("settings.ai.aboutMe.questionsOpenCoach")} - -

    + {/* Only while the Coach can open; without it the link would + bounce back to Insights. The questions stay readable. */} + {coach.available ? ( +

    + + {t("settings.ai.aboutMe.questionsOpenCoach")} + +

    + ) : null} )} diff --git a/src/components/settings/__tests__/coach-links-without-coach.test.tsx b/src/components/settings/__tests__/coach-links-without-coach.test.tsx new file mode 100644 index 000000000..8eddb9860 --- /dev/null +++ b/src/components/settings/__tests__/coach-links-without-coach.test.tsx @@ -0,0 +1,80 @@ +/** + * A link to `/coach` while the Coach is unavailable (no provider, no + * consent) only bounces: the Coach page sends the visitor back to Insights. + * Settings → Coach's sources pointer and the About-me questions tile offer + * the link only while the Coach can actually open. + */ +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const coachState = vi.hoisted(() => ({ available: true })); +vi.mock("@/hooks/use-ai-capability", () => ({ + useAiCapability: () => ({ + available: coachState.available, + reason: coachState.available ? null : "no_provider", + onDeviceAllowed: false, + }), +})); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/coach", +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs"; +import { CoachPrefsSection } from "../coach-prefs-section"; +import { AboutMeNoteManager } from "@/components/records/about-me-note-manager"; + +function render(node: React.ReactNode): string { + const client = new QueryClient({ + defaultOptions: { queries: { retry: 0, staleTime: Infinity } }, + }); + client.setQueryData(queryKeys.coachPrefs(), DEFAULT_COACH_PREFS); + client.setQueryData(queryKeys.coachAboutMe(), { + aboutMe: null, + conditions: null, + allergies: null, + coachFocus: null, + pendingQuestions: ["How do you sleep?"], + updatedAt: "2026-09-01T00:00:00.000Z", + maxChars: 4000, + fieldMaxChars: 500, + }); + return renderToStaticMarkup( + + {node} + , + ); +} + +beforeEach(() => { + coachState.available = true; +}); + +describe("Coach links follow the Coach's capability", () => { + it("the sources pointer links to the Coach while it is available", () => { + expect(render()).toContain( + 'href="/coach"', + ); + }); + + it("the sources pointer offers no dead link without the Coach", () => { + coachState.available = false; + const html = render(); + expect(html).not.toContain('href="/coach"'); + expect(html).not.toContain("coach-prefs-sources-pointer"); + }); + + it("the About-me questions link to the Coach while it is available", () => { + expect(render()).toContain('href="/coach"'); + }); + + it("the About-me questions stay readable but lose the link without the Coach", () => { + coachState.available = false; + const html = render(); + expect(html).toContain("How do you sleep?"); + expect(html).not.toContain('href="/coach"'); + }); +}); diff --git a/src/components/settings/coach-prefs-section.tsx b/src/components/settings/coach-prefs-section.tsx index 75d8e9404..fd89b98ad 100644 --- a/src/components/settings/coach-prefs-section.tsx +++ b/src/components/settings/coach-prefs-section.tsx @@ -39,6 +39,7 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs"; +import { useAiCapability } from "@/hooks/use-ai-capability"; import { DEFAULT_COACH_PREFS, type CoachExcludeMetric, @@ -74,6 +75,7 @@ export interface CoachPrefsSectionProps { export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) { const { t } = useTranslations(); + const coach = useAiCapability("coach"); const { data: persisted } = useCoachPrefs({ enabled: isAuthenticated }); @@ -225,19 +227,22 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) { used to render its own copies of both controls; two writable surfaces for the same persisted fields is exactly the redundancy the rail was built to end. A pointer replaces - them. */} -

    - {t("insights.coach.settingsSourcesPointer")}{" "} - - {t("insights.coach.settingsSourcesPointerLink")} - -

    + {t("insights.coach.settingsSourcesPointer")}{" "} + + {t("insights.coach.settingsSourcesPointerLink")} + +

    + ) : null} {/* Exclude metrics — checked = excluded. */}