merge(onboarding): persist goals to seed the dashboard, add anamnesis step

This commit is contained in:
Marc-André Bombeck committed 2026-06-14 23:22:42 +02:00
commit 1e28f9cfb8
16 files changed
+818 -25

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": {
@@ -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[];
+9
View File
@@ -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
@@ -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);
});
});
+58 -3
View File
@@ -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({
+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>
+16 -12
View File
@@ -24,22 +24,19 @@ import { apiPost } from "@/lib/api/api-fetch";
* v1.4.25 W14b-Content — onboarding step 1 (goals).
*
* Multi-select chip picker for the metrics the user wants to track.
* The goal slugs match a stable enum that v1.4.26 will land on the
* `User` row (`User.onboardingGoals`). Until that column ships the
* picker holds selection in component state only — there's no
* persistence layer to drop into. The earlier localStorage block
* had no consumer (no other surface reads the same key) and the
* single-step wizard makes pre-selection on resume unnecessary, so
* leaning on component state until the column lands keeps the code
* surface honest.
* The goal slugs are the closed enum `ONBOARDING_GOAL_SLUGS`
* (`src/lib/onboarding/goals.ts`). Selection lives in component state
* while the step is open; on advance it is persisted server-side to
* `User.onboardingGoals` (v1.17.1), which seeds the dashboard tile
* layout on completion and feeds reminder suggestions.
*
* CTAs:
* - Back → `/onboarding/0`
* - Skip → advance with empty goal set
* - Skip → advance with empty goal set (explicit "no preference")
* - Next → advance with chosen goal set
*
* Both Skip and Next call POST `/api/onboarding/step` with `{ step: 2 }`
* and `router.push("/onboarding/2")`.
* Both Skip and Next call POST `/api/onboarding/step` with
* `{ step: 2, goals }` and `router.push("/onboarding/2")`.
*/
export type OnboardingGoalSlug =
@@ -122,7 +119,14 @@ export function GoalsChipPicker({ userId: _userId }: GoalsChipPickerProps) {
if (advancing) return;
setAdvancing(true);
try {
await apiPost("/api/onboarding/step", { step: 2 });
// v1.17.1 — persist the chosen goal slugs alongside the step
// write. Skip sends an empty array (explicit "no preference"),
// not omission, so a resume can read the deliberate empty choice.
// The server seeds the dashboard from these on completion.
await apiPost("/api/onboarding/step", {
step: 2,
goals: Array.from(selected),
});
await queryClient.invalidateQueries({ queryKey: ["auth"] });
router.push("/onboarding/2");
} catch (err) {
@@ -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");
});
});
+104
View File
@@ -0,0 +1,104 @@
/**
* v1.17.1 — onboarding goal slug enum + dashboard seeding.
*
* Goals are a personalization seed, not a clinical target store: the
* only leverage they have is which dashboard tiles get promoted and
* made visible. These tests pin (a) the closed slug guard and (b) the
* one-time seed builder's promote/forces-visible/no-op contracts.
*/
import { describe, it, expect } from "vitest";
import {
ONBOARDING_GOAL_SLUGS,
GOAL_WIDGET_SEED_MAP,
isOnboardingGoalSlug,
buildGoalSeededDashboardLayout,
} from "../goals";
import {
DEFAULT_DASHBOARD_LAYOUT,
type DashboardWidgetId,
} from "@/lib/dashboard-layout";
describe("isOnboardingGoalSlug", () => {
it("accepts every member of the closed slug set", () => {
for (const slug of ONBOARDING_GOAL_SLUGS) {
expect(isOnboardingGoalSlug(slug)).toBe(true);
}
});
it("rejects unknown / non-string values", () => {
expect(isOnboardingGoalSlug("not-a-goal")).toBe(false);
expect(isOnboardingGoalSlug("")).toBe(false);
expect(isOnboardingGoalSlug(null)).toBe(false);
expect(isOnboardingGoalSlug(42)).toBe(false);
});
});
describe("GOAL_WIDGET_SEED_MAP", () => {
it("only maps to ids the dashboard layout actually knows", () => {
const known = new Set<DashboardWidgetId>(
DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id as DashboardWidgetId),
);
for (const ids of Object.values(GOAL_WIDGET_SEED_MAP)) {
for (const id of ids) expect(known.has(id)).toBe(true);
}
});
it("leaves general-wellness without a tile preference", () => {
expect(GOAL_WIDGET_SEED_MAP["general-wellness"]).toEqual([]);
});
});
describe("buildGoalSeededDashboardLayout", () => {
it("returns null for an empty selection (no seed)", () => {
expect(buildGoalSeededDashboardLayout([])).toBeNull();
});
it("returns null for a general-wellness-only selection", () => {
expect(buildGoalSeededDashboardLayout(["general-wellness"])).toBeNull();
});
it("ignores unknown slugs and seeds nothing when only unknowns are passed", () => {
expect(buildGoalSeededDashboardLayout(["bogus", "nope"])).toBeNull();
});
it("promotes glucose to the top and forces it visible", () => {
const layout = buildGoalSeededDashboardLayout(["glucose-tracking"]);
expect(layout).not.toBeNull();
const widgets = layout!.widgets;
// glucose is default-invisible + ordered low; the seed lifts it to
// order 0 and turns both surfaces on.
const glucose = widgets.find((w) => w.id === "glucose");
expect(glucose).toMatchObject({
order: 0,
visible: true,
tileVisible: true,
});
});
it("promotes every mapped tile for a multi-goal selection", () => {
const layout = buildGoalSeededDashboardLayout([
"weight-management",
"bp-tracking",
]);
expect(layout).not.toBeNull();
const widgets = layout!.widgets;
const promotedIds = new Set(["weight", "bodyFat", "bp", "bpInTarget"]);
// Every promoted id sits in the leading block and is visible.
for (const id of promotedIds) {
const w = widgets.find((x) => x.id === id);
expect(w?.visible).toBe(true);
expect(w?.tileVisible).toBe(true);
expect(w!.order).toBeLessThan(promotedIds.size);
}
});
it("keeps a dense, complete widget set (no tiles dropped)", () => {
const layout = buildGoalSeededDashboardLayout(["sleep-improvement"]);
expect(layout!.widgets.length).toBe(
DEFAULT_DASHBOARD_LAYOUT.widgets.length,
);
const orders = layout!.widgets.map((w) => w.order).sort((a, b) => a - b);
expect(orders).toEqual(orders.map((_, i) => i));
});
});
+108
View File
@@ -0,0 +1,108 @@
/**
* v1.17.1 — onboarding goal slugs + dashboard seeding.
*
* The closed set of goal slugs the GoalsChipPicker offers, plus the
* server-authoritative seeding that turns a goal selection into a
* personalization signal: which dashboard tiles get promoted to the
* top and made visible. Goals are NOT a clinical target store — the
* targets surface (`/api/insights/targets`) is fully derived from
* height/age/gender + clinical references. Their only leverage is
* (a) dashboard tile pinning (here) and (b) reminder suggestions
* (read by the reminders panel from `User.onboardingGoals`).
*
* One home for the slug enum so the API validation
* (`/api/onboarding/step`), the client picker, and the seeding all
* read the same source — same-key drift was the v1.4.16 dashboard-enum
* bug class.
*/
import {
DEFAULT_DASHBOARD_LAYOUT,
serializeDashboardLayout,
type DashboardLayout,
type DashboardWidgetId,
} from "@/lib/dashboard-layout";
/** Closed slug set — mirrors `OnboardingGoalSlug` in GoalsChipPicker. */
export const ONBOARDING_GOAL_SLUGS = [
"weight-management",
"bp-tracking",
"glucose-tracking",
"sleep-improvement",
"medication-compliance",
"general-wellness",
] as const;
export type OnboardingGoalSlug = (typeof ONBOARDING_GOAL_SLUGS)[number];
export function isOnboardingGoalSlug(
value: unknown,
): value is OnboardingGoalSlug {
return (
typeof value === "string" &&
(ONBOARDING_GOAL_SLUGS as readonly string[]).includes(value)
);
}
/**
* Slug → dashboard widget ids to promote. `general-wellness` maps to
* nothing — it carries no tile preference, so a general-wellness-only
* selection leaves the default layout untouched. Ids must be members
* of `DASHBOARD_WIDGET_IDS`; the seed never invents an id.
*/
export const GOAL_WIDGET_SEED_MAP: Record<
OnboardingGoalSlug,
readonly DashboardWidgetId[]
> = {
"weight-management": ["weight", "bodyFat"],
"bp-tracking": ["bp", "bpInTarget"],
"glucose-tracking": ["glucose"],
"sleep-improvement": ["sleep"],
"medication-compliance": ["medications"],
"general-wellness": [],
};
/**
* Build the seeded dashboard layout for a goal selection.
*
* Returns a layout where the goal-mapped tiles are promoted to the top
* of the order (preserving their relative order within the default
* layout) and forced visible on both surfaces; every other tile keeps
* its default config, shifted down. Returns `null` when there is
* nothing to promote (empty selection or general-wellness only) so the
* caller can skip the write entirely and leave the column null →
* default layout.
*
* This is a ONE-TIME seed: the caller MUST gate it on
* `User.dashboardWidgetsJson == null` so it never clobbers a user who
* already arranged tiles.
*/
export function buildGoalSeededDashboardLayout(
goals: readonly string[] | null | undefined,
): DashboardLayout | null {
if (!goals || goals.length === 0) return null;
const promoted = new Set<DashboardWidgetId>();
for (const slug of goals) {
if (!isOnboardingGoalSlug(slug)) continue;
for (const id of GOAL_WIDGET_SEED_MAP[slug]) promoted.add(id);
}
if (promoted.size === 0) return null;
const base = DEFAULT_DASHBOARD_LAYOUT.widgets;
const promotedWidgets = base.filter((w) => promoted.has(w.id as DashboardWidgetId));
const rest = base.filter((w) => !promoted.has(w.id as DashboardWidgetId));
const ordered = [...promotedWidgets, ...rest].map((w, index) => ({
...w,
// Force the goal-mapped tiles visible on both surfaces; leave the
// rest exactly as the default layout had them.
...(promoted.has(w.id as DashboardWidgetId)
? { visible: true, tileVisible: true }
: {}),
order: index,
}));
return serializeDashboardLayout({
...DEFAULT_DASHBOARD_LAYOUT,
widgets: ordered,
});
}