mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(coach): keep stored plans and conversations reachable with the Coach off
The page gate matched /coach/plans and /coach/conversations to the Coach module by the nav prefix, so with the Coach switched off by the operator or hidden by the person both pages were replaced by the module notice and never reached their read-only mode. Both are now stored-record pages the gate leaves alone. With the Coach off nothing in the navigation led there either. Settings now carries a plans card, shown when plans exist, and the conversations card links to the full list.
This commit is contained in:
1 parent
4ae231d757
commit
03ecb5bd52
14 files changed
+265
-31
No files matched your search
+7
-1
@@ -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": {
|
||||
|
||||
+7
-1
@@ -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": {
|
||||
|
||||
+7
-1
@@ -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": {
|
||||
|
||||
+7
-1
@@ -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": {
|
||||
|
||||
+7
-1
@@ -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": {
|
||||
|
||||
+7
-1
@@ -5867,7 +5867,13 @@
|
||||
"coachConversations": {
|
||||
"title": "코치 대화",
|
||||
"description": "대화를 열어 읽거나 삭제하세요.",
|
||||
"more": "더 보기"
|
||||
"more": "더 보기",
|
||||
"openAll": "모든 대화 열기"
|
||||
},
|
||||
"coachPlans": {
|
||||
"title": "코치 계획",
|
||||
"description": "코치와 합의한 계획을 읽거나 삭제할 수 있습니다.",
|
||||
"open": "계획 열기"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
+7
-1
@@ -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": {
|
||||
|
||||
@@ -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("<ModulePageGate>", () => {
|
||||
@@ -98,4 +103,29 @@ describe("<ModulePageGate>", () => {
|
||||
});
|
||||
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"');
|
||||
});
|
||||
});
|
||||
@@ -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<string> = [
|
||||
"/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:<slug>`), so
|
||||
* `/insights/mood` and `/insights/workouts/<id>` follow their own module while
|
||||
* `/insights` itself follows none. Every other page answers through the nav
|
||||
* destination it sits under (`nav:<href>`), most specific first.
|
||||
* destination it sits under (`nav:<href>`), 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}`);
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={client(stored)}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
@@ -142,3 +156,43 @@ describe("<StoredCoachMemory> (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(<StoredCoachMemory isAuthenticated />, {
|
||||
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(<CoachSection />, {
|
||||
facts: [],
|
||||
conversations: [],
|
||||
plans: [PLAN],
|
||||
});
|
||||
expect(html).toContain('href="/coach/plans"');
|
||||
});
|
||||
|
||||
it("shows no plans card without plans", () => {
|
||||
const html = render(<StoredCoachMemory isAuthenticated />, {
|
||||
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(<StoredCoachMemory isAuthenticated />, {
|
||||
facts: [],
|
||||
conversations: [CONVERSATION],
|
||||
});
|
||||
expect(html).toContain('data-slot="settings-coach-conversations-open-all"');
|
||||
expect(html).toContain('href="/coach/conversations"');
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{history.hasNextPage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 self-start sm:min-h-9"
|
||||
disabled={history.isFetchingNextPage}
|
||||
onClick={() => void history.fetchNextPage()}
|
||||
>
|
||||
{t("settings.ai.coachConversations.more")}
|
||||
</Button>
|
||||
) : null}
|
||||
<SettingsCardActions align="start">
|
||||
{history.hasNextPage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={history.isFetchingNextPage}
|
||||
onClick={() => void history.fetchNextPage()}
|
||||
>
|
||||
{t("settings.ai.coachConversations.more")}
|
||||
</Button>
|
||||
) : null}
|
||||
{visible.length > 0 ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Link
|
||||
href="/coach/conversations"
|
||||
data-slot="settings-coach-conversations-open-all"
|
||||
>
|
||||
{t("settings.ai.coachConversations.openAll")}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
/>
|
||||
<CoachPlansMemoryCard isAuthenticated={isAuthenticated} />
|
||||
<CoachRemindersSection isAuthenticated={isAuthenticated} hideWhenEmpty />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<SettingsCard
|
||||
as="section"
|
||||
aria-labelledby="settings-coach-plans-title"
|
||||
data-testid="settings-coach-plans-card"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={Target}
|
||||
titleId="settings-coach-plans-title"
|
||||
title={t("settings.ai.coachPlans.title")}
|
||||
description={t("settings.ai.coachPlans.description")}
|
||||
/>
|
||||
<SettingsCardActions align="start">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Link href="/coach/plans" data-slot="settings-coach-plans-open">
|
||||
{t("settings.ai.coachPlans.open")}
|
||||
</Link>
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
<CoachConversationsMemoryCard isAuthenticated={authed} />
|
||||
|
||||
<CoachPlansMemoryCard isAuthenticated={authed} />
|
||||
|
||||
<CoachRemindersSection
|
||||
isAuthenticated={authed}
|
||||
hideWhenEmpty={!coachEnabled}
|
||||
|
||||
Reference in new issue
Block a user