diff --git a/messages/de.json b/messages/de.json index 68029e2d1..5525ae9b3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5867,7 +5867,13 @@ "coachConversations": { "title": "Coach-Gespräche", "description": "Öffne ein Gespräch, um es zu lesen, oder lösche es.", - "more": "Mehr anzeigen" + "more": "Mehr anzeigen", + "openAll": "Alle Gespräche öffnen" + }, + "coachPlans": { + "title": "Coach-Pläne", + "description": "Die Pläne, die du mit dem Coach vereinbart hast, zum Lesen oder Entfernen.", + "open": "Pläne öffnen" } }, "dashboard": { diff --git a/messages/en.json b/messages/en.json index e39c7e57d..c5d1af4df 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5867,7 +5867,13 @@ "coachConversations": { "title": "Coach conversations", "description": "Open a conversation to read it, or delete it.", - "more": "Show more" + "more": "Show more", + "openAll": "Open all conversations" + }, + "coachPlans": { + "title": "Coach plans", + "description": "The plans you agreed with the Coach, to read or remove.", + "open": "Open plans" } }, "dashboard": { diff --git a/messages/es.json b/messages/es.json index 6e86d01ab..b4b3587f2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5867,7 +5867,13 @@ "coachConversations": { "title": "Conversaciones con el Coach", "description": "Abre una conversación para leerla o elimínala.", - "more": "Mostrar más" + "more": "Mostrar más", + "openAll": "Abrir todas las conversaciones" + }, + "coachPlans": { + "title": "Planes del Coach", + "description": "Los planes que acordaste con el Coach, para leerlos o eliminarlos.", + "open": "Abrir planes" } }, "dashboard": { diff --git a/messages/fr.json b/messages/fr.json index 9fe90dcb7..98bedae5c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5867,7 +5867,13 @@ "coachConversations": { "title": "Conversations avec le Coach", "description": "Ouvrez une conversation pour la lire, ou supprimez-la.", - "more": "Afficher plus" + "more": "Afficher plus", + "openAll": "Ouvrir toutes les conversations" + }, + "coachPlans": { + "title": "Plans du Coach", + "description": "Les plans convenus avec le Coach, à lire ou à supprimer.", + "open": "Ouvrir les plans" } }, "dashboard": { diff --git a/messages/it.json b/messages/it.json index f617caf03..f9aa7f83b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5867,7 +5867,13 @@ "coachConversations": { "title": "Conversazioni con il Coach", "description": "Apri una conversazione per leggerla oppure eliminala.", - "more": "Mostra altro" + "more": "Mostra altro", + "openAll": "Apri tutte le conversazioni" + }, + "coachPlans": { + "title": "Piani del Coach", + "description": "I piani concordati con il Coach, da leggere o rimuovere.", + "open": "Apri i piani" } }, "dashboard": { diff --git a/messages/ko.json b/messages/ko.json index b03990b57..7d71980ef 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5867,7 +5867,13 @@ "coachConversations": { "title": "코치 대화", "description": "대화를 열어 읽거나 삭제하세요.", - "more": "더 보기" + "more": "더 보기", + "openAll": "모든 대화 열기" + }, + "coachPlans": { + "title": "코치 계획", + "description": "코치와 합의한 계획을 읽거나 삭제할 수 있습니다.", + "open": "계획 열기" } }, "dashboard": { diff --git a/messages/pl.json b/messages/pl.json index d041381ff..f2160a932 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5867,7 +5867,13 @@ "coachConversations": { "title": "Rozmowy z Coachem", "description": "Otwórz rozmowę, aby ją przeczytać, albo ją usuń.", - "more": "Pokaż więcej" + "more": "Pokaż więcej", + "openAll": "Otwórz wszystkie rozmowy" + }, + "coachPlans": { + "title": "Plany z Coachem", + "description": "Plany uzgodnione z Coachem, do przeczytania lub usunięcia.", + "open": "Otwórz plany" } }, "dashboard": { diff --git a/src/components/layout/__tests__/module-page-gate.test.tsx b/src/components/layout/__tests__/module-page-gate.test.tsx index 37828815d..f343dd21e 100644 --- a/src/components/layout/__tests__/module-page-gate.test.tsx +++ b/src/components/layout/__tests__/module-page-gate.test.tsx @@ -51,12 +51,17 @@ describe("moduleOwningPath", () => { expect(moduleOwningPath(path)).toBe(owner); }); - it.each(["/", "/measurements", "/insights", "/insights/weight", "/checkups"])( - "%s belongs to no module", - (path) => { - expect(moduleOwningPath(path)).toBeUndefined(); - }, - ); + it.each([ + "/", + "/measurements", + "/insights", + "/insights/weight", + "/checkups", + "/coach/plans", + "/coach/conversations", + ])("%s belongs to no module", (path) => { + expect(moduleOwningPath(path)).toBeUndefined(); + }); }); describe("", () => { @@ -98,4 +103,29 @@ describe("", () => { }); expect(html).toContain('data-module="medications"'); }); + + // Stored plans and conversations are the person's own records: their pages + // turn read-only with the Coach unavailable instead of being replaced. + describe.each(["/coach/plans", "/coach/conversations"])("%s", (path) => { + it.each([ + ["operator switched the Coach off", "unavailable"], + ["the person hid the Coach", "disabled"], + ])("stays reachable when %s", (_label, access) => { + const html = render(path, { + modules: { coach: false }, + moduleAccess: { coach: access }, + }); + expect(html).toContain('data-testid="page"'); + expect(html).not.toContain("module-disabled-notice"); + }); + }); + + it("still replaces the Coach page itself when the Coach is off", () => { + const html = render("/coach", { + modules: { coach: false }, + moduleAccess: { coach: "unavailable" }, + }); + expect(html).not.toContain('data-testid="page"'); + expect(html).toContain('data-module="coach"'); + }); }); diff --git a/src/components/layout/module-page-gate.tsx b/src/components/layout/module-page-gate.tsx index cde724223..8cd75fc4f 100644 --- a/src/components/layout/module-page-gate.tsx +++ b/src/components/layout/module-page-gate.tsx @@ -18,15 +18,33 @@ import { surfaceModule } from "@/lib/modules/surface"; const INSIGHTS_PREFIX = "/insights/"; +/** + * Pages that hold the person's own stored records under a module's nav entry. + * They stay reachable with the module off and turn read-only on their own, so + * a record can always be read and erased; the gate does not replace them. + */ +const STORED_RECORD_PAGES: ReadonlyArray = [ + "/coach/plans", + "/coach/conversations", +]; + +function isStoredRecordPage(pathname: string): boolean { + return STORED_RECORD_PAGES.some( + (p) => pathname === p || pathname.startsWith(`${p}/`), + ); +} + /** * The module that owns the page at `pathname`, from the one surface map. * * An Insights sub-page answers by its slug (`insights-page:`), so * `/insights/mood` and `/insights/workouts/` follow their own module while * `/insights` itself follows none. Every other page answers through the nav - * destination it sits under (`nav:`), most specific first. + * destination it sits under (`nav:`), most specific first. A stored-record + * page (`/coach/plans`, `/coach/conversations`) follows none either. */ export function moduleOwningPath(pathname: string): ModuleKey | undefined { + if (isStoredRecordPage(pathname)) return undefined; if (pathname.startsWith(INSIGHTS_PREFIX)) { const slug = pathname.slice(INSIGHTS_PREFIX.length).split("/")[0]; const owner = surfaceModule(`insights-page:${slug}`); diff --git a/src/components/settings/__tests__/coach-memory-while-off.test.tsx b/src/components/settings/__tests__/coach-memory-while-off.test.tsx index 0459157c3..96eecaca1 100644 --- a/src/components/settings/__tests__/coach-memory-while-off.test.tsx +++ b/src/components/settings/__tests__/coach-memory-while-off.test.tsx @@ -62,7 +62,22 @@ const CONVERSATION = { attachments: [], }; -function client(stored: { facts: unknown[]; conversations: unknown[] }) { +type Stored = { facts: unknown[]; conversations: unknown[]; plans?: unknown[] }; + +const PLAN = { + id: "plan-1", + metric: "steps", + ifCue: "after lunch", + thenAction: "walk ten minutes", + target: null, + status: "active", + reviewDate: null, + sourceConversationId: null, + createdAt: "2026-09-01T08:00:00.000Z", + updatedAt: "2026-09-01T08:00:00.000Z", +}; + +function client(stored: Stored) { const c = new QueryClient({ defaultOptions: { queries: { retry: 0, staleTime: Infinity } }, }); @@ -71,13 +86,12 @@ function client(stored: { facts: unknown[]; conversations: unknown[] }) { pages: [{ conversations: stored.conversations, nextCursor: null }], pageParams: [null], }); + if (stored.plans) + c.setQueryData(queryKeys.coachPlans("scope:all"), stored.plans); return c; } -function render( - node: React.ReactNode, - stored: { facts: unknown[]; conversations: unknown[] }, -) { +function render(node: React.ReactNode, stored: Stored) { return renderToStaticMarkup( {node} @@ -142,3 +156,43 @@ describe(" (Settings → AI while the Coach page is out of re expect(html).toBe(""); }); }); + +describe("the way to /coach/plans and /coach/conversations with the Coach off", () => { + it("links to the stored plans from Settings → AI when plans exist", () => { + const html = render(, { + facts: [], + conversations: [], + plans: [PLAN], + }); + expect(html).toContain('data-testid="settings-coach-plans-card"'); + expect(html).toContain('href="/coach/plans"'); + }); + + it("links to the stored plans from Settings → Coach with Hide Coach on", () => { + authState.disableCoach = true; + const html = render(, { + facts: [], + conversations: [], + plans: [PLAN], + }); + expect(html).toContain('href="/coach/plans"'); + }); + + it("shows no plans card without plans", () => { + const html = render(, { + facts: [], + conversations: [], + plans: [], + }); + expect(html).not.toContain("settings-coach-plans-card"); + }); + + it("links to the full conversation list from the conversations card", () => { + const html = render(, { + facts: [], + conversations: [CONVERSATION], + }); + expect(html).toContain('data-slot="settings-coach-conversations-open-all"'); + expect(html).toContain('href="/coach/conversations"'); + }); +}); diff --git a/src/components/settings/coach-conversations-memory-card.tsx b/src/components/settings/coach-conversations-memory-card.tsx index e218a3725..a2b2ad284 100644 --- a/src/components/settings/coach-conversations-memory-card.tsx +++ b/src/components/settings/coach-conversations-memory-card.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo, useState } from "react"; +import Link from "next/link"; import { MessagesSquare, Trash2 } from "lucide-react"; import { toast } from "sonner"; @@ -8,6 +9,7 @@ import { Button } from "@/components/ui/button"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { ConversationTranscript } from "@/components/insights/coach-panel/conversation-transcript"; import { useCoachConversationHistory, @@ -26,6 +28,10 @@ import { formatRelativeTime } from "@/lib/i18n/relative-time"; * conversation list uses). Reading and deleting never ask the Coach; the * routes behind them are data routes. * + * The card links to `/coach/conversations`, which stays reachable and + * read-only with the Coach off, so the full list and its search are one step + * away even when no navigation entry leads there. + * * `hideWhenEmpty`: the card renders nothing when there is nothing stored. * Settings → AI mounts it that way while the Coach is off, so it appears only * when there is something to see. @@ -121,18 +127,35 @@ export function CoachConversationsMemoryCard({ ))} )} - {history.hasNextPage ? ( - - ) : null} + + {history.hasNextPage ? ( + + ) : null} + {visible.length > 0 ? ( + + ) : null} + ); } diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx index 7625999a6..79b61e663 100644 --- a/src/components/settings/coach-memory-section.tsx +++ b/src/components/settings/coach-memory-section.tsx @@ -53,6 +53,7 @@ import { toast } from "sonner"; import { apiDelete, apiGet } from "@/lib/api/api-fetch"; import { CoachConversationsMemoryCard } from "@/components/settings/coach-conversations-memory-card"; import { CoachRemindersSection } from "@/components/settings/coach-reminders-section"; +import { CoachPlansMemoryCard } from "@/components/settings/coach-plans-memory-card"; /** Closed enum mirrored from the server `CoachFact.category` column. */ const FACT_CATEGORIES = [ @@ -312,6 +313,7 @@ export function StoredCoachMemory({ isAuthenticated={isAuthenticated} hideWhenEmpty /> + ); diff --git a/src/components/settings/coach-plans-memory-card.tsx b/src/components/settings/coach-plans-memory-card.tsx new file mode 100644 index 000000000..6567bcfe1 --- /dev/null +++ b/src/components/settings/coach-plans-memory-card.tsx @@ -0,0 +1,62 @@ +"use client"; + +import Link from "next/link"; +import { Target } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { useCoachPlans } from "@/hooks/use-coach-plans"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * The way to the Coach's stored plans from Settings. + * + * Plans are the person's own record: `/coach/plans` stays reachable and turns + * read-only while the Coach is unavailable, but with the Coach off nothing in + * the navigation leads there any more. This card is that way in. It renders + * only when plans exist, so a person who never agreed a plan sees nothing. + * Reads the same `?scope=all` slot the plans page reads, so the count and the + * page cannot disagree. + */ +export function CoachPlansMemoryCard({ + isAuthenticated, +}: { + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + const query = useCoachPlans({ + filter: { scope: "all" }, + enabled: isAuthenticated, + }); + const count = query.data?.length ?? 0; + if (count === 0) return null; + + return ( + + + + + + + ); +} diff --git a/src/components/settings/coach-section.tsx b/src/components/settings/coach-section.tsx index 882c55ebd..e4a0ccc83 100644 --- a/src/components/settings/coach-section.tsx +++ b/src/components/settings/coach-section.tsx @@ -34,6 +34,7 @@ import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card"; import { CoachMemorySection } from "@/components/settings/coach-memory-section"; import { CoachConversationsMemoryCard } from "@/components/settings/coach-conversations-memory-card"; import { CoachRemindersSection } from "@/components/settings/coach-reminders-section"; +import { CoachPlansMemoryCard } from "@/components/settings/coach-plans-memory-card"; import { CoachNudgeCard } from "@/components/settings/coach-nudge-card"; import { CoachPrefsSection } from "@/components/settings/coach-prefs-section"; import { useAuth } from "@/hooks/use-auth"; @@ -70,6 +71,8 @@ export function CoachSection() { + +