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:
Marc-André Bombeck committed 2026-09-24 11:57:42 +02:00
1 parent 5e5d33ecf8
commit 6e45acfbc6
19 files changed
+303 -57

No files matched your search

+7 -23
View File
@@ -9593,11 +9593,11 @@ paths:
tags:
- Insights
summary: List the caller's Coach reminders
description: v1.22 (B2/B6) — the durable "remind me about X" memory the Coach captured inline, decrypted on the fly,
description: 'v1.22 (B2/B6) — the durable "remind me about X" memory the Coach captured inline, decrypted on the fly,
soonest-due first. Pass `?status=` (one status or a comma set like `due,surfaced` for the in-app tile); omitted
returns the non-terminal set (proposed + active + due + surfaced). Coach-gated
(`requireModuleEnabled("coach")`). Auth via cookie or Bearer; the owner is narrowed from the session.
Undecryptable rows are omitted.
returns the non-terminal set (proposed + active + due + surfaced). Not gated on the Coach: the reminders belong
to the caller and stay readable while the Coach is unavailable (v1.39). Auth via cookie or Bearer; the owner is
narrowed from the session. Undecryptable rows are omitted.'
parameters:
- name: status
in: query
@@ -9614,24 +9614,7 @@ paths:
schema:
$ref: "#/components/schemas/CoachRemindersList"
"401": *a1
"403":
description: >-
Coach surface disabled.
Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`).
Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections
do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it
is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's
audit trail instead. On the cookie transport the same code answers one further case: a session that has
entered a shared record and sends a request without its record-context assertion, which is a client older
than the fence — leave the record and reload. A request that names no record but the caller's own never
reaches this response, so a client that never switches and never sends the per-request account selector will
not see it.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"403": *a9
"422": *a4
"429": *a5
post:
@@ -9719,7 +9702,8 @@ paths:
- Insights
summary: Soft-delete one Coach reminder
description: "v1.22 — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted id
is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer."
is an idempotent no-op returning `{ deleted: false }`. Not gated on the Coach: erasing a stored reminder works
while the Coach is unavailable (v1.39). Auth via cookie or Bearer."
parameters:
- name: id
in: path
+2 -1
View File
@@ -5794,7 +5794,8 @@
"deleteConfirmTitle": "Diese Erinnerung löschen?",
"deleteConfirmBody": "Sie wird nicht mehr ausgelöst. An bereits erfassten Daten ändert sich nichts.",
"deleteConfirmAction": "Löschen",
"detail": "Fällige erscheinen hier und beim Coach."
"detail": "Fällige erscheinen hier und beim Coach.",
"readOnlyNote": "Der Coach ist gerade nicht verfügbar, deine Erinnerungen kannst du aber weiter lesen und löschen."
},
"aboutMe": {
"title": "Über mich",
+2 -1
View File
@@ -5794,7 +5794,8 @@
"deleteConfirmTitle": "Delete this reminder?",
"deleteConfirmBody": "It stops being raised. Anything you have already recorded is untouched.",
"deleteConfirmAction": "Delete",
"detail": "Due ones show up here and on the Coach."
"detail": "Due ones show up here and on the Coach.",
"readOnlyNote": "The Coach is not available right now, but you can still read and delete your reminders."
},
"aboutMe": {
"title": "About me",
+2 -1
View File
@@ -5794,7 +5794,8 @@
"deleteConfirmTitle": "¿Eliminar este recordatorio?",
"deleteConfirmBody": "Deja de aparecer. Lo que ya has registrado no cambia.",
"deleteConfirmAction": "Eliminar",
"detail": "Las que vencen aparecen aquí y en el Coach."
"detail": "Las que vencen aparecen aquí y en el Coach.",
"readOnlyNote": "El Coach no está disponible ahora mismo, pero aún puedes leer y eliminar tus recordatorios."
},
"aboutMe": {
"title": "Sobre mí",
+2 -1
View File
@@ -5794,7 +5794,8 @@
"deleteConfirmTitle": "Supprimer ce rappel ?",
"deleteConfirmBody": "Il ne se déclenchera plus. Ce que tu as déjà enregistré reste intact.",
"deleteConfirmAction": "Supprimer",
"detail": "Celles qui arrivent à échéance apparaissent ici et chez le Coach."
"detail": "Celles qui arrivent à échéance apparaissent ici et chez le Coach.",
"readOnlyNote": "Le Coach n'est pas disponible pour le moment, mais tu peux toujours lire et supprimer tes rappels."
},
"aboutMe": {
"title": "À propos de moi",
+2 -1
View File
@@ -5794,7 +5794,8 @@
"deleteConfirmTitle": "Eliminare questo promemoria?",
"deleteConfirmBody": "Non verrà più mostrato. Quanto hai già registrato resta invariato.",
"deleteConfirmAction": "Elimina",
"detail": "Quelle in scadenza compaiono qui e nel Coach."
"detail": "Quelle in scadenza compaiono qui e nel Coach.",
"readOnlyNote": "Il Coach non è disponibile al momento, ma puoi ancora leggere ed eliminare i tuoi promemoria."
},
"aboutMe": {
"title": "Su di me",
+2 -1
View File
@@ -5794,7 +5794,8 @@
"deleteConfirmTitle": "이 알림을 삭제할까요?",
"deleteConfirmBody": "더 이상 알려 주지 않아요. 이미 기록한 내용은 그대로예요.",
"deleteConfirmAction": "삭제",
"detail": "예정된 알림은 여기와 코치에 나타나요."
"detail": "예정된 알림은 여기와 코치에 나타나요.",
"readOnlyNote": "지금은 코치를 사용할 수 없지만 알림은 계속 읽고 삭제할 수 있어요."
},
"aboutMe": {
"title": "나에 대해",
+2 -1
View File
@@ -5794,7 +5794,8 @@
"deleteConfirmTitle": "Usunąć to przypomnienie?",
"deleteConfirmBody": "Przestanie się pojawiać. Zapisane wcześniej dane pozostają bez zmian.",
"deleteConfirmAction": "Usuń",
"detail": "Te, których termin nadszedł, pojawiają się tutaj i w Coachu."
"detail": "Te, których termin nadszedł, pojawiają się tutaj i w Coachu.",
"readOnlyNote": "Coach jest teraz niedostępny, ale nadal możesz czytać i usuwać swoje przypomnienia."
},
"aboutMe": {
"title": "O mnie",
@@ -330,6 +330,11 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
// The stored plans list. Its sibling `[id]` route keeps the Coach gate on
// PATCH (confirming a proposal is Coach use) and erases without it.
"src/app/api/coach/plans/route.ts",
// Not listed, by the same rule: `coach/reminders/route.ts` lists without the
// Coach gate but keeps it on POST (creating one is Coach use), and
// `coach/reminders/[id]/route.ts` erases without it but keeps it on PATCH.
// Both files still carry a gate, so the walk counts them as gated and an
// entry here would fail the "does not hide a gate" check below.
"src/app/api/insights/chat/[id]/route.ts",
"src/app/api/insights/chat/messages/[id]/feedback/route.ts",
// The Today digest and its dismiss: data, with the AI parts masked by the
+5 -2
View File
@@ -14,6 +14,11 @@
* no-op rather than a P2025 throw — the existence channel never leaks across
* accounts. PATCH on a 0-count match returns 404; DELETE returns the idempotent
* `{ deleted: false }`.
*
* PATCH is Coach use (it confirms, resolves or reschedules what the Coach
* captured) and is gated by `requireModuleEnabled(userId, "coach")`. DELETE is
* not: erasing one's own stored reminder never depends on the Coach being
* available, the same rule as the stored facts, conversations and plans.
*/
import type { NextRequest } from "next/server";
@@ -152,8 +157,6 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
export const DELETE = apiHandler(
async (_request: NextRequest, ctx: RouteCtx) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const limited = await enforceMutateLimit("delete", user.id);
if (limited) return limited;
@@ -125,6 +125,14 @@ const callDelete = (id = "r1") =>
{ params: Promise.resolve({ id }) },
);
/** The Coach module gate refuses: operator switch, module off or Hide Coach. */
function coachOff() {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: new Response(null, { status: 403 }),
} as never);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
@@ -162,7 +170,14 @@ describe("GET /api/coach/reminders", () => {
?.where as { userId: string; deletedAt: null };
expect(where.userId).toBe("user-1");
expect(where.deletedAt).toBeNull();
expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach");
});
it("lists stored reminders with the Coach unavailable (they are the person's record)", async () => {
coachOff();
vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([] as never);
const res = await callGet();
expect(res.status).toBe(200);
expect(requireModuleEnabled).not.toHaveBeenCalled();
});
it("skips an undecryptable row rather than 500ing", async () => {
@@ -202,6 +217,13 @@ describe("GET /api/coach/reminders", () => {
});
describe("POST /api/coach/reminders", () => {
it("stays Coach use: refused with the Coach unavailable", async () => {
coachOff();
const res = await callPost({ note: "check my sleep" });
expect(res.status).toBe(403);
expect(prisma.coachReminder.create).not.toHaveBeenCalled();
});
it("creates a reminder field-by-field, resolving the when grammar", async () => {
vi.mocked(prisma.coachReminder.count).mockResolvedValue(0 as never);
vi.mocked(prisma.coachReminder.create).mockResolvedValue({
@@ -248,6 +270,14 @@ describe("POST /api/coach/reminders", () => {
});
describe("PATCH /api/coach/reminders/[id]", () => {
it("stays Coach use: refused with the Coach unavailable", async () => {
coachOff();
const res = await callPatch({ status: "active" });
expect(res.status).toBe(403);
expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach");
expect(prisma.coachReminder.updateMany).not.toHaveBeenCalled();
});
it("confirms proposed → active field-by-field, owner-scoped", async () => {
vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({
count: 1,
@@ -309,4 +339,16 @@ describe("DELETE /api/coach/reminders/[id]", () => {
const body = (await res.json()) as { data: { deleted: boolean } };
expect(body.data.deleted).toBe(false);
});
it("erases a stored reminder with the Coach unavailable", async () => {
coachOff();
vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({
count: 1,
} as never);
const res = await callDelete();
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { deleted: boolean } };
expect(body.data.deleted).toBe(true);
expect(requireModuleEnabled).not.toHaveBeenCalled();
});
});
+9 -7
View File
@@ -9,9 +9,13 @@
*
* Ownership: every query is scoped `where: { userId, ... }`, so a caller can
* only ever see / create their own reminders. The owner is always narrowed from
* the session, never the body. Coach-gated by the same
* `requireModuleEnabled(userId, "coach")` kill-switch as the rest of the Coach
* management stack.
* the session, never the body.
*
* GET is not gated on the Coach. A reminder is the person's own stored record:
* reading it never depends on the Coach being available, the same rule as the
* stored facts, conversations and plans (a Coach switched off by the operator
* or hidden by the person must not lock them out of what it kept). POST is
* Coach use and stays gated by `requireModuleEnabled(userId, "coach")`.
*
* `?status=` optionally filters (one status, or a comma set like `due,surfaced`
* for the in-app tile). Omitted returns the non-terminal set (proposed / active
@@ -77,11 +81,9 @@ export const GET = apiHandler(async (req: Request) => {
// the delegate's own ledger while the page shows another person's record,
// which is the failure mode this whole mode system exists to prevent.
//
// The module gate below resolves against the record, so the surface exists
// for a delegate only where the OWNER switched the Coach on.
// No Coach gate: the ledger is readable whatever the Coach's state, for the
// owner and for a delegate with a read grant alike, like the mood note.
const { user } = await requireRecordAuth("read", "record");
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const url = new URL(req.url);
const parsed = coachRemindersListQuerySchema.safeParse({
@@ -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
+10 -6
View File
@@ -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>
);
}
+4 -4
View File
@@ -1745,7 +1745,7 @@ export const coachReminderPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "List the caller's Coach reminders",
description:
'v1.22 (B2/B6) — the durable "remind me about X" memory the Coach captured inline, decrypted on the fly, soonest-due first. Pass `?status=` (one status or a comma set like `due,surfaced` for the in-app tile); omitted returns the non-terminal set (proposed + active + due + surfaced). Coach-gated (`requireModuleEnabled("coach")`). Auth via cookie or Bearer; the owner is narrowed from the session. Undecryptable rows are omitted.',
'v1.22 (B2/B6) — the durable "remind me about X" memory the Coach captured inline, decrypted on the fly, soonest-due first. Pass `?status=` (one status or a comma set like `due,surfaced` for the in-app tile); omitted returns the non-terminal set (proposed + active + due + surfaced). Not gated on the Coach: the reminders belong to the caller and stay readable while the Coach is unavailable (v1.39). Auth via cookie or Bearer; the owner is narrowed from the session. Undecryptable rows are omitted.',
parameters: [
{
name: "status",
@@ -1769,8 +1769,8 @@ export const coachReminderPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
// The read is delegable and the create beside it is not, so only this
// one shares its 403 with the sharing refusal.
...recordRefusal("Coach surface disabled."),
// one answers the sharing refusal. It asks no Coach gate.
...recordRefusal(),
...stdResponses,
},
},
@@ -1857,7 +1857,7 @@ export const coachReminderPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Soft-delete one Coach reminder",
description:
"v1.22 — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer.",
"v1.22 — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`. Not gated on the Coach: erasing a stored reminder works while the Coach is unavailable (v1.39). Auth via cookie or Bearer.",
parameters: [
{
name: "id",