Merge branch 'feat/coach-reminders-readable' into release/v1.39.0

This commit is contained in:
Marc-André Bombeck committed 2026-09-24 11:58:46 +02:00
commit 4ae231d757
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",