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. */}