From 58bc91a0a398ddb2e9502bdc044b4c00aa48e2b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 15:19:48 +0200 Subject: [PATCH] Add the setup flow's confirm screen What the answers switch on is read off the registry's derivation and never computed here, so the list the person confirms is the list the completion route writes. Units, language and time zone are shown once beside it, changeable in Settings, never asked again. The profile half keeps the baseline form and its per-field refusals: the fields are seeded from the account so a value it holds is confirmed rather than typed again, and a refused field still stops the flow. For "someone I look after" the managed-profile form takes the profile's place, because that record is created here, after the questions; an account without a second factor can finish without it and create it later under Settings. Confirming posts the completion, passes the first-result step outright when there is no task to offer, and goes where the step machine says. --- messages/en.json | 21 ++ src/app/onboarding/[step]/page.tsx | 4 + src/components/onboarding/baseline-form.tsx | 125 +++++----- src/components/onboarding/confirm-screen.tsx | 221 ++++++++++++++++++ .../__tests__/height-entry-surfaces.test.tsx | 7 +- .../access/managed-profile-create-form.tsx | 18 +- .../__tests__/confirm-summary.test.ts | 68 ++++++ src/lib/onboarding/confirm-summary.ts | 52 +++++ 8 files changed, 459 insertions(+), 57 deletions(-) create mode 100644 src/components/onboarding/confirm-screen.tsx create mode 100644 src/lib/onboarding/__tests__/confirm-summary.test.ts create mode 100644 src/lib/onboarding/confirm-summary.ts diff --git a/messages/en.json b/messages/en.json index 551ea6af8..caa8d7be3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4719,6 +4719,27 @@ "metric": "kg and cm", "imperial": "lb and ft/in" } + }, + "confirm": { + "title": "Here is what your answers switch on", + "body": "One look, then HealthLog is set up. Nothing here is final.", + "modulesTitle": "Modules", + "switchedOn": "Switched on: {modules}.", + "nothingExtra": "Your answers switch on no extra module; the core readings are always there.", + "alwaysOn": "Always on: {modules}, and the core readings (weight, blood pressure, pulse).", + "everythingElse": "Everything else stays one click away under", + "modulesLink": "Settings, Modules", + "glucoseUnit": "Blood glucose", + "unitPreference": "Units", + "language": "Language", + "timezone": "Time zone", + "changeInSettings": "Change these under Settings, Account", + "cta": "Confirm and continue", + "managedTitle": "The person you look after", + "managedBody": "Their record has no login of its own; you look after it from your account. A second factor on your account is required to create it.", + "managedCreate": "Create the profile and finish", + "managedLater": "You can also create it later under Settings, Shared access.", + "finishWithoutProfile": "Finish without the profile" } } }, diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx index d4d0ded2a..fdb80a1c4 100644 --- a/src/app/onboarding/[step]/page.tsx +++ b/src/app/onboarding/[step]/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { notFound, redirect } from "next/navigation"; import { OnboardingShell } from "@/components/onboarding/onboarding-shell"; +import { ConfirmScreen } from "@/components/onboarding/confirm-screen"; import { QuestionScreen } from "@/components/onboarding/question-screen"; import { UnitsScreen } from "@/components/onboarding/units-screen"; import { getServerTranslator } from "@/lib/i18n/server-translator"; @@ -98,5 +99,8 @@ function renderScreen(screen: OnboardingStepId, state: OnboardingStateDto) { if (screen === "units") { return ; } + if (screen === "confirm") { + return ; + } notFound(); } diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index 6bb195692..51589368e 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -2,8 +2,6 @@ import { useEffect, useState } from "react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Loader2 } from "lucide-react"; @@ -14,14 +12,12 @@ import { } from "@/components/onboarding/baseline-fields"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { - EMPTY_HEIGHT_DRAFT, resolveHeightUnitAdapter, type HeightDraft, } from "@/lib/profile/height-unit-display"; import { useTranslations } from "@/lib/i18n/context"; -import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; +import { apiGet, apiPut } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; -import { queryKeys } from "@/lib/query-keys"; import { AnamnesisCard, buildAnamnesisAboutMeBody, @@ -35,36 +31,36 @@ import { } from "@/components/onboarding/baseline-form-utils"; /** - * v1.4.25 W14b-Content — onboarding step 3 (baseline). + * The profile half of the confirm screen: display name, height, date of + * birth, sex — and the optional anamnesis card. * - * Captures the four profile fields the original v1.4.20 wizard - * collected on its "About you" screen — display name, height, date of - * birth, gender — but spread across a single mobile-friendly card - * instead of the older grid layout. The legacy `/api/onboarding/complete` - * endpoint is *not* used here: completion now flips on the new - * `POST /api/onboarding/step` with `{ step: 4 }`. Profile fields are - * persisted via `PUT /api/auth/profile`, the existing canonical write - * path (see `applyProfileUpdate` in `src/lib/auth/profile-update.ts`). + * Values the account already holds are seeded into the fields, so a person + * confirms them once rather than typing them again (design spec §The + * questions: never re-ask a value the account holds). Profile fields are + * persisted through `PUT /api/auth/profile`, the canonical write path, and + * the answer is READ rather than assumed: a field the server declined is + * named under its own input and the flow stays put, so the account is never + * stamped as set up over a value that never landed (v1.38.14, iOS #97). See + * `baseline-form-utils.ts` for the outcome logic. * - * Submit flow on "Save and continue": - * 1. PUT profile (empty fields are skipped). The write is - * field-independent, so the answer is read rather than assumed: - * a field the server declined is named on screen and the rest - * still counts as saved; a submission where nothing landed keeps - * the person on this step with the blocking field named. See - * `baseline-form-utils.ts`. - * 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. - * 4. router.push("/onboarding/4") — the done screen. + * Submit flow on "Confirm and continue": + * 1. PUT profile (untouched fields are left out). A refusal stops here. + * 2. PUT /api/coach/about-me — only when the anamnesis card was filled; + * preserves any existing `aboutMe` and writes conditions / allergies + * encrypted at rest. + * 3. `onConfirmed()` — the confirm screen's own completion, which derives + * the module map and moves on. * - * "Skip" advances without writing profile data; the wizard still - * completes onboarding (the user can fill profile later from - * Settings). + * "Skip" runs step 3 alone: the flow still completes, the profile can be + * filled later from Settings. */ +export interface BaselineInitialValues { + heightCm: number | null; + dateOfBirth: string | null; + gender: string | null; +} + interface BaselineFormState { displayName: string; /** @@ -77,21 +73,30 @@ interface BaselineFormState { gender: string; } -const EMPTY_FORM: BaselineFormState = { - displayName: "", - height: EMPTY_HEIGHT_DRAFT, - dateOfBirth: "", - gender: "", -}; - -export function BaselineForm() { +export function BaselineForm({ + initial, + backHref, + onConfirmed, + confirming = false, +}: { + initial: BaselineInitialValues; + backHref?: string; + /** The confirm screen's completion; awaited after a clean save. */ + onConfirmed: () => Promise; + /** True while the completion the parent owns is in flight. */ + confirming?: boolean; +}) { const { t } = useTranslations(); - const router = useRouter(); - const queryClient = useQueryClient(); const { preference } = useUnitDisplay(); const heightAdapter = resolveHeightUnitAdapter(preference); - const [form, setForm] = useState(EMPTY_FORM); + const [form, setForm] = useState(() => ({ + displayName: "", + height: heightAdapter.toDraft(initial.heightCm), + // The payload's ISO instant, as the date field's YYYY-MM-DD. + dateOfBirth: initial.dateOfBirth ? initial.dateOfBirth.slice(0, 10) : "", + gender: initial.gender ?? "", + })); const [saving, setSaving] = useState(false); // Refusals from the last save, one sentence per field. Cleared on // every attempt so a slot never keeps a reason the server no longer @@ -148,7 +153,7 @@ export function BaselineForm() { } async function advance(opts: { saveProfile: boolean }) { - if (saving) return; + if (saving || confirming) return; setSaving(true); setFieldErrors({}); try { @@ -188,9 +193,7 @@ export function BaselineForm() { await apiPut("/api/coach/about-me", aboutMeBody); } } - await apiPost("/api/onboarding/step", { step: 4 }); - await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); - router.push("/onboarding/4"); + await onConfirmed(); } catch (err) { toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); setSaving(false); @@ -226,30 +229,44 @@ export function BaselineForm() { disabled={saving} /> -
- +
+ {backHref ? ( + + ) : ( +
diff --git a/src/components/onboarding/confirm-screen.tsx b/src/components/onboarding/confirm-screen.tsx new file mode 100644 index 000000000..6734638d4 --- /dev/null +++ b/src/components/onboarding/confirm-screen.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; + +import { BaselineForm } from "@/components/onboarding/baseline-form"; +import { StepHeading } from "@/components/onboarding/step-heading"; +import { + screenHref, + useOnboardingAnswer, + useOnboardingComplete, +} from "@/components/onboarding/use-onboarding-flow"; +import { ManagedProfileCreateForm } from "@/components/settings/access/managed-profile-create-form"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/hooks/use-auth"; +import { localizedApiError } from "@/lib/api/localized-error"; +import { localeLabels } from "@/lib/i18n/config"; +import { useTranslations } from "@/lib/i18n/context"; +import { MODULE_REGISTRY } from "@/lib/modules/registry"; +import { confirmedModules } from "@/lib/onboarding/confirm-summary"; +import type { OnboardingStateDto } from "@/lib/onboarding/needs"; +import { + firstResultApplies, + nextScreen, + previousScreen, +} from "@/lib/onboarding/wizard-steps"; + +/** + * v1.39 (C2) — the confirm screen. + * + * Three things on one screen, all of them confirmations rather than + * questions (design spec §The screens, 3): + * + * 1. What the answers switch on — read off the registry's derivation + * (`confirmedModules`), never computed here, so the list confirmed is + * the list `POST /api/onboarding/complete` writes. + * 2. The preferences the account already holds: units, language, timezone. + * Shown once, changeable in Settings, never asked again. + * 3. The profile. For a record of one's own, the baseline fields (height, + * date of birth, sex, display name) with their per-field refusals; for + * "someone I look after", the managed-profile form instead, because + * that record is created HERE, after the questions, per the + * maintainer's answer to spec question 2. + * + * Confirming posts the completion — which derives the module map once and + * stamps the account past the first-run redirect — and then goes wherever + * the step machine says: the first-result screen when there is a task to + * offer, otherwise done. When there is no task, the first-result step is + * passed explicitly so the ledger reads as settled rather than owed. + */ +export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { + const { t, locale } = useTranslations(); + const router = useRouter(); + const { user } = useAuth(); + const complete = useOnboardingComplete(); + const answer = useOnboardingAnswer(); + const [finishing, setFinishing] = useState(false); + + const needs = state.needs; + const forSomeoneElse = needs.recordTarget === "someone-else"; + const { chosen, alwaysOn } = confirmedModules(needs); + const back = previousScreen(state, "confirm"); + + const glucoseUnit = needs.units.glucoseUnit ?? user?.glucoseUnit ?? null; + const unitPreference = + needs.units.unitPreference ?? user?.unitPreference ?? "metric"; + + async function finish() { + if (finishing) return; + setFinishing(true); + try { + const { onboarding } = await complete.mutateAsync(); + let written = onboarding ?? state; + if (!firstResultApplies(written)) { + // Nothing to offer, so the step is passed rather than left owed: + // a pending step would keep the setup reading as unfinished forever. + written = await answer.mutateAsync({ + step: "first-result", + status: "skipped", + }); + } + router.push(screenHref(nextScreen(written, "confirm") ?? "done")); + } catch (err) { + toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + setFinishing(false); + } + } + + const moduleNames = (keys: readonly (keyof typeof MODULE_REGISTRY)[]) => + keys.map((key) => t(MODULE_REGISTRY[key].labelKey)).join(", "); + + return ( +
+ + +
+

+ {t("onboarding.flow.confirm.modulesTitle")} +

+

+ {chosen.length > 0 + ? t("onboarding.flow.confirm.switchedOn", { + modules: moduleNames(chosen), + }) + : t("onboarding.flow.confirm.nothingExtra")} +

+

+ {t("onboarding.flow.confirm.alwaysOn", { + modules: moduleNames(alwaysOn), + })} +

+

+ {t("onboarding.flow.confirm.everythingElse")}{" "} + + {t("onboarding.flow.confirm.modulesLink")} + +

+
+ +
+ {glucoseUnit ? ( + <> +
+ {t("onboarding.flow.confirm.glucoseUnit")} +
+
{glucoseUnit}
+ + ) : null} +
+ {t("onboarding.flow.confirm.unitPreference")} +
+
{t(`onboarding.flow.units.options.${unitPreference}`)}
+
+ {t("onboarding.flow.confirm.language")} +
+
{localeLabels[locale]}
+ {user?.timezone ? ( + <> +
+ {t("onboarding.flow.confirm.timezone")} +
+
{user.timezone}
+ + ) : null} +
+ + {t("onboarding.flow.confirm.changeInSettings")} + +
+
+ + {forSomeoneElse ? ( +
+
+

+ {t("onboarding.flow.confirm.managedTitle")} +

+

+ {t("onboarding.flow.confirm.managedBody")} +

+
+ void finish()} + /> +

{t("onboarding.flow.confirm.managedLater")}

+
+ {back ? ( + + ) : ( +
+
+ ) : ( + + )} +
+ ); +} diff --git a/src/components/profile/__tests__/height-entry-surfaces.test.tsx b/src/components/profile/__tests__/height-entry-surfaces.test.tsx index 97af6e93a..86661e422 100644 --- a/src/components/profile/__tests__/height-entry-surfaces.test.tsx +++ b/src/components/profile/__tests__/height-entry-surfaces.test.tsx @@ -73,7 +73,12 @@ function renderUnder( const SURFACES = [ { name: "onboarding baseline step", - node: , + node: ( + {}} + /> + ), firstInputId: "ob-baseline-height", metricLabel: "Height (cm)", }, diff --git a/src/components/settings/access/managed-profile-create-form.tsx b/src/components/settings/access/managed-profile-create-form.tsx index 9b6e91606..ee62c9a2e 100644 --- a/src/components/settings/access/managed-profile-create-form.tsx +++ b/src/components/settings/access/managed-profile-create-form.tsx @@ -15,6 +15,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { useCreateManagedProfile, type ManagedProfileGender, + type ManagedProfileView, } from "@/lib/queries/use-managed-profiles"; import { DEFAULT_TIMEZONE, @@ -73,7 +74,19 @@ import { * invitation form does, rather than the generic "could not be created", which * would send somebody looking for a problem with what they typed. */ -export function ManagedProfileCreateForm() { +export function ManagedProfileCreateForm({ + submitLabel, + onCreated, +}: { + /** The button's label when the form sits somewhere other than the panel. */ + submitLabel?: string; + /** + * v1.39 (C2) — fired after a successful creation, with the record. The + * setup flow's confirm screen mounts this form and continues from here; + * the sharing panel leaves it unset and shows the confirmation line. + */ + onCreated?: (profile: ManagedProfileView) => void; +} = {}) { const { t, locale: actorLocale } = useTranslations(); const create = useCreateManagedProfile(); @@ -116,6 +129,7 @@ export function ManagedProfileCreateForm() { setGender(null); setTimezone(browserTimezone()); setCreated(profile.displayName ?? trimmedName); + onCreated?.(profile); }, // Nothing is cleared here, on purpose: a refused creation is one the // person is being asked to retry, and a form that emptied itself @@ -258,7 +272,7 @@ export function ManagedProfileCreateForm() { > {create.isPending ? t("recordSharing.managed.creating") - : t("recordSharing.managed.create")} + : (submitLabel ?? t("recordSharing.managed.create"))} {error && ( diff --git a/src/lib/onboarding/__tests__/confirm-summary.test.ts b/src/lib/onboarding/__tests__/confirm-summary.test.ts new file mode 100644 index 000000000..3ced0603a --- /dev/null +++ b/src/lib/onboarding/__tests__/confirm-summary.test.ts @@ -0,0 +1,68 @@ +/** + * v1.39 (C2) — the confirm screen's list is the registry's derivation and + * nothing else. + */ +import { describe, expect, it } from "vitest"; + +import { + deriveOnboardingModuleDefaults, + ONBOARDING_ALWAYS_ON_MODULES, + OWNED_MODULE_KEYS, +} from "@/lib/modules/registry"; + +import { confirmedModules } from "../confirm-summary"; +import { emptyOnboardingNeeds } from "../needs"; + +describe("confirmedModules", () => { + it("lists exactly what the derivation switches on, the always-on trio aside", () => { + const needs = { + ...emptyOnboardingNeeds(), + recordTarget: "me" as const, + areas: ["glucose" as const, "sleep" as const], + medication: "yes" as const, + visit: "within-a-month" as const, + }; + const derived = deriveOnboardingModuleDefaults(needs); + const expected = OWNED_MODULE_KEYS.filter( + (key) => + derived.preferences[key] && !ONBOARDING_ALWAYS_ON_MODULES.includes(key), + ); + const { chosen, alwaysOn } = confirmedModules(needs); + expect(chosen).toEqual(expected); + expect(chosen).toContain("medications"); + expect(chosen).toContain("doctorReport"); + // Registry order, so the two lists read in the same order everywhere. + expect(alwaysOn).toEqual( + OWNED_MODULE_KEYS.filter((key) => + ONBOARDING_ALWAYS_ON_MODULES.includes(key), + ), + ); + }); + + it("names cycle when the area was ticked, though the map never owns it", () => { + const { chosen } = confirmedModules({ + ...emptyOnboardingNeeds(), + recordTarget: "me", + areas: ["cycle"], + }); + expect(chosen).toContain("cycle"); + }); + + it("reads a skipped question as the route does — nothing switched on for it", () => { + const { chosen } = confirmedModules({ + ...emptyOnboardingNeeds(), + recordTarget: "me", + }); + expect(chosen).not.toContain("medications"); + expect(chosen).not.toContain("doctorReport"); + }); + + it("switches on the immunization log for a record somebody else runs", () => { + expect( + confirmedModules({ + ...emptyOnboardingNeeds(), + recordTarget: "someone-else", + }).chosen, + ).toContain("vaccinations"); + }); +}); diff --git a/src/lib/onboarding/confirm-summary.ts b/src/lib/onboarding/confirm-summary.ts new file mode 100644 index 000000000..1dfb28c9b --- /dev/null +++ b/src/lib/onboarding/confirm-summary.ts @@ -0,0 +1,52 @@ +/** + * v1.39 (C2) — what the confirm screen says is switched on. + * + * A READ of the registry's derivation, never a second mapping: the list the + * person confirms is `deriveOnboardingModuleDefaults` applied to the same + * answers `POST /api/onboarding/complete` will apply it to, with the same + * conservative defaults for a skipped question. A wizard that computed its + * own list would drift from the one the route actually writes, and the + * confirm screen would then be confirming something else. + * + * What the route writes on top of this — the merge that never retracts a + * module switched on by hand or one holding data — only ever ADDS to the + * list, which is why the sentence beside it says "everything else stays + * under Settings" rather than "is off". + */ +import { + deriveOnboardingModuleDefaults, + MODULE_KEYS, + ONBOARDING_ALWAYS_ON_MODULES, + type ModuleKey, +} from "@/lib/modules/registry"; + +import type { OnboardingNeeds } from "./needs"; + +export interface ConfirmedModules { + /** What the answers switch on, in registry order — the always-on trio aside. */ + chosen: ModuleKey[]; + /** On whatever the answers say. */ + alwaysOn: ModuleKey[]; +} + +export function confirmedModules(needs: OnboardingNeeds): ConfirmedModules { + const derived = deriveOnboardingModuleDefaults({ + // Q1 is required and a confirm screen is never reached without it; the + // fallback only keeps the type total. + recordTarget: needs.recordTarget ?? "me", + areas: needs.areas, + // The same defaults the route applies to a skipped question. + medication: needs.medication ?? "no", + visit: needs.visit ?? "no", + }); + const on = new Set(); + for (const [key, value] of Object.entries(derived.preferences)) { + if (value) on.add(key as ModuleKey); + } + if (derived.cycleTracking) on.add("cycle"); + const alwaysOn = new Set(ONBOARDING_ALWAYS_ON_MODULES); + return { + chosen: MODULE_KEYS.filter((key) => on.has(key) && !alwaysOn.has(key)), + alwaysOn: MODULE_KEYS.filter((key) => alwaysOn.has(key)), + }; +}