feat(coach): surface reminders in-app and add the action card

Add a "Your reminders" panel under Settings → Coach that lists the stored
reminders, highlights the due ones, and gives the user view, confirm,
done/dismiss, and delete control over their own memory. Light the Coach
launcher dot when a reminder is due, alongside the existing nudge signal.

Add the generic confirm-card component for the suggested-action frame: a
plain-text preview with a localized per-action heading and a single confirm
tap that posts the validated params to the build endpoint.

Translate the new copy in English and German.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 13:38:25 +02:00
1 parent 6f407388b4
commit 5331528076
8 files changed
+539 -2

No files matched your search

+41
View File
@@ -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."
+41
View File
@@ -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."
@@ -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 (
<p
role="status"
data-slot="coach-suggested-action-done"
className="text-muted-foreground flex items-center gap-1.5 text-xs"
>
<Check className="text-dracula-green size-3.5" aria-hidden="true" />
{t(
settled === "applied"
? "coach.suggestedAction.applied"
: "coach.suggestedAction.dismissed",
)}
</p>
);
}
const busy = apply.isPending;
const intervalLabel =
action.params.actionType === "checkup.create"
? t(`coach.suggestedAction.interval.${action.params.interval}`)
: null;
return (
<div
data-slot="coach-suggested-action-card"
className={cn(
"border-border/60 bg-muted/40 flex flex-col gap-2.5 rounded-xl border",
"px-3.5 py-3 text-sm",
)}
>
<div className="flex items-start gap-2">
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-cyan mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
<Sparkles className="text-background size-3.5" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-muted-foreground text-xs font-medium">
{t(action.titleKey)}
</span>
<span className="text-foreground leading-relaxed break-words">
{action.summary}
</span>
{intervalLabel && (
<span className="text-muted-foreground text-xs">
{intervalLabel}
</span>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
data-slot="coach-suggested-action-apply"
onClick={() => apply.mutate()}
disabled={busy}
className={cn(
"bg-dracula-purple/90 text-background hover:bg-dracula-purple",
"focus-visible:ring-ring/50 inline-flex min-h-9 items-center gap-1.5",
"rounded-md px-3 py-1.5 text-xs font-medium outline-none",
"focus-visible:ring-2 disabled:opacity-50",
)}
>
{busy ? (
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<Check className="size-3.5" aria-hidden="true" />
)}
{t("coach.suggestedAction.apply")}
</button>
<button
type="button"
data-slot="coach-suggested-action-dismiss"
onClick={() => setSettled("dismissed")}
disabled={busy}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-9 items-center rounded-md px-2.5 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
>
{t("coach.suggestedAction.dismiss")}
</button>
</div>
</div>
);
}
+21 -2
View File
@@ -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() {
)}
>
<Sparkles className="text-background size-6" aria-hidden="true" />
{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
@@ -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 (
<li
key={r.id}
data-testid="settings-coach-reminder"
data-status={r.status}
className={
highlighted
? "border-dracula-purple/40 bg-dracula-purple/5 flex flex-col gap-2 rounded-lg border p-3"
: "border-border bg-background flex flex-col gap-2 rounded-lg border p-3"
}
>
<p className="text-sm break-words">{r.note}</p>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{r.metric && <span className="uppercase">{r.metric}</span>}
{r.dueAt && (
<span>
{t("settings.ai.coachReminders.duePrefix", {
when: formatDateOrRelative(r.dueAt, t),
})}
</span>
)}
<span>{t(`settings.ai.coachReminders.status.${r.status}`)}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{isProposed && (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-9"
disabled={!isAuthenticated || busy}
data-testid="settings-coach-reminder-confirm"
onClick={() => setStatus.mutate({ id: r.id, status: "active" })}
>
<Check className="size-3.5" aria-hidden />
{t("settings.ai.coachReminders.confirm")}
</Button>
)}
{r.status !== "done" && (
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-9"
disabled={!isAuthenticated || busy}
data-testid="settings-coach-reminder-done"
onClick={() => setStatus.mutate({ id: r.id, status: "done" })}
>
<Check className="size-3.5" aria-hidden />
{t("settings.ai.coachReminders.markDone")}
</Button>
)}
{(r.status === "due" || r.status === "surfaced") && (
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-9"
disabled={!isAuthenticated || busy}
data-testid="settings-coach-reminder-dismiss"
onClick={() =>
setStatus.mutate({ id: r.id, status: "dismissed" })
}
>
<X className="size-3.5" aria-hidden />
{t("settings.ai.coachReminders.dismiss")}
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive min-h-9"
disabled={!isAuthenticated || busy}
data-testid="settings-coach-reminder-delete"
aria-label={t("settings.ai.coachReminders.deleteAria")}
onClick={() => remove.mutate(r.id)}
>
{busy ? (
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Trash2 className="size-3.5" aria-hidden />
)}
</Button>
</div>
</li>
);
};
return (
<SettingsCard
as="section"
aria-labelledby="settings-ai-coach-reminders-title"
data-testid="settings-coach-reminders-card"
className="space-y-4"
>
<SettingsCardHeader
icon={BellRing}
titleId="settings-ai-coach-reminders-title"
title={t("settings.ai.coachReminders.title")}
description={t("settings.ai.coachReminders.description")}
/>
{query.isError && (
<p
role="status"
aria-live="polite"
className="text-destructive text-sm"
>
{t("settings.ai.coachReminders.loadError")}
</p>
)}
{!query.isError && reminders.length === 0 ? (
<p
data-testid="settings-coach-reminders-empty"
className="text-muted-foreground text-sm"
>
{t("settings.ai.coachReminders.empty")}
</p>
) : (
<div className="space-y-5">
{due.length > 0 && (
<div className="space-y-2">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.ai.coachReminders.dueHeading")}
</h3>
<ul className="space-y-2">{due.map((r) => row(r, true))}</ul>
</div>
)}
{rest.length > 0 && (
<div className="space-y-2">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.ai.coachReminders.allHeading")}
</h3>
<ul className="space-y-2">{rest.map((r) => row(r, false))}</ul>
</div>
)}
</div>
)}
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
{t("settings.ai.coachReminders.note")}
</p>
</SettingsCard>
);
}
@@ -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 && <CoachMemorySection isAuthenticated={authed} />}
{coachEnabled && <CoachRemindersSection isAuthenticated={authed} />}
</div>
);
}
+78
View File
@@ -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<CoachReminderDTO[]> {
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 };
}
+10
View File
@@ -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,
};