mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
feat(coach,admin): finish the self-context and invite surfaces end to end
Invites move into their own admin section with a real ledger: status badges, expiry, uses, a redemption history of who joined when (new redemption table, revocation is now soft so history survives), and an honest one-time link/QR moment. The assistant self-context grows structured fields for conditions, allergies and focus — encrypted like the free text — merges age and gender from the profile into one fenced prompt block, and asks up to three targeted follow-up questions that surface as tappable chips above the coach composer. A fourth nudge trigger gently suggests a check-up when the self-context is stale and the coach is actually in use.
This commit is contained in:
1 parent
9fe5673251
commit
5b75184649
32 files changed
+2648
-423
No files matched your search
@@ -1,37 +1,54 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.15.20 — Settings → AI "About me" panel.
|
||||
* v1.15.20 — Settings → AI "About me" panel (free text only).
|
||||
* v1.16.0 — structured self-context: alongside the free text the user
|
||||
* answers three short questions (chronic conditions, allergies /
|
||||
* intolerances, what the Coach should watch). All four fields are
|
||||
* encrypted at rest; age/gender stay on the profile and are merged
|
||||
* into the prompt server-side — the panel says so instead of asking
|
||||
* again.
|
||||
*
|
||||
* Free-text self-description the user writes for the AI surfaces. The
|
||||
* server stores it encrypted (`UserHealthProfile.aboutMeEncrypted`) and
|
||||
* injects it into the Coach system prompt + the daily briefing as a
|
||||
* delimited, user-provided context block.
|
||||
* GET /api/coach/about-me — full structured payload + pending questions
|
||||
* PUT /api/coach/about-me — writes all four fields (empty string clears)
|
||||
*
|
||||
* GET /api/coach/about-me — { aboutMe, updatedAt, maxChars }
|
||||
* PUT /api/coach/about-me — { aboutMe } (empty string clears)
|
||||
* After a save the server derives up to 3 clarifying questions (AI
|
||||
* when a provider + budget allow, deterministic hints otherwise); the
|
||||
* panel surfaces them in a quiet callout and the Coach composer
|
||||
* renders them as tappable chips.
|
||||
*
|
||||
* Plain text only — the value is rendered exclusively through a
|
||||
* `<textarea>` and React text children; no markdown anywhere.
|
||||
* Plain text only — every value renders exclusively through inputs and
|
||||
* React text children; no markdown anywhere.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, NotebookPen, Trash2 } from "lucide-react";
|
||||
import { Loader2, MessageCircleQuestion, NotebookPen, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const FALLBACK_MAX_CHARS = 4000;
|
||||
const FALLBACK_FIELD_MAX_CHARS = 500;
|
||||
|
||||
interface AboutMeData {
|
||||
aboutMe: string | null;
|
||||
conditions: string | null;
|
||||
allergies: string | null;
|
||||
coachFocus: string | null;
|
||||
pendingQuestions: string[];
|
||||
updatedAt: string | null;
|
||||
maxChars: number;
|
||||
fieldMaxChars: number;
|
||||
}
|
||||
|
||||
type StructuredKey = "conditions" | "allergies" | "coachFocus";
|
||||
|
||||
async function fetchAboutMe(): Promise<AboutMeData> {
|
||||
const res = await fetch("/api/coach/about-me");
|
||||
if (!res.ok) {
|
||||
@@ -40,6 +57,9 @@ async function fetchAboutMe(): Promise<AboutMeData> {
|
||||
return (await res.json()).data as AboutMeData;
|
||||
}
|
||||
|
||||
const FIELD_CLASSES =
|
||||
"border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-1 focus-visible:outline-none";
|
||||
|
||||
export function AboutMeSection({
|
||||
isAuthenticated,
|
||||
}: {
|
||||
@@ -47,7 +67,12 @@ export function AboutMeSection({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<{
|
||||
aboutMe: string | null;
|
||||
conditions: string | null;
|
||||
allergies: string | null;
|
||||
coachFocus: string | null;
|
||||
}>({ aboutMe: null, conditions: null, allergies: null, coachFocus: null });
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.coachAboutMe(),
|
||||
@@ -55,64 +80,189 @@ export function AboutMeSection({
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const saved = query.data?.aboutMe ?? "";
|
||||
const saved = {
|
||||
aboutMe: query.data?.aboutMe ?? "",
|
||||
conditions: query.data?.conditions ?? "",
|
||||
allergies: query.data?.allergies ?? "",
|
||||
coachFocus: query.data?.coachFocus ?? "",
|
||||
};
|
||||
const maxChars = query.data?.maxChars ?? FALLBACK_MAX_CHARS;
|
||||
const value = draft ?? saved;
|
||||
// A draft equal to the server value renders identically and keeps the
|
||||
// save button disabled, so no effect is needed to clear it — the
|
||||
// save mutation's onSuccess resets the draft after a round-trip.
|
||||
const dirty = draft !== null && draft !== saved;
|
||||
const fieldMaxChars = query.data?.fieldMaxChars ?? FALLBACK_FIELD_MAX_CHARS;
|
||||
const pendingQuestions = query.data?.pendingQuestions ?? [];
|
||||
|
||||
const value = {
|
||||
aboutMe: drafts.aboutMe ?? saved.aboutMe,
|
||||
conditions: drafts.conditions ?? saved.conditions,
|
||||
allergies: drafts.allergies ?? saved.allergies,
|
||||
coachFocus: drafts.coachFocus ?? saved.coachFocus,
|
||||
};
|
||||
const dirty =
|
||||
value.aboutMe !== saved.aboutMe ||
|
||||
value.conditions !== saved.conditions ||
|
||||
value.allergies !== saved.allergies ||
|
||||
value.coachFocus !== saved.coachFocus;
|
||||
const hasAnyContent =
|
||||
saved.aboutMe.length > 0 ||
|
||||
saved.conditions.length > 0 ||
|
||||
saved.allergies.length > 0 ||
|
||||
saved.coachFocus.length > 0;
|
||||
|
||||
// Quiet completeness meter: one segment per answered field. Reads
|
||||
// the SAVED state (not the draft) so it only moves on persistence.
|
||||
const answered = [
|
||||
saved.conditions,
|
||||
saved.allergies,
|
||||
saved.coachFocus,
|
||||
saved.aboutMe,
|
||||
].filter((v) => v.length > 0).length;
|
||||
|
||||
const save = useMutation({
|
||||
mutationKey: queryKeys.coachAboutMe(),
|
||||
mutationFn: async (aboutMe: string) => {
|
||||
mutationFn: async (input: {
|
||||
aboutMe: string;
|
||||
conditions: string;
|
||||
allergies: string;
|
||||
coachFocus: string;
|
||||
}) => {
|
||||
const res = await fetch("/api/coach/about-me", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ aboutMe }),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
return ((await res.json()).data as AboutMeData).aboutMe ?? "";
|
||||
return (await res.json()).data as AboutMeData;
|
||||
},
|
||||
onSuccess: (nextSaved) => {
|
||||
onSuccess: (next) => {
|
||||
const cleared =
|
||||
!next.aboutMe &&
|
||||
!next.conditions &&
|
||||
!next.allergies &&
|
||||
!next.coachFocus;
|
||||
toast.success(
|
||||
nextSaved.length > 0
|
||||
? t("settings.ai.aboutMe.savedToast")
|
||||
: t("settings.ai.aboutMe.clearedToast"),
|
||||
cleared
|
||||
? t("settings.ai.aboutMe.clearedToast")
|
||||
: t("settings.ai.aboutMe.savedToast"),
|
||||
);
|
||||
setDraft(null);
|
||||
setDrafts({
|
||||
aboutMe: null,
|
||||
conditions: null,
|
||||
allergies: null,
|
||||
coachFocus: null,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.coachAboutMeQuestions(),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("settings.ai.aboutMe.saveError"));
|
||||
},
|
||||
});
|
||||
|
||||
const disabled = !isAuthenticated || query.isLoading || save.isPending;
|
||||
|
||||
function structuredField(
|
||||
key: StructuredKey,
|
||||
label: string,
|
||||
placeholder: string,
|
||||
) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`settings-about-me-${key}`}>{label}</Label>
|
||||
<textarea
|
||||
id={`settings-about-me-${key}`}
|
||||
data-testid={`settings-about-me-${key}`}
|
||||
className={cn(FIELD_CLASSES, "min-h-16 resize-y")}
|
||||
rows={2}
|
||||
value={value[key]}
|
||||
maxLength={fieldMaxChars}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
setDrafts((d) => ({ ...d, [key]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-ai-about-me-title"
|
||||
data-testid="settings-about-me-card"
|
||||
className="bg-card border-border space-y-4 rounded-xl border p-6"
|
||||
className="bg-card border-border space-y-5 rounded-xl border p-6"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={NotebookPen}
|
||||
title={t("settings.ai.aboutMe.title")}
|
||||
titleId="settings-ai-about-me-title"
|
||||
description={t("settings.ai.aboutMe.description")}
|
||||
/>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<SettingsCardHeader
|
||||
icon={NotebookPen}
|
||||
title={t("settings.ai.aboutMe.title")}
|
||||
titleId="settings-ai-about-me-title"
|
||||
description={t("settings.ai.aboutMe.description")}
|
||||
/>
|
||||
{/* Completeness — four quiet segments, no percentage shouting. */}
|
||||
<div
|
||||
className="flex items-center gap-1.5"
|
||||
aria-label={t("settings.ai.aboutMe.completeness", {
|
||||
answered,
|
||||
total: 4,
|
||||
})}
|
||||
title={t("settings.ai.aboutMe.completeness", {
|
||||
answered,
|
||||
total: 4,
|
||||
})}
|
||||
>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"h-1 w-5 rounded-full transition-colors",
|
||||
i < answered ? "bg-dracula-purple" : "bg-border",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
data-testid="settings-about-me-textarea"
|
||||
className="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring min-h-36 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-1 focus-visible:outline-none"
|
||||
value={value}
|
||||
maxLength={maxChars}
|
||||
placeholder={t("settings.ai.aboutMe.placeholder")}
|
||||
disabled={!isAuthenticated || query.isLoading || save.isPending}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
aria-label={t("settings.ai.aboutMe.title")}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{structuredField(
|
||||
"conditions",
|
||||
t("settings.ai.aboutMe.conditionsLabel"),
|
||||
t("settings.ai.aboutMe.conditionsPlaceholder"),
|
||||
)}
|
||||
{structuredField(
|
||||
"allergies",
|
||||
t("settings.ai.aboutMe.allergiesLabel"),
|
||||
t("settings.ai.aboutMe.allergiesPlaceholder"),
|
||||
)}
|
||||
<div className="sm:col-span-2">
|
||||
{structuredField(
|
||||
"coachFocus",
|
||||
t("settings.ai.aboutMe.focusLabel"),
|
||||
t("settings.ai.aboutMe.focusPlaceholder"),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="settings-about-me-freetext">
|
||||
{t("settings.ai.aboutMe.freeTextLabel")}
|
||||
</Label>
|
||||
<textarea
|
||||
id="settings-about-me-freetext"
|
||||
data-testid="settings-about-me-textarea"
|
||||
className={cn(FIELD_CLASSES, "min-h-36 resize-y")}
|
||||
value={value.aboutMe}
|
||||
maxLength={maxChars}
|
||||
placeholder={t("settings.ai.aboutMe.placeholder")}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
setDrafts((d) => ({ ...d, aboutMe: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p
|
||||
@@ -120,19 +270,26 @@ export function AboutMeSection({
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
>
|
||||
{t("settings.ai.aboutMe.charCount", {
|
||||
used: value.length,
|
||||
used: value.aboutMe.length,
|
||||
max: maxChars,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{saved.length > 0 && (
|
||||
{hasAnyContent && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="settings-about-me-clear"
|
||||
disabled={!isAuthenticated || save.isPending}
|
||||
onClick={() => save.mutate("")}
|
||||
onClick={() =>
|
||||
save.mutate({
|
||||
aboutMe: "",
|
||||
conditions: "",
|
||||
allergies: "",
|
||||
coachFocus: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
{t("settings.ai.aboutMe.clear")}
|
||||
@@ -156,6 +313,34 @@ export function AboutMeSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingQuestions.length > 0 && (
|
||||
<div
|
||||
data-testid="settings-about-me-questions"
|
||||
className="border-dracula-purple/30 bg-dracula-purple/5 rounded-lg border p-4"
|
||||
>
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
<MessageCircleQuestion
|
||||
className="text-dracula-purple size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("settings.ai.aboutMe.questionsTitle")}
|
||||
</p>
|
||||
<ul className="text-muted-foreground mt-2 list-disc space-y-1 pl-5 text-sm">
|
||||
{pendingQuestions.map((q) => (
|
||||
<li key={q}>{q}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-3 text-xs">
|
||||
<Link
|
||||
href="/insights/coach"
|
||||
className="text-dracula-purple underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("settings.ai.aboutMe.questionsOpenCoach")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{query.isError && (
|
||||
<p role="status" aria-live="polite" className="text-destructive text-xs">
|
||||
{t("settings.ai.aboutMe.loadError")}
|
||||
@@ -163,7 +348,7 @@ export function AboutMeSection({
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
|
||||
{t("settings.ai.aboutMe.hint")}
|
||||
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user