feat(coach): evidence-based cadence suggestions with one-tap in-chat reminder

The Coach can append a ---SUGGEST-REMINDER--- sentinel to propose a
measurement cadence (weight daily, the ESH/AHA 7-2-2 home-BP protocol,
structured glucose). The server resolves the cadence from a closed catalog
and emits an additive 'suggestion' SSE frame the chat renders as a one-tap
action card; accepting it creates a MeasurementReminder with origin COACH
through the same engine the Vorsorge surface uses (server-authoritative
schedule, finite course window self-expires via endsOn).

Non-naggy by construction: a frequency-cap cooldown, dismissal memory, a
master opt-out under Coach settings, and an explicit 'I measure enough'
stop path, all in the coach-prefs blob. Triple dedup at suggest, render,
and create so a cadence is never double-suggested or double-created, and
suggestions for a disabled module never surface. RHR/HRV stay passive --
the Coach surfaces the baseline, it never nudges to 'go measure' them.

The reminder DTO now echoes origin and endsOn for the iOS client.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 21:17:51 +02:00
1 parent b6796eee96
commit 12f4bc24e8
29 files changed
+1538 -9

No files matched your search

@@ -146,6 +146,7 @@ describe("<MessageThread>", () => {
streaming={{
content: "Looking at your data ",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
@@ -170,6 +171,7 @@ describe("<MessageThread>", () => {
streaming={{
content: "Drafting…",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
@@ -187,6 +189,7 @@ describe("<MessageThread>", () => {
streaming={{
content: "",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
@@ -203,6 +206,7 @@ describe("<MessageThread>", () => {
streaming={{
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: "errorProvider",
@@ -243,6 +247,7 @@ describe("<MessageThread>", () => {
streaming={{
content: "Looking at your data ",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: "m3-streaming",
errorCode: null,
@@ -269,6 +274,7 @@ describe("<MessageThread>", () => {
streaming={{
content: "Looking at your data ",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
@@ -467,6 +473,7 @@ describe("<MessageThread>", () => {
streaming={{
content: "",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
@@ -570,6 +577,7 @@ describe("<MessageThread>", () => {
streaming={{
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: "coach.budget.exceeded",
@@ -590,6 +598,7 @@ describe("<MessageThread>", () => {
streaming={{
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: "coach.provider.rate_limited",
@@ -618,6 +627,7 @@ describe("<MessageThread>", () => {
streaming={{
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: null,
@@ -24,6 +24,7 @@ import { useAuth } from "@/hooks/use-auth";
import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me";
import { SourceChips } from "./source-chips";
import { ReminderSuggestionCard } from "./reminder-suggestion-card";
import type {
CoachConversationDetailDTO,
CoachOptimisticUserMessage,
@@ -406,6 +407,7 @@ export function MessageThread({
role="assistant"
content={streaming.content}
metricSource={streaming.metricSource}
suggestion={streaming.suggestion}
providerType={streaming.inProgress ? "streaming" : null}
inProgress={streaming.inProgress}
errorCode={streaming.errorCode}
@@ -425,6 +427,12 @@ interface ChatBubbleProps {
role: "user" | "assistant";
content: string;
metricSource?: import("@/lib/ai/coach/types").CoachProvenance | null;
/**
* v1.18.1 (Workstream C) — live cadence suggestion from the streaming
* hook. Persisted messages carry it on `metricSource.suggestion`
* instead; the bubble falls back to that so the card survives reload.
*/
suggestion?: import("@/lib/ai/coach/types").CoachSuggestion | null;
providerType?: string | null;
inProgress?: boolean;
errorCode?: string | null;
@@ -440,6 +448,7 @@ function ChatBubble({
role,
content,
metricSource,
suggestion,
providerType,
inProgress,
errorCode,
@@ -699,6 +708,16 @@ function ChatBubble({
</div>
</details>
)}
{/* v1.18.1 (Workstream C) — one-tap cadence-suggestion action
card. Live from the streaming hook, or restored from the
persisted message provenance on reload. Not shown on
in-flight or errored turns. */}
{!inProgress &&
!errorCode &&
(() => {
const sug = suggestion ?? metricSource?.suggestion ?? null;
return sug ? <ReminderSuggestionCard suggestion={sug} /> : null;
})()}
{/* v1.4.23 H7 — per-message thumbs feedback. Only persisted
assistant messages get the row (skipped for refusals,
errors, in-flight stream bubbles). The aggregator buckets
@@ -0,0 +1,151 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { BellPlus, Check, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { apiPost } from "@/lib/api/api-fetch";
import type { CoachSuggestion } from "@/lib/ai/coach/types";
/**
* v1.18.1 (Workstream C) — the one-tap cadence-suggestion action card.
*
* Rendered under an assistant bubble when the turn carried a `suggestion`
* (live from the SSE frame, or persisted on the message provenance). Three
* actions, all routed through `POST /api/coach/reminder-suggestions`:
*
* - "Set reminder" → creates a `MeasurementReminder` (origin: COACH).
* - "Not now" → dismissal memory (never re-suggest this cadence).
* - "I measure enough" → the explicit stop path (no more suggestions).
*
* RENDER dedup (the middle leg of the triple dedup): once the user settles
* the card it collapses to a confirmation and never re-renders an action,
* so a stale stream + persisted twin can't double-prompt.
*/
export function ReminderSuggestionCard({
suggestion,
}: {
suggestion: CoachSuggestion;
}) {
const { t } = useTranslations();
const [settled, setSettled] = useState<
"accepted" | "dismissed" | "stopped" | null
>(null);
const act = useMutation({
mutationFn: async (action: "accept" | "dismiss" | "stop") => {
await apiPost("/api/coach/reminder-suggestions", {
cadenceId: suggestion.cadenceId,
action,
});
return action;
},
onSuccess: (action) => {
setSettled(
action === "accept"
? "accepted"
: action === "stop"
? "stopped"
: "dismissed",
);
},
onError: () => {
toast.error(t("coach.reminderSuggestion.failed"));
},
});
if (settled) {
return (
<p
role="status"
data-slot="coach-reminder-suggestion-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 === "accepted"
? "coach.reminderSuggestion.accepted"
: settled === "stopped"
? "coach.reminderSuggestion.stopped"
: "coach.reminderSuggestion.dismissed",
)}
</p>
);
}
const busy = act.isPending;
return (
<div
data-slot="coach-reminder-suggestion-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-pink mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
<BellPlus 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("coach.reminderSuggestion.title")}
</span>
{/* The label is a localised cadence string (the i18n key the
server hands back in `suggestion.label`). */}
<span className="text-foreground leading-relaxed">
{t(suggestion.label)}
</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
data-slot="coach-reminder-suggestion-accept"
onClick={() => act.mutate("accept")}
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"
/>
) : (
<BellPlus className="size-3.5" aria-hidden="true" />
)}
{t("coach.reminderSuggestion.accept")}
</button>
<button
type="button"
data-slot="coach-reminder-suggestion-dismiss"
onClick={() => act.mutate("dismiss")}
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.reminderSuggestion.dismiss")}
</button>
<button
type="button"
data-slot="coach-reminder-suggestion-stop"
onClick={() => act.mutate("stop")}
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.reminderSuggestion.stop")}
</button>
</div>
</div>
);
}
@@ -10,6 +10,7 @@ import type {
CoachProvenance,
CoachScope,
CoachStreamEvent,
CoachSuggestion,
} from "@/lib/ai/coach/types";
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -178,6 +179,11 @@ export interface CoachStreamingMessage {
content: string;
/** Provenance frame — populated once the server emits it. */
metricSource: CoachProvenance | null;
/**
* v1.18.1 (Workstream C) — cadence suggestion from the additive
* `suggestion` frame, if the turn carried one. Null otherwise.
*/
suggestion: CoachSuggestion | null;
/** True until the `done` frame closes the stream. */
inProgress: boolean;
/** Final messageId once `done` lands; null otherwise. */
@@ -220,6 +226,7 @@ export interface CoachOptimisticUserMessage {
const EMPTY_STREAMING: CoachStreamingMessage = {
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: null,
@@ -314,6 +321,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
setStreaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: true,
messageId: null,
errorCode: null,
@@ -326,6 +334,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
setStreaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: "coach.network",
@@ -358,6 +367,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
setStreaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: "coach.network",
@@ -369,6 +379,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
setStreaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: `coach.http.${response.status}`,
@@ -405,6 +416,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
setStreaming({
content: "",
metricSource: null,
suggestion: null,
inProgress: false,
messageId: null,
errorCode: structured ?? `coach.http.${response.status}`,
@@ -423,6 +435,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
let resolvedConversationId: string | null = null;
let collectedContent = "";
let collectedProvenance: CoachProvenance | null = null;
let collectedSuggestion: CoachSuggestion | null = null;
let lastError: string | null = null;
let messageId: string | null = null;
@@ -449,6 +462,13 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
metricSource: evt.metricSource,
}));
break;
case "suggestion":
collectedSuggestion = evt.suggestion;
setStreaming((prev) => ({
...prev,
suggestion: evt.suggestion,
}));
break;
case "done":
resolvedConversationId = evt.conversationId;
messageId = evt.messageId;
@@ -478,6 +498,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
setStreaming({
content: collectedContent,
metricSource: collectedProvenance,
suggestion: collectedSuggestion,
inProgress: false,
messageId,
errorCode: lastError,
@@ -529,4 +550,5 @@ export type {
CoachConversationDTO,
CoachConversationDetailDTO,
CoachProvenance,
CoachSuggestion,
};
@@ -296,6 +296,42 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
})}
</ul>
</div>
{/* v1.18.1 (Workstream C) — cadence-suggestion opt-out. The
Coach offers an occasional, evidence-based reminder to
measure a metric more regularly; this is the master switch
for that surface. Off ⇒ no suggestion cards ever surface. */}
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-1">
<Label
htmlFor="coach-prefs-reminder-suggestions"
className="cursor-pointer text-xs font-medium"
>
{t("coach.settings.reminderSuggestionsLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("coach.settings.reminderSuggestionsDescription")}
</p>
</div>
<Switch
id="coach-prefs-reminder-suggestions"
data-slot="coach-prefs-reminder-suggestions"
checked={draft.reminderSuggestions?.enabled ?? true}
onCheckedChange={(next) =>
setDraft((prev) => ({
...prev,
reminderSuggestions: {
enabled: next,
stopped: prev.reminderSuggestions?.stopped ?? false,
dismissedCadences:
prev.reminderSuggestions?.dismissedCadences ?? [],
lastSuggestedAt:
prev.reminderSuggestions?.lastSuggestedAt ?? null,
},
}))
}
/>
</div>
</div>
)}
<div className="mt-4 flex justify-end pl-7">