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