mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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"');
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user