diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 466698744..5f1f7a8ee 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index f393249b0..68029e2d1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index ea852d2bc..e39c7e57d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index fbfff98a9..6e86d01ab 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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í", diff --git a/messages/fr.json b/messages/fr.json index 80ba6ed56..9fe90dcb7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index b84aaf102..f617caf03 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index ee1169f2b..b03990b57 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5794,7 +5794,8 @@ "deleteConfirmTitle": "이 알림을 삭제할까요?", "deleteConfirmBody": "더 이상 알려 주지 않아요. 이미 기록한 내용은 그대로예요.", "deleteConfirmAction": "삭제", - "detail": "예정된 알림은 여기와 코치에 나타나요." + "detail": "예정된 알림은 여기와 코치에 나타나요.", + "readOnlyNote": "지금은 코치를 사용할 수 없지만 알림은 계속 읽고 삭제할 수 있어요." }, "aboutMe": { "title": "나에 대해", diff --git a/messages/pl.json b/messages/pl.json index afc4028f0..d041381ff 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index ac88bf326..7bd394e83 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -330,6 +330,11 @@ const EXEMPT_ROUTES: ReadonlyArray = [ // 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 diff --git a/src/app/api/coach/reminders/[id]/route.ts b/src/app/api/coach/reminders/[id]/route.ts index 8c702a04b..e8f65765a 100644 --- a/src/app/api/coach/reminders/[id]/route.ts +++ b/src/app/api/coach/reminders/[id]/route.ts @@ -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; diff --git a/src/app/api/coach/reminders/__tests__/route.test.ts b/src/app/api/coach/reminders/__tests__/route.test.ts index 5f3fd26dd..df26d1d3e 100644 --- a/src/app/api/coach/reminders/__tests__/route.test.ts +++ b/src/app/api/coach/reminders/__tests__/route.test.ts @@ -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(); + }); }); diff --git a/src/app/api/coach/reminders/route.ts b/src/app/api/coach/reminders/route.ts index 1291292a6..00496bd00 100644 --- a/src/app/api/coach/reminders/route.ts +++ b/src/app/api/coach/reminders/route.ts @@ -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({ diff --git a/src/components/settings/__tests__/coach-reminders-read-only.test.tsx b/src/components/settings/__tests__/coach-reminders-read-only.test.tsx new file mode 100644 index 000000000..6a2011886 --- /dev/null +++ b/src/components/settings/__tests__/coach-reminders-read-only.test.tsx @@ -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 `` 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( + + {node} + , + ); +} + +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("", () => { + it("stays readable and erasable with the Coach unavailable", () => { + coach.available = false; + const html = render(, 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(, 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(, 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(, []); + expect(html).not.toContain('data-testid="settings-coach-reminders-card"'); + }); + + it("Settings → AI shows them through when they exist", () => { + coach.available = false; + const html = render(, 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(, [])).toBe(""); + }); +}); diff --git a/src/components/settings/__tests__/coach-section.test.tsx b/src/components/settings/__tests__/coach-section.test.tsx index 064709020..f24d59fff 100644 --- a/src/components/settings/__tests__/coach-section.test.tsx +++ b/src/components/settings/__tests__/coach-section.test.tsx @@ -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: () => ({ diff --git a/src/components/settings/__tests__/settings-read-error-family.test.tsx b/src/components/settings/__tests__/settings-read-error-family.test.tsx index 9181bf587..18f03becd 100644 --- a/src/components/settings/__tests__/settings-read-error-family.test.tsx +++ b/src/components/settings/__tests__/settings-read-error-family.test.tsx @@ -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", diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx index 67a4141bf..7625999a6 100644 --- a/src/components/settings/coach-memory-section.tsx +++ b/src/components/settings/coach-memory-section.tsx @@ -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 (``). Settings → Coach always * shows this card; while that page is not reachable, Settings → AI shows it * through `` 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 /> + ); } diff --git a/src/components/settings/coach-reminders-section.tsx b/src/components/settings/coach-reminders-section.tsx index c4cde2c14..27b01e0f1 100644 --- a/src/components/settings/coach-reminders-section.tsx +++ b/src/components/settings/coach-reminders-section.tsx @@ -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 `` 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({ {t(`settings.ai.coachReminders.status.${r.status}`)}
- {isProposed && ( + {!readOnly && isProposed && (
); } diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index 20f692eb3..86fa35cea 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -1745,7 +1745,7 @@ export const coachReminderPaths: NonNullable = { 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 = { }, }, // 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 = { 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",