diff --git a/messages/de.json b/messages/de.json index c910cf994..00354fcff 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index f61d681eb..131892ed7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 7f4564f12..7d214697e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index dab67d9f9..440f668b6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index e4dcc3b21..613c4a58f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index d58a5999d..387558cd0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/prisma/migrations/0163_v1171_onboarding_goals/migration.sql b/prisma/migrations/0163_v1171_onboarding_goals/migration.sql new file mode 100644 index 000000000..2eba45587 --- /dev/null +++ b/prisma/migrations/0163_v1171_onboarding_goals/migration.sql @@ -0,0 +1,15 @@ +-- v1.17.1 — persist the onboarding goal selection. +-- +-- The GoalsChipPicker step let the user toggle goal slugs, but both +-- Skip and Next discarded the selection — the picker was decorative. +-- This adds the `onboarding_goals` text array the dead code already +-- referenced, so the selection can seed dashboard tile pinning and +-- reminder suggestions. +-- +-- Purely-additive: one new column on `users`, NOT NULL with a `'{}'` +-- (empty array) default, so every existing row keeps "no preference" +-- without a backfill pass. The column is a personalization seed, not a +-- clinical target store. + +ALTER TABLE "users" + ADD COLUMN "onboarding_goals" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 669592fd5..c95adf984 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -236,6 +236,15 @@ model User { // round-trip — the column stays at 4 for completed users) // Null = legacy pre-W14b user; the wizard treats null as 0 on mount. onboardingStep Int @default(0) @map("onboarding_step") + // v1.17.1 — goal slugs chosen during onboarding (GoalsChipPicker). + // Seeds dashboard tile pinning + reminder suggestions; NOT a clinical + // target store (targets are fully derived from height/age/gender + + // clinical refs in /api/insights/targets). Empty [] = skipped / no + // preference. The slug set is the closed enum + // `ONBOARDING_GOAL_SLUGS` in src/lib/onboarding/goals.ts; a String[] + // (not a relation) mirrors `insightsExcludeMetrics` precedent — the + // set is small and fixed, never queried relationally. + onboardingGoals String[] @default([]) @map("onboarding_goals") // v1.4.15 Phase B5: tracks whether the user has finished (or // explicitly dismissed) the spotlight tour that overlays the // dashboard on first arrival. Distinct from the wizard at diff --git a/src/app/api/onboarding/__tests__/step.test.ts b/src/app/api/onboarding/__tests__/step.test.ts index 0f8f3b06c..1700626a3 100644 --- a/src/app/api/onboarding/__tests__/step.test.ts +++ b/src/app/api/onboarding/__tests__/step.test.ts @@ -21,6 +21,7 @@ vi.mock("@/lib/db", () => ({ updateMany: vi.fn(), }, }, + toJson: (v: unknown) => v, })); vi.mock("@/lib/auth/session", () => ({ @@ -249,3 +250,147 @@ describe("POST /api/onboarding/step — rate limit", () => { expect(res.status).toBe(429); }); }); + +describe("POST /api/onboarding/step — goal persistence (v1.17.1)", () => { + it("persists the selected goal slugs on the step-2 submit", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + onboardingStep: 1, + onboardingCompletedAt: null, + } as never); + vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); + vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ + onboardingStep: 2, + onboardingCompletedAt: null, + } as never); + + const res = await POST( + req({ step: 2, goals: ["weight-management", "bp-tracking"] }), + ); + expect(res.status).toBe(200); + const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data; + expect(data).toMatchObject({ + onboardingStep: 2, + onboardingGoals: ["weight-management", "bp-tracking"], + }); + }); + + it("persists an explicit empty array on skip (no preference)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + onboardingStep: 1, + onboardingCompletedAt: null, + } as never); + vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); + vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ + onboardingStep: 2, + onboardingCompletedAt: null, + } as never); + + await POST(req({ step: 2, goals: [] })); + const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data; + expect(data).toMatchObject({ onboardingGoals: [] }); + }); + + it("rejects an unknown goal slug with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await POST(req({ step: 2, goals: ["not-a-real-goal"] })); + expect(res.status).toBe(422); + expect(prisma.user.updateMany).not.toHaveBeenCalled(); + }); + + it("does NOT write onboardingGoals when the field is omitted", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + onboardingStep: 1, + onboardingCompletedAt: null, + } as never); + vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); + vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ + onboardingStep: 2, + onboardingCompletedAt: null, + } as never); + + await POST(req({ step: 2 })); + const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data; + expect(data).not.toHaveProperty("onboardingGoals"); + }); +}); + +describe("POST /api/onboarding/step — dashboard seeding on completion", () => { + it("seeds the dashboard from stored goals when dashboardWidgetsJson is null", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // 1st findUnique = the fresh step-read; 2nd = the seed-read. + vi.mocked(prisma.user.findUnique) + .mockResolvedValueOnce({ + onboardingStep: 3, + onboardingCompletedAt: null, + } as never) + .mockResolvedValueOnce({ + onboardingGoals: ["glucose-tracking"], + dashboardWidgetsJson: null, + } as never); + // 1st updateMany = the step claim; 2nd = the gated seed write. + vi.mocked(prisma.user.updateMany) + .mockResolvedValueOnce({ count: 1 } as never) + .mockResolvedValueOnce({ count: 1 } as never); + vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ + onboardingStep: 4, + onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"), + } as never); + + const res = await POST(req({ step: 4 })); + expect(res.status).toBe(200); + // The second updateMany is the seed; its WHERE pins the null gate + // and its data carries a dashboard layout. + expect(prisma.user.updateMany).toHaveBeenCalledTimes(2); + const seedCall = vi.mocked(prisma.user.updateMany).mock.calls[1][0]; + expect(seedCall.data).toHaveProperty("dashboardWidgetsJson"); + expect(seedCall.where).toHaveProperty("dashboardWidgetsJson"); + }); + + it("does NOT seed when dashboardWidgetsJson is already set (gate holds)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique) + .mockResolvedValueOnce({ + onboardingStep: 3, + onboardingCompletedAt: null, + } as never) + .mockResolvedValueOnce({ + onboardingGoals: ["glucose-tracking"], + dashboardWidgetsJson: { version: 1, widgets: [] }, + } as never); + vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); + vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ + onboardingStep: 4, + onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"), + } as never); + + const res = await POST(req({ step: 4 })); + expect(res.status).toBe(200); + // Only the step-claim updateMany fired — the seed was gated out + // because the user already has a layout. + expect(prisma.user.updateMany).toHaveBeenCalledTimes(1); + }); + + it("does NOT seed when stored goals are empty (no preference)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique) + .mockResolvedValueOnce({ + onboardingStep: 3, + onboardingCompletedAt: null, + } as never) + .mockResolvedValueOnce({ + onboardingGoals: [], + dashboardWidgetsJson: null, + } as never); + vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); + vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({ + onboardingStep: 4, + onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"), + } as never); + + await POST(req({ step: 4 })); + expect(prisma.user.updateMany).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/onboarding/step/route.ts b/src/app/api/onboarding/step/route.ts index 50e73f716..0ef6ee0eb 100644 --- a/src/app/api/onboarding/step/route.ts +++ b/src/app/api/onboarding/step/route.ts @@ -10,9 +10,14 @@ import { } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { setOnboardingPendingCookie } from "@/lib/auth/session"; -import { prisma } from "@/lib/db"; +import { prisma, toJson } from "@/lib/db"; +import { Prisma } from "@/generated/prisma/client"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; +import { + ONBOARDING_GOAL_SLUGS, + buildGoalSeededDashboardLayout, +} from "@/lib/onboarding/goals"; /** * v1.4.25 W14b — POST /api/onboarding/step. @@ -42,6 +47,16 @@ import { checkRateLimit } from "@/lib/rate-limit"; const stepBodySchema = z.object({ step: z.number().int().min(1).max(4), + // v1.17.1 — optional goal slugs from the GoalsChipPicker (step 2 + // submit). Validated against the closed slug enum so an unknown slug + // 422s; deduped + capped at the full set size. Persisted to + // `User.onboardingGoals` (field-by-field, no mass assignment) and on + // completion (step 4) seeds the dashboard layout when the user never + // customized it. Omitted leaves the stored goals untouched. + goals: z + .array(z.enum(ONBOARDING_GOAL_SLUGS)) + .max(ONBOARDING_GOAL_SLUGS.length) + .optional(), }); export const POST = apiHandler(async (request: NextRequest) => { @@ -74,7 +89,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }); return apiError("Invalid step payload", 422); } - const { step } = parsed.data; + const { step, goals } = parsed.data; // Re-fetch the fresh User row so concurrent step submissions in // separate tabs can't race past each other — the session.user @@ -126,9 +141,14 @@ export const POST = apiHandler(async (request: NextRequest) => { onboardingCompletedAt: null, onboardingStep: { in: [current] }, }, + // Field-by-field assembly — no mass assignment. `goals` only + // lands when the client sent it (the step-2 submit); it is already + // validated against the closed slug enum by the Zod schema, so the + // array reaching Prisma can never contain an out-of-set value. data: { onboardingStep: step, ...(completing ? { onboardingCompletedAt: new Date() } : {}), + ...(goals !== undefined ? { onboardingGoals: goals } : {}), }, }); if (claimed.count !== 1) { @@ -149,11 +169,41 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); + let goalsSeeded = false; if (completing) { // Mirror /api/onboarding/complete — clear the proxy-readable // pending cookie so the next navigation drops the /onboarding // redirect immediately. await setOnboardingPendingCookie(false); + + // v1.17.1 — seed the dashboard from the stored goal selection. + // ONE-TIME, gated on `dashboardWidgetsJson == null` so a user who + // already arranged tiles is never clobbered. The seed promotes the + // goal-mapped tiles to the top and forces them visible; an empty / + // general-wellness-only selection produces null and leaves the + // column untouched (default layout). The resulting layout is what + // both the web dashboard and the iOS widgets contract read — + // server-authoritative, no client recompute. + const seedRow = await prisma.user.findUnique({ + where: { id: user.id }, + select: { onboardingGoals: true, dashboardWidgetsJson: true }, + }); + if (seedRow && seedRow.dashboardWidgetsJson == null) { + const seededLayout = buildGoalSeededDashboardLayout( + seedRow.onboardingGoals, + ); + if (seededLayout) { + // updateMany so the `dashboardWidgetsJson: null` precondition + // rides in the WHERE clause — a concurrent Settings → Dashboard + // save that lands first leaves `count = 0` and the seed is + // skipped rather than overwriting the user's fresh layout. + const seeded = await prisma.user.updateMany({ + where: { id: user.id, dashboardWidgetsJson: { equals: Prisma.JsonNull } }, + data: { dashboardWidgetsJson: toJson(seededLayout) }, + }); + goalsSeeded = seeded.count === 1; + } + } } await auditLog("onboarding.step", { @@ -167,7 +217,12 @@ export const POST = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "onboarding.step" }, - meta: { outcome: completing ? "completed" : "advanced", step }, + meta: { + outcome: completing ? "completed" : "advanced", + step, + ...(goals !== undefined ? { goals_count: goals.length } : {}), + ...(completing ? { goals_seeded: goalsSeeded } : {}), + }, }); return apiSuccess({ diff --git a/src/components/onboarding/AnamnesisCard.tsx b/src/components/onboarding/AnamnesisCard.tsx new file mode 100644 index 000000000..723d6f98e --- /dev/null +++ b/src/components/onboarding/AnamnesisCard.tsx @@ -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 | null { + const conditions = value.conditions.trim(); + const allergies = value.allergies.trim(); + if (!conditions && !allergies) return null; + const body: Record = { 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 ( +
+ + + {expanded ? ( +
+

+ {t("onboarding.anamnesis.intro")} +

+ +
+ +