mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
feat(coach): keep stored reminders readable and erasable with the Coach unavailable
Reminders are the person's own record, like the stored facts, conversations and plans. The list and delete routes no longer ask the Coach module; creating, confirming, resolving or rescheduling one stays Coach use and is refused by POST and PATCH as before. Settings -> Coach now always mounts the reminders card (shown with Hide Coach on only when reminders exist), and Settings -> AI lists them through the stored-memory block while Settings -> Coach is out of reach. With the Coach unavailable the card is read-only: every reminder can be read and deleted, while keep / done / dismiss are not offered.
This commit is contained in:
1 parent
5e5d33ecf8
commit
6e45acfbc6
19 files changed
+303
-57
No files matched your search
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Stored Coach reminders stay readable and erasable with the Coach unavailable.
|
||||
*
|
||||
* A "remind me about X" note is the person's own record, like the stored facts
|
||||
* and conversations. Before v1.39 Settings → Coach mounted the reminders card
|
||||
* only while the Coach was shown, and Settings → AI never mounted it, so a
|
||||
* person whose Coach was switched off or hidden could neither read nor erase
|
||||
* what it had kept. Now:
|
||||
*
|
||||
* - the card lists every reminder and offers delete on each, whatever the
|
||||
* Coach's state;
|
||||
* - keeping, resolving or dismissing one is Coach use (the PATCH route
|
||||
* refuses it then), so those controls appear only with the Coach
|
||||
* available;
|
||||
* - Settings → Coach shows the card with Hide Coach on, and Settings → AI
|
||||
* shows it through `<StoredCoachMemory>` only when reminders exist.
|
||||
*/
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const coach = vi.hoisted(() => ({ available: false }));
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({
|
||||
available: coach.available,
|
||||
reason: coach.available ? null : "user_disabled",
|
||||
onDeviceAllowed: false,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
disableCoach: !coach.available,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
|
||||
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 { CoachRemindersSection } from "../coach-reminders-section";
|
||||
import { CoachSection } from "../coach-section";
|
||||
import { StoredCoachMemory } from "../coach-memory-section";
|
||||
|
||||
const reminder = (id: string, status: string) => ({
|
||||
id,
|
||||
note: `note ${id}`,
|
||||
metric: null,
|
||||
triggerKind: "date",
|
||||
dueAt: null,
|
||||
contextCue: null,
|
||||
status,
|
||||
source: "extractor",
|
||||
createdAt: "2026-09-01T08:00:00.000Z",
|
||||
updatedAt: "2026-09-01T08:00:00.000Z",
|
||||
});
|
||||
const STORED = [
|
||||
reminder("r-proposed", "proposed"),
|
||||
reminder("r-active", "active"),
|
||||
reminder("r-surfaced", "surfaced"),
|
||||
];
|
||||
|
||||
function render(node: React.ReactNode, reminders: unknown[]): string {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0, staleTime: Infinity } },
|
||||
});
|
||||
client.setQueryData(queryKeys.coachReminders(), reminders);
|
||||
client.setQueryData(queryKeys.coachFacts(), []);
|
||||
client.setQueryData(queryKeys.coachConversationHistory(""), {
|
||||
pages: [{ conversations: [], nextCursor: null }],
|
||||
pageParams: [null],
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const count = (html: string, needle: string) => html.split(needle).length - 1;
|
||||
|
||||
beforeEach(() => {
|
||||
// Everything is seeded; a background refetch must not reach the network.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("{}", { status: 500 })),
|
||||
);
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("<CoachRemindersSection>", () => {
|
||||
it("stays readable and erasable with the Coach unavailable", () => {
|
||||
coach.available = false;
|
||||
const html = render(<CoachRemindersSection isAuthenticated />, STORED);
|
||||
expect(count(html, 'data-testid="settings-coach-reminder"')).toBe(3);
|
||||
expect(html).toContain("note r-proposed");
|
||||
// Every reminder can be removed.
|
||||
expect(count(html, 'data-slot="coach-reminder-delete"')).toBe(3);
|
||||
// Keeping, resolving or dismissing one is Coach use and is not offered.
|
||||
expect(html).not.toContain("settings-coach-reminder-confirm");
|
||||
expect(html).not.toContain("settings-coach-reminder-done");
|
||||
expect(html).not.toContain("settings-coach-reminder-dismiss");
|
||||
expect(html).toContain("you can still read and delete your reminders");
|
||||
});
|
||||
|
||||
it("offers the lifecycle controls with the Coach available", () => {
|
||||
coach.available = true;
|
||||
const html = render(<CoachRemindersSection isAuthenticated />, STORED);
|
||||
expect(html).toContain('data-testid="settings-coach-reminder-confirm"');
|
||||
expect(html).toContain('data-testid="settings-coach-reminder-done"');
|
||||
expect(html).toContain('data-testid="settings-coach-reminder-dismiss"');
|
||||
expect(count(html, 'data-slot="coach-reminder-delete"')).toBe(3);
|
||||
expect(html).not.toContain("you can still read and delete your reminders");
|
||||
});
|
||||
});
|
||||
|
||||
describe("where the reminders show while the Coach is unavailable", () => {
|
||||
it("Settings → Coach shows them with Hide Coach on", () => {
|
||||
coach.available = false;
|
||||
const html = render(<CoachSection />, STORED);
|
||||
expect(html).toContain('data-testid="settings-coach-reminders-card"');
|
||||
expect(html).toContain("note r-active");
|
||||
// The Coach's tuning stays hidden with the Coach.
|
||||
expect(html).not.toContain('id="coach-nudge"');
|
||||
});
|
||||
|
||||
it("Settings → Coach does not invite new reminders with Hide Coach on", () => {
|
||||
coach.available = false;
|
||||
const html = render(<CoachSection />, []);
|
||||
expect(html).not.toContain('data-testid="settings-coach-reminders-card"');
|
||||
});
|
||||
|
||||
it("Settings → AI shows them through <StoredCoachMemory> when they exist", () => {
|
||||
coach.available = false;
|
||||
const html = render(<StoredCoachMemory isAuthenticated />, STORED);
|
||||
expect(html).toContain('data-testid="settings-coach-reminders-card"');
|
||||
expect(count(html, 'data-slot="coach-reminder-delete"')).toBe(3);
|
||||
});
|
||||
|
||||
it("Settings → AI shows nothing for somebody with no stored reminders", () => {
|
||||
coach.available = false;
|
||||
expect(render(<StoredCoachMemory isAuthenticated />, [])).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,16 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
// The reminders card reads the Coach capability off `/api/auth/me`; the
|
||||
// react-query mock above carries no context for the real hook to read.
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
const authState: { disableCoach: boolean } = { disableCoach: false };
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
|
||||
@@ -25,6 +25,16 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn(), setQueryData: vi.fn() }),
|
||||
}));
|
||||
|
||||
// The reminders card reads the Coach capability off `/api/auth/me`; the
|
||||
// react-query mock above carries no context for the real hook to read.
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/settings",
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
* v1.39 — never gated on the Coach. What the Coach stored is the person's
|
||||
* own record: it stays readable and deletable while the Coach is off for any
|
||||
* reason (the operator's switch, Hide Coach, no provider, no consent). The
|
||||
* facts routes are data routes and ask no AI gate. Settings → Coach always
|
||||
* facts routes are data routes and ask no AI gate. The same holds for the
|
||||
* stored reminders (`<CoachRemindersSection>`). Settings → Coach always
|
||||
* shows this card; while that page is not reachable, Settings → AI shows it
|
||||
* through `<StoredCoachMemory>` below, whenever rows exist.
|
||||
*/
|
||||
@@ -51,6 +52,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
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";
|
||||
|
||||
/** Closed enum mirrored from the server `CoachFact.category` column. */
|
||||
const FACT_CATEGORIES = [
|
||||
@@ -310,6 +312,7 @@ export function StoredCoachMemory({
|
||||
isAuthenticated={isAuthenticated}
|
||||
hideWhenEmpty
|
||||
/>
|
||||
<CoachRemindersSection isAuthenticated={isAuthenticated} hideWhenEmpty />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,15 @@
|
||||
* DELETE /api/coach/reminders/{id} → { data: { deleted } }
|
||||
*
|
||||
* Reads unwrap `(await res.json()).data`; every key routes through
|
||||
* `queryKeys.coachReminders()` so a mutation invalidates the list. Gated on the
|
||||
* Coach surface like the rest of the memory controls.
|
||||
* `queryKeys.coachReminders()` so a mutation invalidates the list.
|
||||
*
|
||||
* v1.39 — never gated on the Coach. A reminder is the person's own record, like
|
||||
* the stored facts and conversations: the list and delete routes ask no Coach
|
||||
* gate, so the card stays readable and every reminder stays deletable while the
|
||||
* Coach is unavailable for any reason. Keeping, resolving or dismissing one is
|
||||
* Coach use (the PATCH route refuses it then), so those controls are not
|
||||
* offered in that state. Settings → Coach always mounts this card; Settings → AI
|
||||
* mounts it through `<StoredCoachMemory>` with `hideWhenEmpty`.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import { BellRing, Check, Trash2, X } from "lucide-react";
|
||||
@@ -28,6 +35,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import {
|
||||
useCoachReminders,
|
||||
useCoachReminderMutations,
|
||||
@@ -38,10 +46,16 @@ const DUE_STATUSES = new Set(["due", "surfaced"]);
|
||||
|
||||
export function CoachRemindersSection({
|
||||
isAuthenticated,
|
||||
hideWhenEmpty = false,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
/** Render nothing while there are no stored reminders (the Coach-off mount). */
|
||||
hideWhenEmpty?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
// Unavailable (including the loading frame) means read-only: the lifecycle
|
||||
// controls appear only once the account says the Coach can be used.
|
||||
const readOnly = !useAiCapability("coach").available;
|
||||
const query = useCoachReminders({ enabled: isAuthenticated });
|
||||
const { setStatus, remove } = useCoachReminderMutations();
|
||||
|
||||
@@ -87,7 +101,7 @@ export function CoachRemindersSection({
|
||||
<span>{t(`settings.ai.coachReminders.status.${r.status}`)}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isProposed && (
|
||||
{!readOnly && isProposed && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -101,7 +115,7 @@ export function CoachRemindersSection({
|
||||
{t("settings.ai.coachReminders.confirm")}
|
||||
</Button>
|
||||
)}
|
||||
{r.status !== "done" && (
|
||||
{!readOnly && r.status !== "done" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -115,7 +129,7 @@ export function CoachRemindersSection({
|
||||
{t("settings.ai.coachReminders.markDone")}
|
||||
</Button>
|
||||
)}
|
||||
{(r.status === "due" || r.status === "surfaced") && (
|
||||
{!readOnly && (r.status === "due" || r.status === "surfaced") && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -151,6 +165,9 @@ export function CoachRemindersSection({
|
||||
);
|
||||
};
|
||||
|
||||
if (hideWhenEmpty && (query.isPending || query.isError)) return null;
|
||||
if (hideWhenEmpty && reminders.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
as="section"
|
||||
@@ -163,7 +180,11 @@ export function CoachRemindersSection({
|
||||
title={t("settings.ai.coachReminders.title")}
|
||||
description={t("settings.ai.coachReminders.description")}
|
||||
/>
|
||||
<p className="text-sm">{t("settings.ai.coachReminders.detail")}</p>
|
||||
<p className="text-sm" data-slot="coach-reminders-detail">
|
||||
{readOnly
|
||||
? t("settings.ai.coachReminders.readOnlyNote")
|
||||
: t("settings.ai.coachReminders.detail")}
|
||||
</p>
|
||||
|
||||
{query.isError && (
|
||||
<QueryErrorRow
|
||||
|
||||
@@ -16,11 +16,12 @@
|
||||
* notification).
|
||||
* 4. Coach memory — durable-fact review + forget controls.
|
||||
*
|
||||
* The nav entry is module-gated on `coach`. The preference + nudge +
|
||||
* reminder cards keep their own content gate on `!user.disableCoach`: hiding
|
||||
* the Coach hides its tuning. The memory (facts and stored conversations) is
|
||||
* never gated: it is the person's own record, readable and deletable whatever
|
||||
* the Coach's state.
|
||||
* The nav entry is module-gated on `coach`. The preference + nudge cards
|
||||
* keep their own content gate on `!user.disableCoach`: hiding the Coach hides
|
||||
* its tuning. The memory (facts, stored conversations and reminders) is never
|
||||
* gated: it is the person's own record, readable and deletable whatever the
|
||||
* Coach's state. The reminders card turns read-only while the Coach is
|
||||
* unavailable and, with the Coach hidden, shows only when rows exist.
|
||||
*
|
||||
* The "about me" context lives in the AI section, not here — the daily
|
||||
* briefing reads it too, so it is not a Coach-only setting.
|
||||
@@ -69,7 +70,10 @@ export function CoachSection() {
|
||||
|
||||
<CoachConversationsMemoryCard isAuthenticated={authed} />
|
||||
|
||||
{coachEnabled && <CoachRemindersSection isAuthenticated={authed} />}
|
||||
<CoachRemindersSection
|
||||
isAuthenticated={authed}
|
||||
hideWhenEmpty={!coachEnabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user