settings: move the About-me note and free-text allergies into the Anamnese

The free-text self-context note and the free-text allergy line are
personal medical context, so they join conditions, allergies and family
history in Settings -> Anamnese. The note is its own card (same AI-surface
gate as the inclusion card); the allergy line sits directly under the
structured AllergyManager in the same card, labelled as a free-text
supplement so the two inputs never read as duplicates. The account
section drops the old panel. Store, API and query keys are unchanged;
the scoped Clear (note only, allergies untouched) carries over with its
tests. Orphaned strings removed, new labels in all six languages.
Refs #159
This commit is contained in:
Marc-André Bombeck committed 2026-08-13 06:16:45 +02:00
1 parent 13b7d0379b
commit a1b1617d0f
18 files changed
+603 -403

No files matched your search

+7 -5
View File
@@ -5622,14 +5622,10 @@
"profileHint": "Alter und Geschlecht stammen aus deinem Profil und werden automatisch mitgegeben.",
"freeTextLabel": "Über mich (Freitext)",
"conditionsPlaceholder": "Z. B. Bluthochdruck, Typ-2-Diabetes…",
"allergiesLabel": "Allergien & Unverträglichkeiten",
"allergiesPlaceholder": "Z. B. Laktoseintoleranz, Penicillin-Allergie…",
"focusPlaceholder": "Z. B. behalte meinen Blutdruck im Blick, motiviere mich zu mehr Bewegung…",
"completeness": "{answered} von {total} beantwortet",
"questionsTitle": "Der Coach hat Rückfragen",
"questionsOpenCoach": "Im Coach beantworten",
"recordsLink": "Allergien, Familienanamnese und deine Vorerkrankungen gehören zu deiner Anamnese.",
"recordsLinkCta": "Anamnese öffnen"
"questionsOpenCoach": "Im Coach beantworten"
},
"responseTimeoutHeading": "Antwort-Timeout",
"responseTimeoutBody": "Wie lange auf die Antwort der KI gewartet wird, bevor abgebrochen wird. Erhöhe den Wert für ein lokales oder selbst gehostetes Modell, das beim ersten Aufruf Zeit zum Laden braucht.",
@@ -9303,6 +9299,12 @@
"savedToast": "Allergie gespeichert",
"updatedToast": "Allergie aktualisiert",
"saveError": "Die Allergie konnte nicht gespeichert werden."
},
"freeText": {
"label": "Freitext-Ergänzung (Allergien & Unverträglichkeiten)",
"hint": "Eine Freitext-Ergänzung zur strukturierten Liste oben, für Kontext, den ein einzelner Eintrag nicht abbilden kann.",
"savedToast": "Allergie-Notiz gespeichert.",
"saveError": "Speichern fehlgeschlagen. Bitte versuche es erneut."
}
},
"family": {
+7 -5
View File
@@ -5622,14 +5622,10 @@
"profileHint": "Age and gender come from your profile and are included automatically.",
"freeTextLabel": "About me (free text)",
"conditionsPlaceholder": "E.g. hypertension, type 2 diabetes…",
"allergiesLabel": "Allergies & intolerances",
"allergiesPlaceholder": "E.g. lactose intolerance, penicillin allergy…",
"focusPlaceholder": "E.g. keep an eye on my blood pressure, motivate me to move more…",
"completeness": "{answered} of {total} answered",
"questionsTitle": "The Coach has follow-up questions",
"questionsOpenCoach": "Answer them in the Coach",
"recordsLink": "Allergies, family history and your pre-existing conditions are part of your medical history.",
"recordsLinkCta": "Open medical history"
"questionsOpenCoach": "Answer them in the Coach"
},
"responseTimeoutHeading": "Response timeout",
"responseTimeoutBody": "How long to wait for the AI to respond before giving up. Raise it for a local or self-hosted model that needs time to load on the first request.",
@@ -9303,6 +9299,12 @@
"savedToast": "Allergy saved",
"updatedToast": "Allergy updated",
"saveError": "Couldn't save the allergy."
},
"freeText": {
"label": "Free-text note (allergies & intolerances)",
"hint": "A free-text supplement to the structured list above, for context a single entry cannot carry.",
"savedToast": "Allergy note saved.",
"saveError": "Could not save. Please try again."
}
},
"family": {
+7 -5
View File
@@ -5622,14 +5622,10 @@
"profileHint": "La edad y el género provienen de tu perfil y se incluyen automáticamente.",
"freeTextLabel": "Sobre mí (texto libre)",
"conditionsPlaceholder": "P. ej. hipertensión, diabetes tipo 2…",
"allergiesLabel": "Alergias e intolerancias",
"allergiesPlaceholder": "P. ej. intolerancia a la lactosa, alergia a la penicilina…",
"focusPlaceholder": "P. ej. vigila mi tensión arterial, motívame a moverme más…",
"completeness": "{answered} de {total} respondidas",
"questionsTitle": "El Coach tiene preguntas",
"questionsOpenCoach": "Responderlas en el Coach",
"recordsLink": "Las alergias, los antecedentes familiares y tus enfermedades preexistentes forman parte de tu historia clínica.",
"recordsLinkCta": "Abrir historia clínica"
"questionsOpenCoach": "Responderlas en el Coach"
},
"responseTimeoutHeading": "Tiempo de espera de la respuesta",
"responseTimeoutBody": "Cuánto esperar a que responda la IA antes de cancelar. Aumenta el valor para un modelo local o autoalojado que necesita tiempo para cargarse en la primera solicitud.",
@@ -9303,6 +9299,12 @@
"savedToast": "Alergia guardada",
"updatedToast": "Alergia actualizada",
"saveError": "No se ha podido guardar la alergia."
},
"freeText": {
"label": "Nota de texto libre (alergias e intolerancias)",
"hint": "Un complemento de texto libre a la lista estructurada de arriba, para contexto que una entrada individual no puede reflejar.",
"savedToast": "Nota de alergias guardada.",
"saveError": "No se pudo guardar. Inténtalo de nuevo."
}
},
"family": {
+7 -5
View File
@@ -5622,14 +5622,10 @@
"profileHint": "L'âge et le genre proviennent de votre profil et sont inclus automatiquement.",
"freeTextLabel": "À propos de moi (texte libre)",
"conditionsPlaceholder": "P. ex. hypertension, diabète de type 2…",
"allergiesLabel": "Allergies et intolérances",
"allergiesPlaceholder": "P. ex. intolérance au lactose, allergie à la pénicilline…",
"focusPlaceholder": "P. ex. surveille ma tension, motive-moi à bouger davantage…",
"completeness": "{answered} sur {total} renseignés",
"questionsTitle": "Le Coach a des questions",
"questionsOpenCoach": "Y répondre dans le Coach",
"recordsLink": "Les allergies, les antécédents familiaux et vos affections préexistantes font partie de vos antécédents médicaux.",
"recordsLinkCta": "Ouvrir les antécédents médicaux"
"questionsOpenCoach": "Y répondre dans le Coach"
},
"responseTimeoutHeading": "Délai de réponse",
"responseTimeoutBody": "Durée d'attente de la réponse de l'IA avant d'abandonner. Augmentez-la pour un modèle local ou auto-hébergé qui a besoin de temps pour se charger à la première requête.",
@@ -9303,6 +9299,12 @@
"savedToast": "Allergie enregistrée",
"updatedToast": "Allergie mise à jour",
"saveError": "Impossible d'enregistrer l'allergie."
},
"freeText": {
"label": "Note en texte libre (allergies et intolérances)",
"hint": "Un complément en texte libre à la liste structurée ci-dessus, pour un contexte qu'une entrée seule ne peut pas refléter.",
"savedToast": "Note d'allergies enregistrée.",
"saveError": "Impossible d'enregistrer. Veuillez réessayer."
}
},
"family": {
+7 -5
View File
@@ -5622,14 +5622,10 @@
"profileHint": "Età e genere provengono dal tuo profilo e vengono inclusi automaticamente.",
"freeTextLabel": "Su di me (testo libero)",
"conditionsPlaceholder": "Es. ipertensione, diabete di tipo 2…",
"allergiesLabel": "Allergie e intolleranze",
"allergiesPlaceholder": "Es. intolleranza al lattosio, allergia alla penicillina…",
"focusPlaceholder": "Es. tieni d'occhio la mia pressione, motivami a muovermi di più…",
"completeness": "{answered} su {total} compilati",
"questionsTitle": "Il Coach ha alcune domande",
"questionsOpenCoach": "Rispondi nel Coach",
"recordsLink": "Allergie, anamnesi familiare e le tue patologie preesistenti fanno parte della tua anamnesi.",
"recordsLinkCta": "Apri anamnesi"
"questionsOpenCoach": "Rispondi nel Coach"
},
"responseTimeoutHeading": "Timeout della risposta",
"responseTimeoutBody": "Quanto attendere la risposta dell'IA prima di annullare. Aumentalo per un modello locale o self-hosted che ha bisogno di tempo per caricarsi alla prima richiesta.",
@@ -9303,6 +9299,12 @@
"savedToast": "Allergia salvata",
"updatedToast": "Allergia aggiornata",
"saveError": "Impossibile salvare l'allergia."
},
"freeText": {
"label": "Nota a testo libero (allergie e intolleranze)",
"hint": "Un'integrazione a testo libero all'elenco strutturato qui sopra, per il contesto che una singola voce non può contenere.",
"savedToast": "Nota sulle allergie salvata.",
"saveError": "Impossibile salvare. Riprova."
}
},
"family": {
+7 -5
View File
@@ -5622,14 +5622,10 @@
"profileHint": "Wiek i płeć pochodzą z Twojego profilu i są dołączane automatycznie.",
"freeTextLabel": "O mnie (tekst dowolny)",
"conditionsPlaceholder": "Np. nadciśnienie, cukrzyca typu 2…",
"allergiesLabel": "Alergie i nietolerancje",
"allergiesPlaceholder": "Np. nietolerancja laktozy, alergia na penicylinę…",
"focusPlaceholder": "Np. pilnuj mojego ciśnienia, motywuj mnie do ruchu…",
"completeness": "Wypełnione: {answered} z {total}",
"questionsTitle": "Coach ma pytania",
"questionsOpenCoach": "Odpowiedz w Coachu",
"recordsLink": "Alergie, wywiad rodzinny i Twoje choroby przewlekłe są częścią Twojego wywiadu medycznego.",
"recordsLinkCta": "Otwórz wywiad medyczny"
"questionsOpenCoach": "Odpowiedz w Coachu"
},
"responseTimeoutHeading": "Limit czasu odpowiedzi",
"responseTimeoutBody": "Jak długo czekać na odpowiedź AI, zanim nastąpi przerwanie. Zwiększ wartość dla lokalnego lub samodzielnie hostowanego modelu, który potrzebuje czasu na załadowanie przy pierwszym żądaniu.",
@@ -9303,6 +9299,12 @@
"savedToast": "Alergia zapisana",
"updatedToast": "Alergia zaktualizowana",
"saveError": "Nie udało się zapisać alergii."
},
"freeText": {
"label": "Notatka tekstowa (alergie i nietolerancje)",
"hint": "Tekstowe uzupełnienie ustrukturyzowanej listy powyżej, na kontekst, którego pojedynczy wpis nie odda.",
"savedToast": "Notatka o alergiach zapisana.",
"saveError": "Nie udało się zapisać. Spróbuj ponownie."
}
},
"family": {
@@ -189,7 +189,7 @@ const REGISTRY: DestructiveEntry[] = [
confirm: ["inline-two-step"],
},
{
file: "components/settings/about-me-section.tsx",
file: "components/records/about-me-note-manager.tsx",
destroys: "the free-text self-context note the Coach and briefing read",
recovery: "permanent",
confirm: ["ConfirmButton"],
@@ -276,7 +276,8 @@ const PINNED_AFFORDANCES: Record<
"src/components/records/family-history-form.tsx": { "toast.success": 1 },
"src/components/records/family-history-manager.tsx": { "toast.success": 1 },
"src/components/settings/_info-tile.tsx": { "text-success": 1 },
"src/components/settings/about-me-section.tsx": { "toast.success": 1 },
"src/components/records/about-me-note-manager.tsx": { "toast.success": 1 },
"src/components/records/allergy-free-text-note.tsx": { "toast.success": 1 },
"src/components/settings/account-section/avatar-section.tsx": {
"text-success": 1,
},
@@ -1,5 +1,10 @@
/**
* Settings → AI "About me": the Clear control cannot take the allergy line.
* Anamnese "About me" note: the Clear control cannot take the allergy line.
*
* #159 — the panel moved from the account settings into the Anamnese as
* `about-me-note-manager.tsx` (note) + `allergy-free-text-note.tsx` (the
* free-text allergy line, under the structured AllergyManager). The scoped
* clear is unchanged and stays pinned here.
*
* The control used to fire `save.mutate({ aboutMe: "", allergies: "" })` from
* a bare `onClick`. One tap wiped a person's free-text note AND their
@@ -81,7 +86,8 @@ vi.mock("@/components/ui/confirm-button", () => ({
ConfirmDialog: () => null,
}));
const { AboutMeSection } = await import("../about-me-section");
const { AboutMeNoteManager } = await import("../about-me-note-manager");
const { AllergyFreeTextNote } = await import("../allergy-free-text-note");
const STORED: AboutMePayload = {
aboutMe: "Shift work, training for a half marathon.",
@@ -102,7 +108,7 @@ function render(seed: Partial<typeof STORED> = {}) {
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<AboutMeSection isAuthenticated />
<AboutMeNoteManager />
</I18nProvider>
</QueryClientProvider>,
);
@@ -171,10 +177,21 @@ describe("clearing allergies stays possible, in the field itself", () => {
it("renders the allergy textarea, editable, carrying the stored value", () => {
// Removing the field from the Clear payload only removes the untargeted
// route. The targeted one has to still be there, or scoping the control
// would have taken a capability away rather than a footgun.
const html = render();
// would have taken a capability away rather than a footgun. #159 — the
// field lives in its own component under the structured allergy list now.
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
client.setQueryData(queryKeys.coachAboutMe(), STORED);
const html = renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<AllergyFreeTextNote />
</I18nProvider>
</QueryClientProvider>,
);
const field = html.slice(
html.indexOf('data-testid="settings-about-me-allergies"'),
html.indexOf('data-testid="records-allergy-free-text"'),
);
const openTag = field.slice(0, field.indexOf(">"));
expect(openTag).not.toContain("disabled");
@@ -0,0 +1,220 @@
"use client";
/**
* #159 — the "About me" free-text note, at home in the Anamnese
* (medical-history) section.
*
* v1.15.20 introduced the note under Settings → AI, v1.18.1 moved it to the
* account profile, v1.25.12 moved the chronic conditions + Coach focus out of
* it into the Anamnese. This completes the move: the note itself is personal
* medical context the Coach and the daily briefing read, so it lives with
* conditions, allergies and family history as one coherent medical history.
* The card header (title/description) is rendered by `anamnesis-section.tsx`,
* matching the neighbouring managers.
*
* Data path is unchanged: the note is part of the self-context payload
* (`GET`/`PUT /api/coach/about-me`), read and written through the same
* `queryKeys.coachAboutMe()` query- and mutation-key the rest of the app
* uses. The PUT sends only the field this editor owns (plus the base token),
* so conditions / focus / allergies stay untouched by contract.
*
* The "Clear" control writes `aboutMe: ""` and NOTHING else — the allergies
* field is omitted from the payload so the stored allergy line survives (the
* v1.32-era scoped-clear lesson; see `about-me-clear-scope.test.tsx`).
*
* 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, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
import { Label } from "@/components/ui/label";
import { ConfirmButton } from "@/components/ui/confirm-button";
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";
import { withBaseToken, isConflict } from "@/lib/api/optimistic-token";
const FALLBACK_MAX_CHARS = 4000;
interface AboutMeData {
aboutMe: string | null;
conditions: string | null;
allergies: string | null;
coachFocus: string | null;
pendingQuestions: string[];
updatedAt: string | null;
maxChars: number;
fieldMaxChars: number;
}
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 AboutMeNoteManager() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [draft, setDraft] = useState<string | null>(null);
const query = useQuery({
queryKey: queryKeys.coachAboutMe(),
queryFn: () => apiGet<AboutMeData>("/api/coach/about-me"),
});
const savedNote = query.data?.aboutMe ?? "";
const maxChars = query.data?.maxChars ?? FALLBACK_MAX_CHARS;
const pendingQuestions = query.data?.pendingQuestions ?? [];
const value = draft ?? savedNote;
const dirty = value !== savedNote;
// The clear control only ever touches the note, so it only appears when
// there is a note to clear.
const hasNote = savedNote.length > 0;
const save = useMutation({
mutationKey: queryKeys.coachAboutMe(),
// Send only the note. `conditions` / `coachFocus` / `allergies` are
// omitted so the server preserves them (they are edited in their own
// cards). v1.32.21 (R5a) — echo the base token this edit was based on so
// a concurrent write to the same self-context 409s instead of silently
// overwriting the newer state.
mutationFn: async (input: { aboutMe: string }) => {
return apiPut<AboutMeData>(
"/api/coach/about-me",
withBaseToken(input, query.data?.updatedAt ?? undefined),
);
},
onSuccess: (next) => {
const cleared = !next.aboutMe;
toast.success(
cleared
? t("settings.ai.aboutMe.clearedToast")
: t("settings.ai.aboutMe.savedToast"),
);
setDraft(null);
queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
queryClient.invalidateQueries({
queryKey: queryKeys.coachAboutMeQuestions(),
});
},
onError: (err) => {
// v1.32.21 (R5a) — a 409 means the stored self-context advanced since
// this edit was based. Refetch so the token advances, KEEP the draft so
// the user can re-save, nudge gently.
if (isConflict(err)) {
queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
toast.message(t("common.conflictReloaded"));
return;
}
toast.error(t("settings.ai.aboutMe.saveError"));
},
});
const disabled = query.isLoading || save.isPending;
return (
<div className="space-y-4" data-slot="about-me-note-manager">
<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}
maxLength={maxChars}
placeholder={t("settings.ai.aboutMe.placeholder")}
disabled={disabled}
onChange={(e) => setDraft(e.target.value)}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<p
data-testid="settings-about-me-count"
className="text-muted-foreground text-xs tabular-nums"
>
{t("settings.ai.aboutMe.charCount", {
used: value.length,
max: maxChars,
})}
</p>
<div className="flex items-center gap-2">
{hasNote && (
<ConfirmButton
slot="settings-about-me-clear"
label={t("settings.ai.aboutMe.clear")}
icon={<Trash2 className="size-4" aria-hidden />}
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
title={t("settings.ai.aboutMe.clearTitle")}
body={t("settings.ai.aboutMe.clearBody")}
confirmLabel={t("settings.ai.aboutMe.clearConfirm")}
pending={save.isPending}
onConfirm={() => save.mutate({ aboutMe: "" })}
/>
)}
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid="settings-about-me-save"
disabled={save.isPending || !dirty}
onClick={() => save.mutate({ aboutMe: 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 && (
<SettingsInfoTile
icon={MessageCircleQuestion}
tone="primary"
data-testid="settings-about-me-questions"
title={t("settings.ai.aboutMe.questionsTitle")}
>
<ul className="list-disc space-y-1 pl-6">
{pendingQuestions.map((q) => (
<li key={q}>{q}</li>
))}
</ul>
<p className="mt-3 text-xs">
<Link
href="/coach"
className="text-primary underline-offset-4 hover:underline"
>
{t("settings.ai.aboutMe.questionsOpenCoach")}
</Link>
</p>
</SettingsInfoTile>
)}
{query.isError && (
<QueryErrorRow
message={t("settings.ai.aboutMe.loadError")}
onRetry={() => query.refetch()}
/>
)}
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
</p>
</div>
);
}
@@ -0,0 +1,149 @@
"use client";
/**
* #159 — the free-text allergies / intolerances line, mounted directly under
* the structured `AllergyManager` in the SAME Anamnese card.
*
* The structured list is the record; this line is its labelled free-text
* SUPPLEMENT (context the structured rows cannot carry — "reacts within
* minutes", "family suspects a cross-allergy"). The label + the one-sentence
* hint say exactly that, so the two inputs never read as duplicates. It used
* to live in the account-settings "About me" panel; the placement moved here
* so both allergy inputs sit in one place.
*
* Data path is unchanged: the line is part of the self-context payload
* (`GET`/`PUT /api/coach/about-me`), read and written through the same
* `queryKeys.coachAboutMe()` query- and mutation-key the rest of the app
* uses. The PUT sends only this field (plus the base token), so the note /
* conditions / focus stay untouched by contract. Clearing the line is done in
* the field itself — a deliberate act on the thing being emptied.
*
* Plain text only — the value renders exclusively through the input; no
* markdown anywhere.
*/
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Label } from "@/components/ui/label";
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";
import { withBaseToken, isConflict } from "@/lib/api/optimistic-token";
const FALLBACK_FIELD_MAX_CHARS = 500;
interface AboutMeData {
aboutMe: string | null;
conditions: string | null;
allergies: string | null;
coachFocus: string | null;
updatedAt: string | null;
fieldMaxChars: number;
}
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 AllergyFreeTextNote() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [draft, setDraft] = useState<string | null>(null);
const query = useQuery({
queryKey: queryKeys.coachAboutMe(),
queryFn: () => apiGet<AboutMeData>("/api/coach/about-me"),
});
const savedLine = query.data?.allergies ?? "";
const fieldMaxChars = query.data?.fieldMaxChars ?? FALLBACK_FIELD_MAX_CHARS;
const value = draft ?? savedLine;
const dirty = value !== savedLine;
const save = useMutation({
mutationKey: queryKeys.coachAboutMe(),
// Send only the allergies line — omitted fields are preserved server-side
// by the PUT contract. v1.32.21 (R5a) — echo the base token so a
// concurrent self-context write 409s instead of silently losing.
mutationFn: async (input: { allergies: string }) => {
return apiPut<AboutMeData>(
"/api/coach/about-me",
withBaseToken(input, query.data?.updatedAt ?? undefined),
);
},
onSuccess: () => {
toast.success(t("records.allergies.freeText.savedToast"));
setDraft(null);
queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
queryClient.invalidateQueries({
queryKey: queryKeys.coachAboutMeQuestions(),
});
},
onError: (err) => {
if (isConflict(err)) {
queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
toast.message(t("common.conflictReloaded"));
return;
}
toast.error(t("records.allergies.freeText.saveError"));
},
});
const disabled = query.isLoading || save.isPending;
return (
<div
className="border-border space-y-1.5 border-t pt-4"
data-slot="allergy-free-text-note"
>
<Label htmlFor="records-allergy-free-text">
{t("records.allergies.freeText.label")}
</Label>
{/* One sentence naming the difference: the structured rows above are
the record; this line is free-text context supplementing them. */}
<p className="text-muted-foreground text-xs">
{t("records.allergies.freeText.hint")}
</p>
<textarea
id="records-allergy-free-text"
data-testid="records-allergy-free-text"
className={cn(FIELD_CLASSES, "min-h-16 resize-y")}
rows={2}
value={value}
maxLength={fieldMaxChars}
placeholder={t("settings.ai.aboutMe.allergiesPlaceholder")}
disabled={disabled}
onChange={(e) => setDraft(e.target.value)}
/>
{query.isError ? (
<QueryErrorRow
message={t("settings.ai.aboutMe.loadError")}
onRetry={() => query.refetch()}
/>
) : (
<div className="flex justify-end">
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid="records-allergy-free-text-save"
disabled={save.isPending || !dirty}
onClick={() => save.mutate({ allergies: value })}
>
{save.isPending && (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
)}
{t("settings.ai.aboutMe.save")}
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { I18nProvider } from "@/lib/i18n/context";
import { AnamnesisSection } from "../anamnesis-section";
/**
* #159 — the "About me" note and the free-text allergies line moved from the
* account settings into the Anamnese. Two halves, and both matter:
*
* 1. PRESENCE — the Anamnese renders the note as its own card and mounts
* the free-text allergy supplement in the SAME card as the structured
* AllergyManager, directly under it.
* 2. ABSENCE — the account section carries no about-me mount any more. The
* section is too prop- and hook-heavy for an SSR render here, so the
* absence half is structural over the source (the established
* `destructive-controls-confirm.test.tsx` pattern): no import, no
* element, no about-me slot.
*/
const moduleState = vi.hoisted(() => ({
coach: true,
insights: true,
}));
vi.mock("@/hooks/use-module-enabled", () => ({
useModuleEnabled: (key: "coach" | "insights") => moduleState[key],
}));
vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({
AiProfileInclusionManager: () => null,
}));
vi.mock("@/components/records/conditions-manager", () => ({
ConditionsManager: () => null,
}));
vi.mock("@/components/records/health-profile-facts-manager", () => ({
HealthProfileFactsManager: () => null,
}));
vi.mock("@/components/records/allergy-manager", () => ({
AllergyManager: () => <div data-slot="allergy-manager-probe" />,
}));
vi.mock("@/components/records/allergy-free-text-note", () => ({
AllergyFreeTextNote: () => <div data-slot="allergy-free-text-probe" />,
}));
vi.mock("@/components/records/about-me-note-manager", () => ({
AboutMeNoteManager: () => <div data-slot="about-me-note-probe" />,
}));
vi.mock("@/components/records/family-history-manager", () => ({
FamilyHistoryManager: () => null,
}));
vi.mock("@/components/records/emergency-profile-manager", () => ({
EmergencyProfileManager: () => null,
}));
function renderSection(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AnamnesisSection />
</I18nProvider>,
);
}
beforeEach(() => {
moduleState.coach = true;
moduleState.insights = true;
});
describe("Anamnese — the About-me note card (#159)", () => {
it("renders the note as its own card in the medical history", () => {
const html = renderSection();
expect(html).toContain('data-testid="records-about-me-card"');
expect(html).toContain('data-slot="about-me-note-probe"');
expect(html).toContain("About me");
});
it("mounts the free-text allergy supplement in the allergies card, under the structured list", () => {
const html = renderSection();
const managerAt = html.indexOf('data-slot="allergy-manager-probe"');
const noteAt = html.indexOf('data-slot="allergy-free-text-probe"');
expect(managerAt).toBeGreaterThan(-1);
expect(noteAt).toBeGreaterThan(-1);
// Directly under the structured list, same card: the supplement follows
// the manager, and no card boundary (a new SettingsCard opens with its
// header h2) sits between the two.
expect(noteAt).toBeGreaterThan(managerAt);
const between = html.slice(managerAt, noteAt);
expect(between).not.toContain("<h2");
});
it("keeps the same AI-surface gate as the inclusion card", () => {
moduleState.coach = false;
moduleState.insights = false;
const html = renderSection();
expect(html).not.toContain('data-testid="records-about-me-card"');
// The allergy supplement is NOT AI-gated — the line is medical history
// whichever surfaces read it, exactly like the structured list.
expect(html).toContain('data-slot="allergy-free-text-probe"');
});
it("insights alone keeps the note available (the daily briefing reads it)", () => {
moduleState.coach = false;
const html = renderSection();
expect(html).toContain('data-testid="records-about-me-card"');
});
});
describe("Account settings — the About-me card is gone (#159)", () => {
it("the account section carries no about-me mount any more", () => {
const source = readFileSync(
join(process.cwd(), "src/components/settings/account-section/index.tsx"),
"utf8",
);
expect(source).not.toContain("AboutMeSection");
expect(source).not.toContain("about-me-section");
expect(source).not.toContain("AboutMeNoteManager");
expect(source).not.toContain('data-testid="settings-about-me-card"');
});
});
@@ -24,6 +24,12 @@ vi.mock("@/components/records/health-profile-facts-manager", () => ({
vi.mock("@/components/records/allergy-manager", () => ({
AllergyManager: () => null,
}));
vi.mock("@/components/records/allergy-free-text-note", () => ({
AllergyFreeTextNote: () => null,
}));
vi.mock("@/components/records/about-me-note-manager", () => ({
AboutMeNoteManager: () => <div data-slot="about-me-note" />,
}));
vi.mock("@/components/records/family-history-manager", () => ({
FamilyHistoryManager: () => null,
}));
@@ -51,10 +51,11 @@ const CONTROLS: { file: string; slots: string[] }[] = [
file: "components/settings/trusted-devices-card.tsx",
slots: ["revoke-trusted-device", "revoke-all-trusted-devices"],
},
// The allergy wipe. Scoped to the note as well as gated — see
// `about-me-clear-scope.test.tsx` for the payload half.
// The note wipe. Scoped to the note as well as gated — see
// `about-me-clear-scope.test.tsx` for the payload half. #159 — the panel
// moved to the Anamnese as `about-me-note-manager.tsx`.
{
file: "components/settings/about-me-section.tsx",
file: "components/records/about-me-note-manager.tsx",
slots: ["settings-about-me-clear"],
},
{
@@ -37,7 +37,8 @@ import { SecurityActivityCard } from "../security-activity-card";
import { TrustedDevicesCard } from "../trusted-devices-card";
import { CoachMemorySection } from "../coach-memory-section";
import { CoachRemindersSection } from "../coach-reminders-section";
import { AboutMeSection } from "../about-me-section";
import { AboutMeNoteManager } from "@/components/records/about-me-note-manager";
import { AllergyFreeTextNote } from "@/components/records/allergy-free-text-note";
function renderCard(node: React.ReactNode) {
return renderToStaticMarkup(
@@ -51,7 +52,8 @@ const cases: Array<[string, () => React.ReactNode]> = [
["TrustedDevicesCard", () => <TrustedDevicesCard isAuthenticated />],
["CoachMemorySection", () => <CoachMemorySection isAuthenticated />],
["CoachRemindersSection", () => <CoachRemindersSection isAuthenticated />],
["AboutMeSection", () => <AboutMeSection isAuthenticated />],
["AboutMeNoteManager", () => <AboutMeNoteManager />],
["AllergyFreeTextNote", () => <AllergyFreeTextNote />],
];
describe("settings read-error family — a read failure is foreground and recoverable", () => {
@@ -1,353 +0,0 @@
"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.
*
* The "Clear" control writes `aboutMe: ""` and nothing else. It used to send
* `allergies: ""` alongside, so one untargeted tap wiped the allergy line as
* well — while the toast it raised said "About-me note cleared", which is what
* the control looks like it does and what a person reading it expects. The
* allergy line is the entry a clinician reads first, so the control is scoped
* rather than merely gated: the field is omitted from the payload, and the PUT
* leaves an omitted field untouched by contract. Clearing allergies is done in
* the allergies field itself, which is a deliberate act on the thing being
* emptied. The confirmation on top of that guards the note.
*/
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 { QueryErrorRow } from "@/components/ui/query-error-row";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
import { Label } from "@/components/ui/label";
import { ConfirmButton } from "@/components/ui/confirm-button";
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";
import { withBaseToken, isConflict } from "@/lib/api/optimistic-token";
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;
// The clear control only ever touches the note, so it only appears when
// there is a note to clear.
const hasNote = saved.aboutMe.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).
// v1.32.21 (R5a) — echo the base token this edit was based on so a
// concurrent write to the same self-context (another tab, or Coach adopt)
// 409s instead of silently overwriting the newer state.
// `allergies` is optional on purpose: the clear control omits it so the
// stored allergy line survives, per the PUT's omitted-field contract.
mutationFn: async (input: { aboutMe: string; allergies?: string }) => {
return apiPut<AboutMeData>(
"/api/coach/about-me",
withBaseToken(input, query.data?.updatedAt ?? undefined),
);
},
onSuccess: (next) => {
const cleared = !next.aboutMe;
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: (err) => {
// v1.32.21 (R5a) — explicit-Save disposition: a 409 means the stored
// self-context advanced since this edit was based. Refetch so the token
// advances, KEEP the drafts so the user can re-save, nudge gently.
if (isConflict(err)) {
queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
toast.message(t("common.conflictReloaded"));
return;
}
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"
>
<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-primary" : "bg-border",
)}
/>
))}
</div>
</div>
<div>
{structuredField(
"allergies",
t("settings.ai.aboutMe.allergiesLabel"),
t("settings.ai.aboutMe.allergiesPlaceholder"),
)}
</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
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">
{hasNote && (
<ConfirmButton
slot="settings-about-me-clear"
label={t("settings.ai.aboutMe.clear")}
icon={<Trash2 className="size-4" aria-hidden />}
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
title={t("settings.ai.aboutMe.clearTitle")}
body={t("settings.ai.aboutMe.clearBody")}
confirmLabel={t("settings.ai.aboutMe.clearConfirm")}
disabled={!isAuthenticated}
pending={save.isPending}
onConfirm={() => save.mutate({ aboutMe: "" })}
/>
)}
<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 && (
<SettingsInfoTile
icon={MessageCircleQuestion}
tone="primary"
data-testid="settings-about-me-questions"
title={t("settings.ai.aboutMe.questionsTitle")}
>
<ul className="list-disc space-y-1 pl-6">
{pendingQuestions.map((q) => (
<li key={q}>{q}</li>
))}
</ul>
<p className="mt-3 text-xs">
<Link
href="/coach"
className="text-primary underline-offset-4 hover:underline"
>
{t("settings.ai.aboutMe.questionsOpenCoach")}
</Link>
</p>
</SettingsInfoTile>
)}
{query.isError && (
<QueryErrorRow
message={t("settings.ai.aboutMe.loadError")}
onRetry={() => query.refetch()}
/>
)}
<p className="text-muted-foreground border-border border-t pt-3 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 text-xs">
{t("settings.ai.aboutMe.recordsLink")}{" "}
<Link
href="/settings/anamnesis"
className="text-primary underline-offset-4 hover:underline"
>
{t("settings.ai.aboutMe.recordsLinkCta")}
</Link>
</p>
</SettingsCard>
);
}
@@ -25,7 +25,6 @@ import {
} from "@/lib/profile/height-unit-display";
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
import { useTranslations } from "@/lib/i18n/context";
import { AboutMeSection } from "@/components/settings/about-me-section";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TimezonePicker } from "@/components/settings/timezone-picker";
@@ -525,12 +524,10 @@ export function AccountSection() {
Medikamente settings section, where every medication-specific
preference now lives. */}
{/* v1.18.1 (D8) — the AI "About me" context lives under Profil, placed
before Zyklus-Tracking. It is personal medical context (conditions,
allergies, what the Coach should watch) the daily briefing + Coach
read, so it belongs with the account profile rather than the AI
provider-configuration screen. */}
<AboutMeSection isAuthenticated={isAuthenticated} />
{/* #159 — the AI "About me" note and the free-text allergies line moved
to Settings → Anamnese: they are personal medical context, so they
sit with conditions, allergies and family history as one medical
history (`anamnesis-section.tsx`). */}
{/* Cycle tracking is a module, and the Modules hub carries its switch.
The second enable surface that used to sit here asked the same
@@ -22,6 +22,7 @@ import {
ClipboardList,
EyeOff,
HeartPulse,
NotebookPen,
ShieldAlert,
Siren,
Users,
@@ -33,6 +34,8 @@ import { useTranslations } from "@/lib/i18n/context";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
import { AllergyManager } from "@/components/records/allergy-manager";
import { AllergyFreeTextNote } from "@/components/records/allergy-free-text-note";
import { AboutMeNoteManager } from "@/components/records/about-me-note-manager";
import { ConditionsManager } from "@/components/records/conditions-manager";
import { EmergencyProfileManager } from "@/components/records/emergency-profile-manager";
import { FamilyHistoryManager } from "@/components/records/family-history-manager";
@@ -66,6 +69,28 @@ export function AnamnesisSection() {
<ConditionsManager />
</SettingsCard>
)}
{/* #159 — the "About me" free-text note moved here from the account
settings: it is personal medical context the Coach and the daily
briefing read, so it belongs in the medical history. Same
AI-surface gate as the inclusion card above — the note only feeds
the AI surfaces, so with both disabled there is nothing to feed. */}
{(coachEnabled || insightsEnabled) && (
<SettingsCard
as="section"
aria-labelledby="records-about-me-title"
data-testid="records-about-me-card"
>
<SettingsCardHeader
icon={NotebookPen}
title={t("settings.ai.aboutMe.title")}
titleId="records-about-me-title"
description={t("settings.ai.aboutMe.description")}
/>
<AboutMeNoteManager />
</SettingsCard>
)}
<SettingsCard>
<SettingsCardHeader
icon={ClipboardList}
@@ -82,6 +107,10 @@ export function AnamnesisSection() {
description={t("records.allergies.cardDescription")}
/>
<AllergyManager />
{/* #159 — the free-text supplement to the structured list above,
moved here from the account-settings "About me" panel so both
allergy inputs live in one card. */}
<AllergyFreeTextNote />
</SettingsCard>
<SettingsCard>