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 (
+
+
+
+
+ ) : (
+
+ )}
+
+ );
+}
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)),
+ };
+}