diff --git a/messages/de.json b/messages/de.json
index fc469ffe7..df0fae7c5 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4416,6 +4416,28 @@
"clearedToast": "{count} gespeicherte Angaben vergessen.",
"clearError": "Konnte die gespeicherten Angaben nicht löschen. Bitte erneut versuchen."
},
+ "coachReminders": {
+ "title": "Deine Erinnerungen",
+ "description": "Dinge, um die du den Coach gebeten hast, später darauf zurückzukommen. Fällige erscheinen hier und beim Coach.",
+ "empty": "Noch keine Erinnerungen. Bitte den Coach, dich an etwas zu erinnern — dann erscheint es hier.",
+ "loadError": "Konnte deine Erinnerungen nicht laden.",
+ "dueHeading": "Jetzt fällig",
+ "allHeading": "Gespeichert",
+ "duePrefix": "fällig {when}",
+ "confirm": "Behalten",
+ "markDone": "Erledigt",
+ "dismiss": "Verwerfen",
+ "deleteAria": "Diese Erinnerung löschen",
+ "note": "Erinnerungen werden verschlüsselt gespeichert. Lösche eine jederzeit — sie wird aus dem entfernt, was der Coach sehen kann.",
+ "status": {
+ "proposed": "Vorgeschlagen",
+ "active": "Gespeichert",
+ "due": "Fällig",
+ "surfaced": "Angezeigt",
+ "done": "Erledigt",
+ "dismissed": "Verworfen"
+ }
+ },
"aboutMe": {
"title": "Über mich",
"description": "Persönlicher Kontext, den Coach und Tagesbriefing zusätzlich zu deinen Daten lesen.",
@@ -7051,6 +7073,25 @@
"glucoseStructured": "Miss deine Glukose nach einem strukturierten Tagesplan"
}
},
+ "suggestedAction": {
+ "apply": "Hinzufügen",
+ "dismiss": "Jetzt nicht",
+ "applied": "Hinzugefügt",
+ "dismissed": "Verworfen",
+ "failed": "Konnte das nicht hinzufügen — bitte erneut versuchen",
+ "checkup": {
+ "title": "Vorsorge-Erinnerung hinzufügen?"
+ },
+ "note": {
+ "title": "Erinnerung speichern?"
+ },
+ "interval": {
+ "yearly": "Jedes Jahr",
+ "halfYearly": "Alle sechs Monate",
+ "quarterly": "Alle drei Monate",
+ "monthly": "Jeden Monat"
+ }
+ },
"settings": {
"reminderSuggestionsLabel": "Kadenz-Vorschläge",
"reminderSuggestionsDescription": "Lass den Coach gelegentlich eine evidenzbasierte Erinnerung anbieten, eine Metrik regelmäßiger zu messen."
diff --git a/messages/en.json b/messages/en.json
index c02b890d4..58bc1940e 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4416,6 +4416,28 @@
"clearedToast": "Forgot {count} stored facts.",
"clearError": "Could not clear the stored facts. Please try again."
},
+ "coachReminders": {
+ "title": "Your reminders",
+ "description": "Things you asked the Coach to bring back later. Due ones show up here and on the Coach.",
+ "empty": "No reminders yet. Ask the Coach to remind you about something and it will appear here.",
+ "loadError": "Could not load your reminders.",
+ "dueHeading": "Due now",
+ "allHeading": "Saved",
+ "duePrefix": "due {when}",
+ "confirm": "Keep",
+ "markDone": "Done",
+ "dismiss": "Dismiss",
+ "deleteAria": "Delete this reminder",
+ "note": "Reminders are stored encrypted. Delete one any time — it is removed from what the Coach can see.",
+ "status": {
+ "proposed": "Suggested",
+ "active": "Saved",
+ "due": "Due",
+ "surfaced": "Surfaced",
+ "done": "Done",
+ "dismissed": "Dismissed"
+ }
+ },
"aboutMe": {
"title": "About me",
"description": "Personal context the Coach and the daily briefing read alongside your data.",
@@ -7051,6 +7073,25 @@
"glucoseStructured": "Test your glucose on a structured daily schedule"
}
},
+ "suggestedAction": {
+ "apply": "Add",
+ "dismiss": "Not now",
+ "applied": "Added",
+ "dismissed": "Dismissed",
+ "failed": "Couldn't add that — please try again",
+ "checkup": {
+ "title": "Add a check-up reminder?"
+ },
+ "note": {
+ "title": "Save a reminder?"
+ },
+ "interval": {
+ "yearly": "Every year",
+ "halfYearly": "Every six months",
+ "quarterly": "Every three months",
+ "monthly": "Every month"
+ }
+ },
"settings": {
"reminderSuggestionsLabel": "Cadence suggestions",
"reminderSuggestionsDescription": "Let the Coach offer an occasional, evidence-based reminder to measure a metric more regularly."
diff --git a/src/components/insights/coach-panel/suggested-action-card.tsx b/src/components/insights/coach-panel/suggested-action-card.tsx
new file mode 100644
index 000000000..5ac6f57c9
--- /dev/null
+++ b/src/components/insights/coach-panel/suggested-action-card.tsx
@@ -0,0 +1,133 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { Check, Loader2, Sparkles } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import { useTranslations } from "@/lib/i18n/context";
+import { apiPost } from "@/lib/api/api-fetch";
+import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action";
+
+/**
+ * v1.22 (F6) — the generalised one-tap "confirm → apply" Coach action card.
+ *
+ * Rendered under an assistant bubble when the turn carried a `suggestedAction`
+ * (live from the SSE frame, or persisted on the message provenance). The card
+ * shows a localized heading per action type (`titleKey`) + the model's plain-text
+ * summary, and two actions:
+ *
+ * - "Add" → POSTs the validated params to `POST /api/coach/suggested-actions`,
+ * which builds the entity SERVER-side, field-by-field. NEVER auto-applied.
+ * - "Not now" → dismisses the card locally (no server write).
+ *
+ * RENDER dedup mirrors the cadence card: once settled, the card collapses to a
+ * confirmation so a stale stream + persisted twin can't double-prompt. The
+ * STATIC copy is i18n; the dynamic summary/label is the user's own content
+ * (plain text — no markdown, matching the XSS posture).
+ */
+export function SuggestedActionCard({
+ action,
+}: {
+ action: CoachSuggestedAction;
+}) {
+ const { t } = useTranslations();
+ const [settled, setSettled] = useState<"applied" | "dismissed" | null>(null);
+
+ const apply = useMutation({
+ mutationFn: async () => {
+ await apiPost("/api/coach/suggested-actions", action.params);
+ },
+ onSuccess: () => setSettled("applied"),
+ onError: () => toast.error(t("coach.suggestedAction.failed")),
+ });
+
+ if (settled) {
+ return (
+
+
+ {t(
+ settled === "applied"
+ ? "coach.suggestedAction.applied"
+ : "coach.suggestedAction.dismissed",
+ )}
+
+ );
+ }
+
+ const busy = apply.isPending;
+ const intervalLabel =
+ action.params.actionType === "checkup.create"
+ ? t(`coach.suggestedAction.interval.${action.params.interval}`)
+ : null;
+
+ return (
+
+
+
+
+
+
+
+ {t(action.titleKey)}
+
+
+ {action.summary}
+
+ {intervalLabel && (
+
+ {intervalLabel}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx
index e59d77802..461734302 100644
--- a/src/components/insights/layout-coach-fab.tsx
+++ b/src/components/insights/layout-coach-fab.tsx
@@ -115,8 +115,27 @@ export function LayoutCoachFab() {
},
});
+ // v1.22 (M4) — surface a due/surfaced Coach reminder on the FAB. Additive:
+ // the dot also lights when the daily sweep has flipped a "remind me" reminder
+ // to due, independent of the nudge seen-stamp (resolving the reminder in the
+ // ledger clears it). Same cheap, gated, stale-while-revalidate read shape.
+ const { data: dueReminders } = useQuery({
+ queryKey: queryKeys.coachReminders("due,surfaced"),
+ queryFn: async () => {
+ const data = await apiGet<{ reminders?: unknown[] } | undefined>(
+ "/api/coach/reminders?status=due,surfaced",
+ );
+ return data?.reminders ?? [];
+ },
+ enabled: coachAvailable && !onCoachPage,
+ staleTime: 5 * 60 * 1000,
+ });
+ const hasDueReminders = (dueReminders?.length ?? 0) > 0;
+
const nudgedAt = status?.nudgedAt ?? null;
const unread = isNudgeUnread(status, seenStamp);
+ // The visual dot lights for either an unread nudge OR a due reminder.
+ const showDot = unread || hasDueReminders;
// The unread dot is visual-only (`aria-hidden`) and the swapped
// `aria-label` is not announced on mutation — a screen-reader user
@@ -194,7 +213,7 @@ export function LayoutCoachFab() {
type="button"
size="icon"
data-slot="coach-fab"
- data-unread={unread ? "true" : undefined}
+ data-unread={showDot ? "true" : undefined}
onClick={handleOpen}
aria-label={accessibleLabel}
title={accessibleLabel}
@@ -239,7 +258,7 @@ export function LayoutCoachFab() {
)}
>
- {unread ? (
+ {showDot ? (
// v1.18.6 (CCH-03) — discreet "the Coach said something" dot.
// Deliberately NOT an alarming red (the medication-card rule:
// status via calm signal, never an alarm tint): a small cyan
diff --git a/src/components/settings/coach-reminders-section.tsx b/src/components/settings/coach-reminders-section.tsx
new file mode 100644
index 000000000..b57eb612d
--- /dev/null
+++ b/src/components/settings/coach-reminders-section.tsx
@@ -0,0 +1,212 @@
+"use client";
+
+/**
+ * v1.22 (B6) — Settings → AI "Your context: reminders" panel.
+ *
+ * The user-facing ledger for the durable "remind me about X" memory the Coach
+ * captures inline (the `---REMEMBER---` sentinel) and the sweep resurfaces. The
+ * due / surfaced reminders are highlighted at the top (the in-app surface of the
+ * sweep output); the rest are listed with view + lifecycle controls so the user
+ * stays in control of their stored memory:
+ *
+ * GET /api/coach/reminders → { data: { reminders: [...] } }
+ * PATCH /api/coach/reminders/{id} {status} → confirm / done / dismiss
+ * 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.
+ */
+import { useMemo } from "react";
+import { BellRing, Check, Loader2, Trash2, X } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { formatDateOrRelative } from "@/lib/format";
+import { SettingsCard } from "@/components/settings/settings-card";
+import { SettingsCardHeader } from "@/components/settings/_card-header";
+import { useTranslations } from "@/lib/i18n/context";
+import {
+ useCoachReminders,
+ useCoachReminderMutations,
+ type CoachReminderDTO,
+} from "@/hooks/use-coach-reminders";
+
+const DUE_STATUSES = new Set(["due", "surfaced"]);
+
+export function CoachRemindersSection({
+ isAuthenticated,
+}: {
+ isAuthenticated: boolean;
+}) {
+ const { t } = useTranslations();
+ const query = useCoachReminders({ enabled: isAuthenticated });
+ const { setStatus, remove } = useCoachReminderMutations();
+
+ const reminders = useMemo(() => query.data ?? [], [query.data]);
+ const { due, rest } = useMemo(() => {
+ const due: CoachReminderDTO[] = [];
+ const rest: CoachReminderDTO[] = [];
+ for (const r of reminders) {
+ (DUE_STATUSES.has(r.status) ? due : rest).push(r);
+ }
+ return { due, rest };
+ }, [reminders]);
+
+ const pendingId =
+ (setStatus.isPending && setStatus.variables?.id) ||
+ (remove.isPending && remove.variables) ||
+ null;
+
+ const row = (r: CoachReminderDTO, highlighted: boolean) => {
+ const busy = pendingId === r.id;
+ const isProposed = r.status === "proposed";
+ return (
+
+ {r.note}
+
+ {r.metric && {r.metric}}
+ {r.dueAt && (
+
+ {t("settings.ai.coachReminders.duePrefix", {
+ when: formatDateOrRelative(r.dueAt, t),
+ })}
+
+ )}
+ {t(`settings.ai.coachReminders.status.${r.status}`)}
+
+
+ {isProposed && (
+
+ )}
+ {r.status !== "done" && (
+
+ )}
+ {(r.status === "due" || r.status === "surfaced") && (
+
+ )}
+
+
+
+ );
+ };
+
+ return (
+
+
+
+ {query.isError && (
+
+ {t("settings.ai.coachReminders.loadError")}
+
+ )}
+
+ {!query.isError && reminders.length === 0 ? (
+
+ {t("settings.ai.coachReminders.empty")}
+
+ ) : (
+
+ {due.length > 0 && (
+
+
+ {t("settings.ai.coachReminders.dueHeading")}
+
+
{due.map((r) => row(r, true))}
+
+ )}
+ {rest.length > 0 && (
+
+
+ {t("settings.ai.coachReminders.allHeading")}
+
+
{rest.map((r) => row(r, false))}
+
+ )}
+
+ )}
+
+
+ {t("settings.ai.coachReminders.note")}
+
+
+ );
+}
diff --git a/src/components/settings/coach-section.tsx b/src/components/settings/coach-section.tsx
index 7bdc78e9e..fca4b52fb 100644
--- a/src/components/settings/coach-section.tsx
+++ b/src/components/settings/coach-section.tsx
@@ -29,6 +29,7 @@
import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card";
import { CoachMemorySection } from "@/components/settings/coach-memory-section";
+import { CoachRemindersSection } from "@/components/settings/coach-reminders-section";
import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
import { CoachPrefsSection } from "@/components/settings/coach-prefs-section";
import { useAuth } from "@/hooks/use-auth";
@@ -62,6 +63,8 @@ export function CoachSection() {
)}
{coachEnabled && }
+
+ {coachEnabled && }
);
}
diff --git a/src/hooks/use-coach-reminders.ts b/src/hooks/use-coach-reminders.ts
new file mode 100644
index 000000000..1f4b7db25
--- /dev/null
+++ b/src/hooks/use-coach-reminders.ts
@@ -0,0 +1,78 @@
+"use client";
+
+/**
+ * v1.22 (B2/B6) — read + mutate the user's Coach episodic reminders.
+ *
+ * One shared hook for the in-app reminder tile/indicator and the "Your context"
+ * ledger. Reads unwrap `(await res.json()).data` per the envelope convention;
+ * every key routes through `queryKeys.coachReminders(status)` so a lifecycle
+ * mutation invalidates the matching list. The `status` arg keys the tile read
+ * (`due,surfaced`) separately from the full ledger so they never share a slot.
+ */
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import { queryKeys } from "@/lib/query-keys";
+import { apiDelete, apiGet, apiPatch } from "@/lib/api/api-fetch";
+
+export interface CoachReminderDTO {
+ id: string;
+ note: string;
+ metric: string | null;
+ triggerKind: string;
+ dueAt: string | null;
+ contextCue: string | null;
+ status: string;
+ source: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+async function fetchReminders(status?: string): Promise {
+ const qs = status ? `?status=${encodeURIComponent(status)}` : "";
+ const data = await apiGet<{ reminders?: CoachReminderDTO[] } | undefined>(
+ `/api/coach/reminders${qs}`,
+ );
+ return data?.reminders ?? [];
+}
+
+/** List the caller's reminders, optionally filtered to a status set. */
+export function useCoachReminders(opts?: {
+ status?: string;
+ enabled?: boolean;
+}) {
+ const status = opts?.status;
+ return useQuery({
+ queryKey: queryKeys.coachReminders(status),
+ queryFn: () => fetchReminders(status),
+ enabled: opts?.enabled ?? true,
+ });
+}
+
+/** Lifecycle + delete mutations, invalidating every reminders list slot. */
+export function useCoachReminderMutations() {
+ const queryClient = useQueryClient();
+ const invalidate = () =>
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.coachRemindersAll(),
+ });
+
+ const setStatus = useMutation({
+ mutationFn: async (args: { id: string; status: string }) => {
+ await apiPatch(`/api/coach/reminders/${encodeURIComponent(args.id)}`, {
+ status: args.status,
+ });
+ return args.id;
+ },
+ onSuccess: invalidate,
+ });
+
+ const remove = useMutation({
+ mutationFn: async (id: string) => {
+ await apiDelete(`/api/coach/reminders/${encodeURIComponent(id)}`);
+ return id;
+ },
+ onSuccess: invalidate,
+ });
+
+ return { setStatus, remove };
+}
diff --git a/src/lib/query-keys/coach.ts b/src/lib/query-keys/coach.ts
index 90e2652ae..7224d6161 100644
--- a/src/lib/query-keys/coach.ts
+++ b/src/lib/query-keys/coach.ts
@@ -32,4 +32,14 @@ export const coachKeys = {
* (`GET /api/insights/coach/seeded-question`).
*/
coachSeededQuestion: () => ["coach-seeded-question"] as const,
+ /**
+ * v1.22 (B2/B6) — the user's Coach episodic reminders (`GET /api/coach/
+ * reminders`). The optional `status` arg keys the in-app tile read
+ * (`due,surfaced`) separately from the full ledger list so the two never
+ * share a cache slot.
+ */
+ coachReminders: (status?: string) =>
+ ["coach-reminders", status ?? "all"] as const,
+ /** Prefix key — invalidates every `coachReminders(status)` slot at once. */
+ coachRemindersAll: () => ["coach-reminders"] as const,
};