From 5331528076e0c3ad3c52b425a1ce77fdab00a120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 13:38:25 +0200 Subject: [PATCH] feat(coach): surface reminders in-app and add the action card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- messages/de.json | 41 ++++ messages/en.json | 41 ++++ .../coach-panel/suggested-action-card.tsx | 133 +++++++++++ src/components/insights/layout-coach-fab.tsx | 23 +- .../settings/coach-reminders-section.tsx | 212 ++++++++++++++++++ src/components/settings/coach-section.tsx | 3 + src/hooks/use-coach-reminders.ts | 78 +++++++ src/lib/query-keys/coach.ts | 10 + 8 files changed, 539 insertions(+), 2 deletions(-) create mode 100644 src/components/insights/coach-panel/suggested-action-card.tsx create mode 100644 src/components/settings/coach-reminders-section.tsx create mode 100644 src/hooks/use-coach-reminders.ts 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 ( +

+

+ ); + } + + 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() { )} >