feat(onboarding): add an optional anamnesis step on baseline

A collapsible "About your health" card on the baseline step captures
the genuinely-new baseline the app can act on — chronic conditions and
allergies — without re-asking the height/sex/birthdate already gathered
above. Both write through the existing encrypted self-context path
(PUT /api/coach/about-me), so there is no new model and no new
contract; the form preserves any existing free-text about-me on save
and only sends a field the user actually typed.

The card is collapsed by default with a clear optional framing, so the
step stays clear and the progress bar keeps its five dots; every field
is editable later in Settings. Copy is warm and medically grounded —
context, not a clinical form — with keys across all six locales. Also
re-truths the goals body copy now that the selection genuinely surfaces
on the dashboard.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 23:21:58 +02:00
1 parent 5f601426a9
commit c8cd5aa226
9 files changed
+363 -10

No files matched your search

+12 -1
View File
@@ -3648,7 +3648,7 @@
},
"goals": {
"title": "Was möchtest du erfassen?",
"body": "Wähle die Bereiche, die dir wichtig sind – das hilft dir zu entscheiden, was du verfolgen und als Nächstes einrichten willst.",
"body": "Wähle die Bereiche, die dir wichtig sind – wir zeigen sie zuerst auf deinem Dashboard an, und sie helfen dir zu entscheiden, was du als Nächstes einrichtest.",
"helpHint": "Ein bis drei Schwerpunkte funktionieren erfahrungsgemäß am besten – mehr geht aber jederzeit.",
"options": {
"weightManagement": "Gewicht im Griff",
@@ -3733,6 +3733,17 @@
"title": "Erkenne das Muster",
"body": "Tageszusammenfassungen, Trend-Diagramme und ein Coach, der zeigt was sich verändert hat — alles auf deinem Gerät."
}
},
"anamnesis": {
"title": "Zu deiner Gesundheit (optional)",
"subtitle": "Etwas Kontext, damit HealthLog deine Werte fairer einordnet.",
"intro": "Teile so viel oder so wenig, wie du möchtest – jedes Feld ist optional und jederzeit in den Einstellungen änderbar.",
"conditionsLabel": "Bestehende Erkrankungen",
"conditionsPlaceholder": "z. B. Bluthochdruck, Typ-2-Diabetes",
"conditionsHint": "Hilft HealthLog, deine Werte im Kontext einzuordnen, statt Erwartbares zu melden.",
"allergiesLabel": "Allergien oder Unverträglichkeiten",
"allergiesPlaceholder": "z. B. Penicillin, Laktose",
"privacyNote": "Verschlüsselt gespeichert und nur für besseren Kontext genutzt. Medikamente fragen wir hier nicht ab – die kannst du jederzeit auf der Medikamenten-Seite ergänzen."
}
},
"gettingStarted": {
+12 -1
View File
@@ -3648,7 +3648,7 @@
},
"goals": {
"title": "What would you like to track?",
"body": "Pick the areas you care about — it helps you decide what to track and set up next.",
"body": "Pick the areas you care about — we'll surface them first on your dashboard, and they help you decide what to set up next.",
"helpHint": "One to three priorities tend to work best — you can always add more later.",
"options": {
"weightManagement": "Weight management",
@@ -3733,6 +3733,17 @@
"title": "See the pattern",
"body": "Daily summaries, trend charts, and a coach that surfaces what changed — all on your own device."
}
},
"anamnesis": {
"title": "About your health (optional)",
"subtitle": "A little context so HealthLog reads your numbers more fairly.",
"intro": "Share as much or as little as you like — every field is optional and you can edit it anytime in Settings.",
"conditionsLabel": "Ongoing conditions",
"conditionsPlaceholder": "e.g. high blood pressure, type 2 diabetes",
"conditionsHint": "Helps HealthLog frame your readings in context rather than flagging the expected.",
"allergiesLabel": "Allergies or intolerances",
"allergiesPlaceholder": "e.g. penicillin, lactose",
"privacyNote": "Stored encrypted and used only to give you better context. We don't ask for your medications here — add those anytime from the Medications page."
}
},
"gettingStarted": {
+12 -1
View File
@@ -3648,7 +3648,7 @@
},
"goals": {
"title": "¿Qué te gustaría registrar?",
"body": "Elige las áreas que te interesan: te ayuda a decidir qué seguir y qué configurar a continuación.",
"body": "Elige las áreas que te interesan: las mostraremos primero en tu panel y te ayudarán a decidir qué configurar a continuación.",
"helpHint": "Una a tres prioridades suelen funcionar mejor — siempre podrás añadir más más adelante.",
"options": {
"weightManagement": "Control del peso",
@@ -3733,6 +3733,17 @@
"title": "Descubre los patrones",
"body": "Resúmenes diarios, gráficas de tendencias y un coach que destaca lo que cambió — todo en tu propio dispositivo."
}
},
"anamnesis": {
"title": "Sobre tu salud (opcional)",
"subtitle": "Un poco de contexto para que HealthLog interprete tus valores de forma más justa.",
"intro": "Comparte lo que quieras: cada campo es opcional y puedes editarlo cuando quieras en Ajustes.",
"conditionsLabel": "Afecciones actuales",
"conditionsPlaceholder": "p. ej. hipertensión, diabetes tipo 2",
"conditionsHint": "Ayuda a HealthLog a interpretar tus lecturas en contexto en lugar de marcar lo esperado.",
"allergiesLabel": "Alergias o intolerancias",
"allergiesPlaceholder": "p. ej. penicilina, lactosa",
"privacyNote": "Se almacena cifrado y solo se usa para darte mejor contexto. Aquí no te pedimos tu medicación: puedes añadirla cuando quieras desde la página de Medicación."
}
},
"gettingStarted": {
+12 -1
View File
@@ -3648,7 +3648,7 @@
},
"goals": {
"title": "Que souhaitez-vous suivre ?",
"body": "Sélectionnez les domaines qui comptent pour vous : cela vous aide à décider quoi suivre et configurer ensuite.",
"body": "Sélectionnez les domaines qui comptent pour vous : nous les afficherons en premier sur votre tableau de bord, et ils vous aident à décider quoi configurer ensuite.",
"helpHint": "Un à trois axes prioritaires fonctionnent généralement le mieux, mais vous pourrez en ajouter plus tard.",
"options": {
"weightManagement": "Gestion du poids",
@@ -3733,6 +3733,17 @@
"title": "Repérez les tendances",
"body": "Résumés quotidiens, graphiques de tendances et un coach qui met en avant ce qui a changé — le tout sur votre appareil."
}
},
"anamnesis": {
"title": "À propos de votre santé (facultatif)",
"subtitle": "Un peu de contexte pour que HealthLog interprète vos valeurs plus justement.",
"intro": "Partagez autant ou aussi peu que vous le souhaitez : chaque champ est facultatif et modifiable à tout moment dans les Réglages.",
"conditionsLabel": "Affections en cours",
"conditionsPlaceholder": "p. ex. hypertension, diabète de type 2",
"conditionsHint": "Aide HealthLog à replacer vos mesures dans leur contexte plutôt que de signaler l'attendu.",
"allergiesLabel": "Allergies ou intolérances",
"allergiesPlaceholder": "p. ex. pénicilline, lactose",
"privacyNote": "Stocké chiffré et utilisé uniquement pour un meilleur contexte. Nous ne demandons pas vos médicaments ici — ajoutez-les quand vous voulez depuis la page Médicaments."
}
},
"gettingStarted": {
+12 -1
View File
@@ -3648,7 +3648,7 @@
},
"goals": {
"title": "Cosa vuoi monitorare?",
"body": "Scegli gli ambiti che ti interessano: ti aiuta a decidere cosa monitorare e configurare in seguito.",
"body": "Scegli gli ambiti che ti interessano: li mostreremo per primi nella tua dashboard e ti aiutano a decidere cosa configurare in seguito.",
"helpHint": "Da una a tre priorità funzionano di solito meglio — potrai aggiungerne altre in qualsiasi momento.",
"options": {
"weightManagement": "Gestione del peso",
@@ -3733,6 +3733,17 @@
"title": "Scopri lo schema",
"body": "Riepiloghi giornalieri, grafici di tendenza e un coach che evidenzia i cambiamenti — tutto sul tuo dispositivo."
}
},
"anamnesis": {
"title": "Sulla tua salute (facoltativo)",
"subtitle": "Un po' di contesto perché HealthLog interpreti i tuoi valori in modo più equo.",
"intro": "Condividi quanto vuoi: ogni campo è facoltativo e modificabile in qualsiasi momento nelle Impostazioni.",
"conditionsLabel": "Condizioni in corso",
"conditionsPlaceholder": "es. ipertensione, diabete di tipo 2",
"conditionsHint": "Aiuta HealthLog a inquadrare le tue misurazioni nel contesto invece di segnalare l'atteso.",
"allergiesLabel": "Allergie o intolleranze",
"allergiesPlaceholder": "es. penicillina, lattosio",
"privacyNote": "Memorizzato cifrato e usato solo per darti un contesto migliore. Qui non chiediamo i tuoi farmaci: aggiungili quando vuoi dalla pagina Farmaci."
}
},
"gettingStarted": {
+12 -1
View File
@@ -3648,7 +3648,7 @@
},
"goals": {
"title": "Co chcesz śledzić?",
"body": "Wybierz obszary, które są dla Ciebie ważne — to pomaga zdecydować, co śledzić i co skonfigurować w następnej kolejności.",
"body": "Wybierz obszary, które są dla Ciebie ważne — pokażemy je najpierw na pulpicie i pomogą Ci zdecydować, co skonfigurować dalej.",
"helpHint": "Od jednego do trzech priorytetów zwykle sprawdza się najlepiej — możesz dodać kolejne w dowolnej chwili.",
"options": {
"weightManagement": "Kontrola wagi",
@@ -3733,6 +3733,17 @@
"title": "Zobacz wzorce",
"body": "Codzienne podsumowania, wykresy trendów i coach, który wskazuje co się zmieniło — wszystko na Twoim urządzeniu."
}
},
"anamnesis": {
"title": "O Twoim zdrowiu (opcjonalnie)",
"subtitle": "Trochę kontekstu, aby HealthLog rzetelniej odczytywał Twoje wyniki.",
"intro": "Podziel się tym, czym chcesz — każde pole jest opcjonalne i możesz je zmienić w dowolnej chwili w Ustawieniach.",
"conditionsLabel": "Aktualne schorzenia",
"conditionsPlaceholder": "np. nadciśnienie, cukrzyca typu 2",
"conditionsHint": "Pomaga HealthLog ujmować Twoje pomiary w kontekście, zamiast oznaczać to, czego należy się spodziewać.",
"allergiesLabel": "Alergie lub nietolerancje",
"allergiesPlaceholder": "np. penicylina, laktoza",
"privacyNote": "Przechowywane w postaci zaszyfrowanej i używane tylko po to, by dać Ci lepszy kontekst. Nie pytamy tu o leki — dodasz je w dowolnej chwili na stronie Leki."
}
},
"gettingStarted": {
+155
View File
@@ -0,0 +1,155 @@
"use client";
import { useId, useState } from "react";
import { ChevronDown, HeartHandshake } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* v1.17.1 — optional "About your health" card on the onboarding
* Baseline step (3).
*
* Captures the genuinely-new baseline the app can act on — chronic
* conditions and allergies / intolerances — that the height/sex/dob
* fields above do NOT already cover. Both write through the existing
* encrypted self-context path (`PUT /api/coach/about-me`), so there is
* no new model and no new contract; the parent BaselineForm preserves
* any existing free-text `aboutMe` on save.
*
* Collapsed by default with a prominent skip framing — the step stays
* übersichtlich and every field is optional and editable later in
* Settings → AI. Warm, medically-grounded copy: framed as "so
* HealthLog reads your numbers in context," never as a clinical form.
*
* Controlled: the parent owns the values so it can persist them in the
* same submit as the profile fields.
*/
export interface AnamnesisValue {
conditions: string;
allergies: string;
}
/**
* Build the `PUT /api/coach/about-me` body for the anamnesis answers,
* or `null` when the card was left untouched (so an untouched collapsed
* card never round-trips). Preserves the existing free-text `aboutMe`
* (the PUT schema requires it and an empty value clears it) and only
* includes conditions / allergies when the user typed something — so an
* unrelated stored value is never cleared. Field-by-field assembly: no
* mass-spread of the value object into the body.
*/
export function buildAnamnesisAboutMeBody(
baseAboutMe: string,
value: AnamnesisValue,
): Record<string, unknown> | null {
const conditions = value.conditions.trim();
const allergies = value.allergies.trim();
if (!conditions && !allergies) return null;
const body: Record<string, unknown> = { aboutMe: baseAboutMe };
if (conditions) body.conditions = conditions;
if (allergies) body.allergies = allergies;
return body;
}
export interface AnamnesisCardProps {
value: AnamnesisValue;
onChange: (next: AnamnesisValue) => void;
disabled?: boolean;
}
/** Per-field cap mirrors ABOUT_ME_FIELD_MAX_CHARS on the server. */
const FIELD_MAX = 500;
export function AnamnesisCard({ value, onChange, disabled }: AnamnesisCardProps) {
const { t } = useTranslations();
const [expanded, setExpanded] = useState(false);
const conditionsId = useId();
const allergiesId = useId();
const panelId = useId();
return (
<div className="bg-card border-border rounded-xl border">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-controls={panelId}
className="flex w-full items-center gap-3 rounded-xl p-5 text-left"
>
<span
aria-hidden="true"
className="bg-muted text-muted-foreground flex size-9 shrink-0 items-center justify-center rounded-full"
>
<HeartHandshake className="size-4" />
</span>
<span className="min-w-0 flex-1 space-y-0.5">
<span className="text-foreground block text-sm font-medium">
{t("onboarding.anamnesis.title")}
</span>
<span className="text-muted-foreground block text-xs leading-relaxed">
{t("onboarding.anamnesis.subtitle")}
</span>
</span>
<ChevronDown
aria-hidden="true"
className={cn(
"text-muted-foreground size-4 shrink-0 transition-transform duration-150 ease-out motion-reduce:transition-none",
expanded && "rotate-180",
)}
/>
</button>
{expanded ? (
<div id={panelId} className="space-y-5 px-5 pb-5">
<p className="text-muted-foreground text-xs leading-relaxed">
{t("onboarding.anamnesis.intro")}
</p>
<div className="space-y-2">
<Label htmlFor={conditionsId}>
{t("onboarding.anamnesis.conditionsLabel")}
</Label>
<Textarea
id={conditionsId}
value={value.conditions}
onChange={(e) =>
onChange({ ...value, conditions: e.target.value })
}
disabled={disabled}
maxLength={FIELD_MAX}
rows={2}
placeholder={t("onboarding.anamnesis.conditionsPlaceholder")}
/>
<p className="text-muted-foreground text-xs">
{t("onboarding.anamnesis.conditionsHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={allergiesId}>
{t("onboarding.anamnesis.allergiesLabel")}
</Label>
<Textarea
id={allergiesId}
value={value.allergies}
onChange={(e) =>
onChange({ ...value, allergies: e.target.value })
}
disabled={disabled}
maxLength={FIELD_MAX}
rows={2}
placeholder={t("onboarding.anamnesis.allergiesPlaceholder")}
/>
</div>
<p className="text-muted-foreground text-xs leading-relaxed">
{t("onboarding.anamnesis.privacyNote")}
</p>
</div>
) : null}
</div>
);
}
+57 -4
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
@@ -19,7 +19,12 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useTranslations } from "@/lib/i18n/context";
import { apiPost, apiPut } from "@/lib/api/api-fetch";
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import {
AnamnesisCard,
buildAnamnesisAboutMeBody,
type AnamnesisValue,
} from "@/components/onboarding/AnamnesisCard";
/**
* v1.4.25 W14b-Content — onboarding step 3 (baseline).
@@ -35,9 +40,12 @@ import { apiPost, apiPut } from "@/lib/api/api-fetch";
*
* Submit flow on "Save and continue":
* 1. PUT profile (best-effort — empty fields are skipped).
* 2. POST step:4 — flips `onboardingCompletedAt` server-side and
* 2. PUT /api/coach/about-me — only when the optional anamnesis card
* (v1.17.1) was filled; preserves any existing `aboutMe` and
* writes conditions / allergies encrypted at rest.
* 3. POST step:4 — flips `onboardingCompletedAt` server-side and
* clears the proxy cookie.
* 3. router.push("/onboarding/4") — the done screen.
* 4. router.push("/onboarding/4") — the done screen.
*
* "Skip" advances without writing profile data; the wizard still
* completes onboarding (the user can fill profile later from
@@ -66,6 +74,37 @@ export function BaselineForm() {
const [form, setForm] = useState<BaselineFormState>(EMPTY_FORM);
const [saving, setSaving] = useState(false);
// v1.17.1 — optional anamnesis (conditions + allergies). Persisted
// through the existing encrypted self-context path
// (`PUT /api/coach/about-me`). We read the current self-context once
// so a returning/resuming user's free-text `aboutMe` is preserved on
// save (the PUT schema requires `aboutMe`, and an empty value clears
// it). A fresh user has no self-context, so `baseAboutMe` stays "".
const [anamnesis, setAnamnesis] = useState<AnamnesisValue>({
conditions: "",
allergies: "",
});
const [baseAboutMe, setBaseAboutMe] = useState("");
useEffect(() => {
let active = true;
void apiGet<{ aboutMe: string | null }>("/api/coach/about-me")
.then((ctx) => {
if (active && typeof ctx.aboutMe === "string") {
setBaseAboutMe(ctx.aboutMe);
}
})
.catch(() => {
// Non-fatal — onboarding must never block on the self-context
// read. A fresh user has none; on error we keep the "" base,
// which the PUT below only sends when the user actually typed
// an anamnesis answer.
});
return () => {
active = false;
};
}, []);
function patch<K extends keyof BaselineFormState>(
key: K,
value: BaselineFormState[K],
@@ -92,6 +131,14 @@ export function BaselineForm() {
if (Object.keys(profileBody).length > 0) {
await apiPut("/api/auth/profile", profileBody);
}
// Anamnesis — only write when the user actually filled a field
// (the helper returns null for an untouched card, so a
// collapsed card never round-trips).
const aboutMeBody = buildAnamnesisAboutMeBody(baseAboutMe, anamnesis);
if (aboutMeBody) {
await apiPut("/api/coach/about-me", aboutMeBody);
}
}
await apiPost("/api/onboarding/step", { step: 4 });
await queryClient.invalidateQueries({ queryKey: ["auth"] });
@@ -210,6 +257,12 @@ export function BaselineForm() {
</div>
</fieldset>
<AnamnesisCard
value={anamnesis}
onChange={setAnamnesis}
disabled={saving}
/>
<div className="flex items-center justify-between gap-2 pt-2">
<Button asChild variant="ghost" className="min-h-11 min-w-11">
<Link href="/onboarding/2">{t("onboarding.shell.back")}</Link>
@@ -0,0 +1,79 @@
/**
* v1.17.1 — optional onboarding anamnesis card.
*
* Vitest runs in the Node environment here (no jsdom), so the
* expand/collapse interaction is left to e2e. This file locks the two
* contracts that matter for correctness:
* 1. the about-me PUT body builder (what actually persists), and
* 2. the collapsed SSR shape + i18n wiring.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
AnamnesisCard,
buildAnamnesisAboutMeBody,
} from "../AnamnesisCard";
describe("buildAnamnesisAboutMeBody", () => {
const empty = { conditions: "", allergies: "" };
it("returns null when both fields are blank (untouched card)", () => {
expect(buildAnamnesisAboutMeBody("", empty)).toBeNull();
expect(
buildAnamnesisAboutMeBody("", { conditions: " ", allergies: "\n" }),
).toBeNull();
});
it("preserves the existing aboutMe so it is never cleared", () => {
const body = buildAnamnesisAboutMeBody("I'm an athlete.", {
conditions: "Hypertension",
allergies: "",
});
expect(body).toMatchObject({
aboutMe: "I'm an athlete.",
conditions: "Hypertension",
});
// Allergies omitted (blank) so the stored value isn't cleared.
expect(body).not.toHaveProperty("allergies");
});
it("includes only the fields the user actually filled (no mass spread)", () => {
const body = buildAnamnesisAboutMeBody("", {
conditions: " Type 2 diabetes ",
allergies: " Penicillin ",
});
expect(body).toEqual({
aboutMe: "",
conditions: "Type 2 diabetes",
allergies: "Penicillin",
});
});
});
describe("<AnamnesisCard> (collapsed SSR)", () => {
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AnamnesisCard
value={{ conditions: "", allergies: "" }}
onChange={vi.fn()}
/>
</I18nProvider>,
);
}
it("renders the title + a collapsed (aria-expanded=false) toggle", () => {
const html = render();
expect(html).toContain("About your health (optional)");
expect(html).toContain('aria-expanded="false"');
});
it("keeps the questions hidden until expanded", () => {
const html = render();
// The conditions textarea label only renders inside the expanded
// panel; collapsed SSR must not include it.
expect(html).not.toContain("Ongoing conditions");
});
});