diff --git a/messages/de.json b/messages/de.json index c661a9b13..2d115aed0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index df716ff7a..15f1df75d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 55d2cdf45..13c66febd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index a4214cfca..dd414bccc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 7d8790c76..7ee83cfc5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index f1c62f51c..91caae79a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index a9c42a979..27a745581 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -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"], diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index a7a984d9e..8d5ccc2ef 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -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, }, diff --git a/src/components/settings/__tests__/about-me-clear-scope.test.tsx b/src/components/records/__tests__/about-me-clear-scope.test.tsx similarity index 86% rename from src/components/settings/__tests__/about-me-clear-scope.test.tsx rename to src/components/records/__tests__/about-me-clear-scope.test.tsx index 60bfef5cf..24b396442 100644 --- a/src/components/settings/__tests__/about-me-clear-scope.test.tsx +++ b/src/components/records/__tests__/about-me-clear-scope.test.tsx @@ -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 = {}) { return renderToStaticMarkup( - + , ); @@ -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( + + + + + , + ); 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"); diff --git a/src/components/records/about-me-note-manager.tsx b/src/components/records/about-me-note-manager.tsx new file mode 100644 index 000000000..d53bc11d7 --- /dev/null +++ b/src/components/records/about-me-note-manager.tsx @@ -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(null); + + const query = useQuery({ + queryKey: queryKeys.coachAboutMe(), + queryFn: () => apiGet("/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( + "/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 ( +
+
+ +