Files
HealthLog/src/components/settings/about-me-section.tsx
T
Marc-André Bombeck d73656685a fix(settings): move pre-existing conditions into Anamnese
The chronic / pre-existing conditions the Coach watches lived under the
personal-context "About me" panel, but they belong with the rest of the
medical history. Move the conditions and the "what should the Coach
watch" focus into Settings -> Anamnese, next to allergies and family
history, and make them editable inline there.

The store is unchanged: both fields are still part of the
/api/coach/about-me payload, read and written through the same query key,
so the Coach and every other consumer keep reading the same data. The
About-me panel keeps the free-text note and the allergies line and omits
the two conditions fields on save, leaving them untouched.
2026-06-30 23:46:03 +02:00

329 lines
11 KiB
TypeScript

"use client";
/**
* v1.15.20 — Settings → AI "About me" panel (free text only).
* v1.16.0 — structured self-context: alongside the free text the user
* notes allergies / intolerances. All 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.
*
* v1.25.12 — the pre-existing / chronic conditions and the "what should the
* Coach watch" focus moved out of this panel into Settings → Anamnese, where
* they sit with allergies + family history as one medical history. Editing them
* has a single home there now; this panel keeps the free-text note and the
* allergies line. The store is unchanged — every field is still part of the
* `/api/coach/about-me` payload; the PUT here simply omits the two conditions
* fields, which are preserved server-side.
*
* GET /api/coach/about-me — full structured payload + pending questions
* PUT /api/coach/about-me — writes aboutMe (empty string clears) + allergies;
* conditions / coachFocus are omitted here and left untouched
*
* 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 — 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,
MessageCircleQuestion,
NotebookPen,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
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 = "allergies";
async function fetchAboutMe(): Promise<AboutMeData> {
return apiGet<AboutMeData>("/api/coach/about-me");
}
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,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [drafts, setDrafts] = useState<{
aboutMe: string | null;
allergies: string | null;
}>({ aboutMe: null, allergies: null });
const query = useQuery({
queryKey: queryKeys.coachAboutMe(),
queryFn: fetchAboutMe,
enabled: isAuthenticated,
});
const saved = {
aboutMe: query.data?.aboutMe ?? "",
allergies: query.data?.allergies ?? "",
};
const maxChars = query.data?.maxChars ?? FALLBACK_MAX_CHARS;
const fieldMaxChars = query.data?.fieldMaxChars ?? FALLBACK_FIELD_MAX_CHARS;
const pendingQuestions = query.data?.pendingQuestions ?? [];
const value = {
aboutMe: drafts.aboutMe ?? saved.aboutMe,
allergies: drafts.allergies ?? saved.allergies,
};
const dirty =
value.aboutMe !== saved.aboutMe || value.allergies !== saved.allergies;
const hasAnyContent = saved.aboutMe.length > 0 || saved.allergies.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.allergies, saved.aboutMe].filter(
(v) => v.length > 0,
).length;
const save = useMutation({
mutationKey: queryKeys.coachAboutMe(),
// Send only the two fields this panel owns. `conditions` / `coachFocus`
// are omitted so the server preserves them (they are edited under Anamnese).
mutationFn: async (input: { aboutMe: string; allergies: string }) => {
return apiPut<AboutMeData>("/api/coach/about-me", input);
},
onSuccess: (next) => {
const cleared = !next.aboutMe && !next.allergies;
toast.success(
cleared
? t("settings.ai.aboutMe.clearedToast")
: t("settings.ai.aboutMe.savedToast"),
);
setDrafts({ aboutMe: null, allergies: 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 (
<SettingsCard
as="section"
aria-labelledby="settings-ai-about-me-title"
data-testid="settings-about-me-card"
className="space-y-5"
>
<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 — two quiet segments, no percentage shouting. */}
<div
className="flex items-center gap-1.5"
aria-label={t("settings.ai.aboutMe.completeness", {
answered,
total: 2,
})}
title={t("settings.ai.aboutMe.completeness", {
answered,
total: 2,
})}
>
{[0, 1].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>
<div className="pl-7">
{structuredField(
"allergies",
t("settings.ai.aboutMe.allergiesLabel"),
t("settings.ai.aboutMe.allergiesPlaceholder"),
)}
</div>
<div className="space-y-1.5 pl-7">
<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 pl-7">
<p
data-testid="settings-about-me-count"
className="text-muted-foreground text-xs tabular-nums"
>
{t("settings.ai.aboutMe.charCount", {
used: value.aboutMe.length,
max: maxChars,
})}
</p>
<div className="flex items-center gap-2">
{hasAnyContent && (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid="settings-about-me-clear"
disabled={!isAuthenticated || save.isPending}
onClick={() => save.mutate({ aboutMe: "", allergies: "" })}
>
<Trash2 className="size-4" aria-hidden />
{t("settings.ai.aboutMe.clear")}
</Button>
)}
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid="settings-about-me-save"
disabled={!isAuthenticated || save.isPending || !dirty}
onClick={() => save.mutate(value)}
>
{save.isPending && (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
)}
{t("settings.ai.aboutMe.save")}
</Button>
</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="/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-sm"
>
{t("settings.ai.aboutMe.loadError")}
</p>
)}
<p className="text-muted-foreground border-border border-t pt-3 pl-7 text-xs">
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
</p>
{/* v1.25.12 — cross-link to the Anamnese (medical-history) home, where the
chronic conditions the Coach watches now live alongside allergies +
family history as one coherent medical history. */}
<p className="text-muted-foreground pl-7 text-xs">
{t("settings.ai.aboutMe.recordsLink")}{" "}
<Link
href="/settings/anamnesis"
className="text-dracula-purple underline-offset-4 hover:underline"
>
{t("settings.ai.aboutMe.recordsLinkCta")}
</Link>
</p>
</SettingsCard>
);
}