fix(settings): offer Coach links only while the Coach can open

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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 12:21:21 +02:00
1 parent fbe2cd5833
commit e49997bf97
3 files changed
+111 -20

No files matched your search

@@ -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<string | null>(null);
const query = useQuery({
@@ -194,14 +196,18 @@ export function AboutMeNoteManager() {
<li key={q}>{q}</li>
))}
</ul>
<p className="mt-3 text-xs">
<Link
href="/coach"
className="text-primary underline-offset-4 hover:underline"
>
{t("settings.ai.aboutMe.questionsOpenCoach")}
</Link>
</p>
{/* Only while the Coach can open; without it the link would
bounce back to Insights. The questions stay readable. */}
{coach.available ? (
<p className="mt-3 text-xs">
<Link
href="/coach"
className="text-primary underline-offset-4 hover:underline"
>
{t("settings.ai.aboutMe.questionsOpenCoach")}
</Link>
</p>
) : null}
</SettingsInfoTile>
)}
@@ -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(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
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(<CoachPrefsSection isAuthenticated />)).toContain(
'href="/coach"',
);
});
it("the sources pointer offers no dead link without the Coach", () => {
coachState.available = false;
const html = render(<CoachPrefsSection isAuthenticated />);
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(<AboutMeNoteManager />)).toContain('href="/coach"');
});
it("the About-me questions stay readable but lose the link without the Coach", () => {
coachState.available = false;
const html = render(<AboutMeNoteManager />);
expect(html).toContain("How do you sleep?");
expect(html).not.toContain('href="/coach"');
});
});
+17 -12
View File
@@ -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. */}
<p
data-slot="coach-prefs-sources-pointer"
className="text-muted-foreground border-border/60 rounded-md border border-dashed px-3 py-2 text-xs leading-relaxed"
>
{t("insights.coach.settingsSourcesPointer")}{" "}
<Link
href="/coach"
className="text-foreground underline underline-offset-2"
them. Only while the Coach can open: without it the link
would bounce back to Insights and the rail is out of reach. */}
{coach.available ? (
<p
data-slot="coach-prefs-sources-pointer"
className="text-muted-foreground border-border/60 rounded-md border border-dashed px-3 py-2 text-xs leading-relaxed"
>
{t("insights.coach.settingsSourcesPointerLink")}
</Link>
</p>
{t("insights.coach.settingsSourcesPointer")}{" "}
<Link
href="/coach"
className="text-foreground underline underline-offset-2"
>
{t("insights.coach.settingsSourcesPointerLink")}
</Link>
</p>
) : null}
{/* Exclude metrics — checked = excluded. */}
<div className="flex flex-col gap-2">