fix(settings): move pre-existing conditions into Anamnese

The chronic / pre-existing conditions the Coach watches lived under the
personal-context "About me" panel, but they belong with the rest of the
medical history. Move the conditions and the "what should the Coach
watch" focus into Settings -> Anamnese, next to allergies and family
history, and make them editable inline there.

The store is unchanged: both fields are still part of the
/api/coach/about-me payload, read and written through the same query key,
so the Coach and every other consumer keep reading the same data. The
About-me panel keeps the free-text note and the allergies line and omits
the two conditions fields on save, leaving them untouched.
This commit is contained in:
Marc-André Bombeck committed 2026-06-30 23:46:03 +02:00
1 parent 5bfba0ba60
commit d73656685a
10 files changed
+233 -204

No files matched your search

+4 -4
View File
@@ -7828,12 +7828,12 @@
},
"conditions": {
"cardTitle": "Vorerkrankungen",
"cardDescription": "Chronische Erkrankungen und worauf der Coach achten soll — aus deinem persönlichen Kontext.",
"cardDescription": "Chronische Erkrankungen und worauf der Coach achten soll.",
"conditionsLabel": "Chronische Erkrankungen",
"focusLabel": "Worauf der Coach achtet",
"empty": "Noch nichts hinterlegt.",
"editCta": "Im persönlichen Kontext bearbeiten",
"loadError": "Vorerkrankungen konnten nicht geladen werden."
"loadError": "Vorerkrankungen konnten nicht geladen werden.",
"savedToast": "Erkrankungen gespeichert.",
"saveError": "Speichern fehlgeschlagen. Bitte versuche es erneut."
}
},
"environment": {
+4 -4
View File
@@ -7828,12 +7828,12 @@
},
"conditions": {
"cardTitle": "Pre-existing conditions",
"cardDescription": "Chronic conditions and what the Coach watches — from your personal context.",
"cardDescription": "Chronic conditions and what the Coach watches.",
"conditionsLabel": "Chronic conditions",
"focusLabel": "What the Coach watches",
"empty": "Nothing noted yet.",
"editCta": "Edit in personal context",
"loadError": "Couldn't load your conditions."
"loadError": "Couldn't load your conditions.",
"savedToast": "Conditions saved.",
"saveError": "Could not save. Please try again."
}
},
"environment": {
+4 -4
View File
@@ -7828,12 +7828,12 @@
},
"conditions": {
"cardTitle": "Enfermedades preexistentes",
"cardDescription": "Enfermedades crónicas y aquello que el Coach debe vigilar, desde tu contexto personal.",
"cardDescription": "Enfermedades crónicas y aquello que el Coach debe vigilar.",
"conditionsLabel": "Enfermedades crónicas",
"focusLabel": "Qué vigila el Coach",
"empty": "Aún no hay nada anotado.",
"editCta": "Editar en el contexto personal",
"loadError": "No se pudieron cargar tus enfermedades."
"loadError": "No se pudieron cargar tus enfermedades.",
"savedToast": "Enfermedades guardadas.",
"saveError": "No se pudo guardar. Inténtalo de nuevo."
}
},
"environment": {
+4 -4
View File
@@ -7828,12 +7828,12 @@
},
"conditions": {
"cardTitle": "Affections préexistantes",
"cardDescription": "Affections chroniques et ce que le Coach doit surveiller, d'après votre contexte personnel.",
"cardDescription": "Affections chroniques et ce que le Coach doit surveiller.",
"conditionsLabel": "Affections chroniques",
"focusLabel": "Ce que le Coach surveille",
"empty": "Rien de noté pour l'instant.",
"editCta": "Modifier dans le contexte personnel",
"loadError": "Impossible de charger vos antécédents."
"loadError": "Impossible de charger vos antécédents.",
"savedToast": "Affections enregistrées.",
"saveError": "Impossible d'enregistrer. Veuillez réessayer."
}
},
"environment": {
+4 -4
View File
@@ -7828,12 +7828,12 @@
},
"conditions": {
"cardTitle": "Patologie preesistenti",
"cardDescription": "Patologie croniche e ciò che il Coach deve tenere d'occhio, dal tuo contesto personale.",
"cardDescription": "Patologie croniche e ciò che il Coach deve tenere d'occhio.",
"conditionsLabel": "Patologie croniche",
"focusLabel": "Cosa tiene d'occhio il Coach",
"empty": "Ancora nessuna annotazione.",
"editCta": "Modifica nel contesto personale",
"loadError": "Impossibile caricare le tue patologie."
"loadError": "Impossibile caricare le tue patologie.",
"savedToast": "Patologie salvate.",
"saveError": "Impossibile salvare. Riprova."
}
},
"environment": {
+4 -4
View File
@@ -7828,12 +7828,12 @@
},
"conditions": {
"cardTitle": "Choroby przewlekłe",
"cardDescription": "Choroby przewlekłe i to, na co Coach ma zwracać uwagę — z Twojego kontekstu osobistego.",
"cardDescription": "Choroby przewlekłe i to, na co Coach ma zwracać uwagę.",
"conditionsLabel": "Choroby przewlekłe",
"focusLabel": "Na co zwraca uwagę Coach",
"empty": "Nic jeszcze nie zapisano.",
"editCta": "Edytuj w kontekście osobistym",
"loadError": "Nie udało się wczytać Twoich chorób."
"loadError": "Nie udało się wczytać Twoich chorób.",
"savedToast": "Choroby zapisane.",
"saveError": "Nie udało się zapisać. Spróbuj ponownie."
}
},
"environment": {
@@ -0,0 +1,166 @@
"use client";
/**
* v1.25.12 — pre-existing / chronic conditions editor, at home in the Anamnese
* (medical-history) section.
*
* The chronic-conditions note and the explicit "what should the Coach watch"
* focus are foundational medical history, so they are edited here next to
* allergies + family history — one coherent medical-history home. They used to
* be edited under Settings → Profile → "About me"; the editing moved here so the
* conditions the Coach watches sit with the rest of the medical record.
*
* Data path is unchanged: both fields are part of the self-context payload
* (`GET`/`PUT /api/coach/about-me`) and are read/written through the same
* `queryKeys.coachAboutMe()` query- and mutation-key the rest of the app uses,
* so every consumer (the Coach above all) keeps reading the same store. The PUT
* echoes the current `aboutMe` free text (a required field that clears on empty)
* and omits `allergies` (optional, preserved server-side) so saving conditions
* never disturbs the other self-context fields.
*
* Plain text only — values render exclusively through inputs and React text
* children; no markdown renderer (XSS posture, see contributor notes).
*/
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 { 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";
const FALLBACK_FIELD_MAX_CHARS = 500;
interface AboutMeData {
aboutMe: string | null;
conditions: string | null;
allergies: string | null;
coachFocus: 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 ConditionsManager() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [drafts, setDrafts] = useState<{
conditions: string | null;
coachFocus: string | null;
}>({ conditions: null, coachFocus: null });
const query = useQuery({
queryKey: queryKeys.coachAboutMe(),
queryFn: () => apiGet<AboutMeData>("/api/coach/about-me"),
});
const saved = {
conditions: query.data?.conditions ?? "",
coachFocus: query.data?.coachFocus ?? "",
};
const fieldMaxChars = query.data?.fieldMaxChars ?? FALLBACK_FIELD_MAX_CHARS;
const value = {
conditions: drafts.conditions ?? saved.conditions,
coachFocus: drafts.coachFocus ?? saved.coachFocus,
};
const dirty =
value.conditions !== saved.conditions ||
value.coachFocus !== saved.coachFocus;
const save = useMutation({
mutationKey: queryKeys.coachAboutMe(),
mutationFn: async (input: { conditions: string; coachFocus: string }) => {
// Echo the current free-text `aboutMe` (required; empty clears it) and
// omit `allergies` (optional, preserved server-side) so this write only
// touches the two conditions fields.
return apiPut<AboutMeData>("/api/coach/about-me", {
aboutMe: query.data?.aboutMe ?? "",
conditions: input.conditions,
coachFocus: input.coachFocus,
});
},
onSuccess: () => {
toast.success(t("records.conditions.savedToast"));
setDrafts({ conditions: null, coachFocus: null });
queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
queryClient.invalidateQueries({
queryKey: queryKeys.coachAboutMeQuestions(),
});
},
onError: () => {
toast.error(t("records.conditions.saveError"));
},
});
const disabled = query.isLoading || save.isPending;
function field(
key: "conditions" | "coachFocus",
label: string,
placeholder: string,
) {
return (
<div className="space-y-1.5">
<Label htmlFor={`records-conditions-${key}`}>{label}</Label>
<textarea
id={`records-conditions-${key}`}
data-testid={`records-conditions-${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 (
<div className="space-y-4" data-slot="conditions-manager">
{query.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("records.conditions.loadError")}
</p>
) : (
<>
{field(
"conditions",
t("records.conditions.conditionsLabel"),
t("settings.ai.aboutMe.conditionsPlaceholder"),
)}
{field(
"coachFocus",
t("records.conditions.focusLabel"),
t("settings.ai.aboutMe.focusPlaceholder"),
)}
<div className="flex justify-end">
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid="records-conditions-save"
disabled={disabled || !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>
);
}
@@ -1,98 +0,0 @@
"use client";
/**
* v1.25.1 — pre-existing / chronic conditions, surfaced in the Anamnese
* (medical-history) home.
*
* The chronic-conditions note and the explicit "what should the Coach watch"
* focus the user entered in Settings → Profile → "About me" are foundational
* medical history, so they are surfaced here next to allergies + family history
* to give one coherent medical-history overview.
*
* This is a READ-ONLY shared view of the existing self-context
* (`GET /api/coach/about-me`) — it reads through the same query-key the "About
* me" panel writes, so the two stay in lockstep. Editing keeps its single home
* in personal context (linked below); nothing is moved, no new storage, no
* schema change. Plain text only — values render as React text children, no
* markdown renderer (XSS posture, see contributor notes).
*/
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
interface SelfContext {
conditions: string | null;
coachFocus: string | null;
}
export function ConditionsOverview() {
const { t } = useTranslations();
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.coachAboutMe(),
queryFn: () => apiGet<SelfContext>("/api/coach/about-me"),
});
const conditions = (data?.conditions ?? "").trim();
const coachFocus = (data?.coachFocus ?? "").trim();
const hasAny = conditions.length > 0 || coachFocus.length > 0;
return (
<div className="space-y-4" data-slot="conditions-overview">
{isLoading && (
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
)}
{isError && (
<p role="alert" className="text-destructive text-sm">
{t("records.conditions.loadError")}
</p>
)}
{!isLoading && !isError && (
<>
{hasAny ? (
<dl className="space-y-3">
{conditions.length > 0 && (
<div className="space-y-1">
<dt className="text-sm font-medium">
{t("records.conditions.conditionsLabel")}
</dt>
<dd className="text-muted-foreground text-sm whitespace-pre-wrap">
{conditions}
</dd>
</div>
)}
{coachFocus.length > 0 && (
<div className="space-y-1">
<dt className="text-sm font-medium">
{t("records.conditions.focusLabel")}
</dt>
<dd className="text-muted-foreground text-sm whitespace-pre-wrap">
{coachFocus}
</dd>
</div>
)}
</dl>
) : (
<p className="text-muted-foreground text-sm">
{t("records.conditions.empty")}
</p>
)}
<p className="text-xs">
<Link
href="/settings/account"
className="text-dracula-purple underline-offset-4 hover:underline"
>
{t("records.conditions.editCta")}
</Link>
</p>
</>
)}
</div>
);
}
+34 -74
View File
@@ -3,14 +3,21 @@
/**
* v1.15.20 — Settings → AI "About me" panel (free text only).
* v1.16.0 — structured self-context: alongside the free text the user
* answers three short questions (chronic conditions, allergies /
* intolerances, what the Coach should watch). All four fields are
* encrypted at rest; age/gender stay on the profile and are merged
* into the prompt server-side — the panel says so instead of asking
* again.
* 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 all four fields (empty string clears)
* 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
@@ -54,7 +61,7 @@ interface AboutMeData {
fieldMaxChars: number;
}
type StructuredKey = "conditions" | "allergies" | "coachFocus";
type StructuredKey = "allergies";
async function fetchAboutMe(): Promise<AboutMeData> {
return apiGet<AboutMeData>("/api/coach/about-me");
@@ -72,10 +79,8 @@ export function AboutMeSection({
const queryClient = useQueryClient();
const [drafts, setDrafts] = useState<{
aboutMe: string | null;
conditions: string | null;
allergies: string | null;
coachFocus: string | null;
}>({ aboutMe: null, conditions: null, allergies: null, coachFocus: null });
}>({ aboutMe: null, allergies: null });
const query = useQuery({
queryKey: queryKeys.coachAboutMe(),
@@ -85,9 +90,7 @@ export function AboutMeSection({
const saved = {
aboutMe: query.data?.aboutMe ?? "",
conditions: query.data?.conditions ?? "",
allergies: query.data?.allergies ?? "",
coachFocus: query.data?.coachFocus ?? "",
};
const maxChars = query.data?.maxChars ?? FALLBACK_MAX_CHARS;
const fieldMaxChars = query.data?.fieldMaxChars ?? FALLBACK_FIELD_MAX_CHARS;
@@ -95,57 +98,33 @@ export function AboutMeSection({
const value = {
aboutMe: drafts.aboutMe ?? saved.aboutMe,
conditions: drafts.conditions ?? saved.conditions,
allergies: drafts.allergies ?? saved.allergies,
coachFocus: drafts.coachFocus ?? saved.coachFocus,
};
const dirty =
value.aboutMe !== saved.aboutMe ||
value.conditions !== saved.conditions ||
value.allergies !== saved.allergies ||
value.coachFocus !== saved.coachFocus;
const hasAnyContent =
saved.aboutMe.length > 0 ||
saved.conditions.length > 0 ||
saved.allergies.length > 0 ||
saved.coachFocus.length > 0;
value.aboutMe !== saved.aboutMe || value.allergies !== saved.allergies;
const hasAnyContent = saved.aboutMe.length > 0 || saved.allergies.length > 0;
// Quiet completeness meter: one segment per answered field. Reads
// the SAVED state (not the draft) so it only moves on persistence.
const answered = [
saved.conditions,
saved.allergies,
saved.coachFocus,
saved.aboutMe,
].filter((v) => v.length > 0).length;
const answered = [saved.allergies, saved.aboutMe].filter(
(v) => v.length > 0,
).length;
const save = useMutation({
mutationKey: queryKeys.coachAboutMe(),
mutationFn: async (input: {
aboutMe: string;
conditions: string;
allergies: string;
coachFocus: string;
}) => {
// Send only the two fields this panel owns. `conditions` / `coachFocus`
// are omitted so the server preserves them (they are edited under Anamnese).
mutationFn: async (input: { aboutMe: string; allergies: string }) => {
return apiPut<AboutMeData>("/api/coach/about-me", input);
},
onSuccess: (next) => {
const cleared =
!next.aboutMe &&
!next.conditions &&
!next.allergies &&
!next.coachFocus;
const cleared = !next.aboutMe && !next.allergies;
toast.success(
cleared
? t("settings.ai.aboutMe.clearedToast")
: t("settings.ai.aboutMe.savedToast"),
);
setDrafts({
aboutMe: null,
conditions: null,
allergies: null,
coachFocus: null,
});
setDrafts({ aboutMe: null, allergies: null });
queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
queryClient.invalidateQueries({
queryKey: queryKeys.coachAboutMeQuestions(),
@@ -195,19 +174,19 @@ export function AboutMeSection({
titleId="settings-ai-about-me-title"
description={t("settings.ai.aboutMe.description")}
/>
{/* Completeness — four quiet segments, no percentage shouting. */}
{/* Completeness — two quiet segments, no percentage shouting. */}
<div
className="flex items-center gap-1.5"
aria-label={t("settings.ai.aboutMe.completeness", {
answered,
total: 4,
total: 2,
})}
title={t("settings.ai.aboutMe.completeness", {
answered,
total: 4,
total: 2,
})}
>
{[0, 1, 2, 3].map((i) => (
{[0, 1].map((i) => (
<span
key={i}
aria-hidden="true"
@@ -220,24 +199,12 @@ export function AboutMeSection({
</div>
</div>
<div className="grid gap-4 pl-7 sm:grid-cols-2">
{structuredField(
"conditions",
t("settings.ai.aboutMe.conditionsLabel"),
t("settings.ai.aboutMe.conditionsPlaceholder"),
)}
<div className="pl-7">
{structuredField(
"allergies",
t("settings.ai.aboutMe.allergiesLabel"),
t("settings.ai.aboutMe.allergiesPlaceholder"),
)}
<div className="sm:col-span-2">
{structuredField(
"coachFocus",
t("settings.ai.aboutMe.focusLabel"),
t("settings.ai.aboutMe.focusPlaceholder"),
)}
</div>
</div>
<div className="space-y-1.5 pl-7">
@@ -277,14 +244,7 @@ export function AboutMeSection({
className="min-h-11 sm:min-h-9"
data-testid="settings-about-me-clear"
disabled={!isAuthenticated || save.isPending}
onClick={() =>
save.mutate({
aboutMe: "",
conditions: "",
allergies: "",
coachFocus: "",
})
}
onClick={() => save.mutate({ aboutMe: "", allergies: "" })}
>
<Trash2 className="size-4" aria-hidden />
{t("settings.ai.aboutMe.clear")}
@@ -351,9 +311,9 @@ export function AboutMeSection({
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
</p>
{/* v1.25.1 — cross-link to the Anamnese (medical-history) home. The
chronic conditions noted above plus allergies + family history read as
one coherent medical history there. */}
{/* v1.25.12 — cross-link to the Anamnese (medical-history) home, where the
chronic conditions the Coach watches now live alongside allergies +
family history as one coherent medical history. */}
<p className="text-muted-foreground pl-7 text-xs">
{t("settings.ai.aboutMe.recordsLink")}{" "}
<Link
@@ -9,12 +9,13 @@
* signal and not a clinical diagnosis — surfaced alongside the existing
* tracking-domain settings sections (Labs / Illness / Vorsorge).
*
* v1.25.1 — the section also surfaces the pre-existing / chronic conditions the
* Coach watches (the self-context entered under Profile → "About me") as a
* read-only shared view, so conditions + allergies + family history read as one
* coherent medical history. The conditions card is coach-gated (the data only
* feeds the Coach) and links back to its single editing home in personal
* context.
* v1.25.12 — the section is the single home for the pre-existing / chronic
* conditions the Coach watches, edited inline here so conditions + allergies +
* family history read (and write) as one coherent medical history. The
* conditions card is coach-gated (the data only feeds the Coach); it reads and
* writes the same self-context store (`/api/coach/about-me`) the rest of the app
* uses — the placement simply moved out of personal context into the medical
* record where it belongs.
*/
import {
@@ -28,7 +29,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
import { AllergyManager } from "@/components/records/allergy-manager";
import { ConditionsOverview } from "@/components/records/conditions-overview";
import { ConditionsManager } from "@/components/records/conditions-manager";
import { FamilyHistoryManager } from "@/components/records/family-history-manager";
export function AnamnesisSection() {
@@ -45,7 +46,7 @@ export function AnamnesisSection() {
</CardDescription>
</CardHeader>
<CardContent>
<ConditionsOverview />
<ConditionsManager />
</CardContent>
</Card>
)}