mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
6f407388b4
commit
5331528076
8 files changed
+539
-2
No files matched your search
@@ -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."
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user