From 5b756f01f23e64528dcdf0474a3e69b1172711d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:21:39 +0200 Subject: [PATCH 1/3] feat(onboarding): add User.onboardingGoals for goal persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The goals step let the user toggle goal slugs but both Skip and Next discarded the selection — the picker was decorative. Add the onboarding_goals text array the earlier dead code already referenced so the selection can persist and seed dashboard tiles + reminder suggestions. Not a clinical target store; targets stay fully derived from height/age/gender in /api/insights/targets. Empty array = no preference; purely-additive migration with an empty-array default, no backfill. --- .../0163_v1171_onboarding_goals/migration.sql | 15 +++++++++++++++ prisma/schema.prisma | 9 +++++++++ 2 files changed, 24 insertions(+) create mode 100644 prisma/migrations/0163_v1171_onboarding_goals/migration.sql 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 715611d14..7ca1c51e8 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 From 5f601426a97eb98fcd0cb0a2d6315bac1419f2c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:21:48 +0200 Subject: [PATCH 2/3] feat(onboarding): persist goal selection and seed the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the goals step to actually keep its promise. The picker now posts the chosen slugs on the step-2 submit; the step endpoint validates them against the closed slug enum (422 on an unknown slug), persists them to User.onboardingGoals field-by-field, and on completion seeds dashboardWidgetsJson from the selection — promoting the goal-mapped tiles to the top and forcing them visible. The seed is one-time, gated on a null dashboardWidgetsJson via the updateMany WHERE clause, so a user who already arranged tiles is never clobbered. An empty or general-wellness-only selection leaves the column untouched (default layout). The resulting layout is what both the web dashboard and the iOS widgets contract read — no client recompute. Skip posts an explicit empty array (deliberate no preference). --- src/app/api/onboarding/__tests__/step.test.ts | 145 ++++++++++++++++++ src/app/api/onboarding/step/route.ts | 61 +++++++- src/components/onboarding/GoalsChipPicker.tsx | 28 ++-- src/lib/onboarding/__tests__/goals.test.ts | 104 +++++++++++++ src/lib/onboarding/goals.ts | 108 +++++++++++++ 5 files changed, 431 insertions(+), 15 deletions(-) create mode 100644 src/lib/onboarding/__tests__/goals.test.ts create mode 100644 src/lib/onboarding/goals.ts 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/GoalsChipPicker.tsx b/src/components/onboarding/GoalsChipPicker.tsx index 63708d0b3..488acd625 100644 --- a/src/components/onboarding/GoalsChipPicker.tsx +++ b/src/components/onboarding/GoalsChipPicker.tsx @@ -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) { diff --git a/src/lib/onboarding/__tests__/goals.test.ts b/src/lib/onboarding/__tests__/goals.test.ts new file mode 100644 index 000000000..039be8b02 --- /dev/null +++ b/src/lib/onboarding/__tests__/goals.test.ts @@ -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( + 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)); + }); +}); diff --git a/src/lib/onboarding/goals.ts b/src/lib/onboarding/goals.ts new file mode 100644 index 000000000..4a2fc7eb6 --- /dev/null +++ b/src/lib/onboarding/goals.ts @@ -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(); + 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, + }); +} From c8cd5aa22602a78f764d07d5c6a9be49ceb2e120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:21:58 +0200 Subject: [PATCH 3/3] feat(onboarding): add an optional anamnesis step on baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- messages/de.json | 13 +- messages/en.json | 13 +- messages/es.json | 13 +- messages/fr.json | 13 +- messages/it.json | 13 +- messages/pl.json | 13 +- src/components/onboarding/AnamnesisCard.tsx | 155 ++++++++++++++++++ src/components/onboarding/BaselineForm.tsx | 61 ++++++- .../__tests__/anamnesis-card.test.tsx | 79 +++++++++ 9 files changed, 363 insertions(+), 10 deletions(-) create mode 100644 src/components/onboarding/AnamnesisCard.tsx create mode 100644 src/components/onboarding/__tests__/anamnesis-card.test.tsx diff --git a/messages/de.json b/messages/de.json index 01784ec8b..3f3db97aa 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 a78cc1a24..67925684e 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 07ef722cb..2a34af1a6 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 acec48c77..62b8a5f10 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 e825a1770..b65f81c21 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 b551d7b0b..22ee2065f 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/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")} +

+ +
+ +