mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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">
|
||||
|
||||
Reference in new issue
Block a user