mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
13b7d0379b
commit
a1b1617d0f
18 files changed
+603
-403
No files matched your search
+7
-5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
+23
-6
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user