Merge wave C — the flow cannot strand, mislead or silently switch off

The getting-started checklist's data queries follow the same setup rule as
its visibility, so a settled account stops being told to do what it has
done; a failed completion leaves the confirm screen usable; an empty
multi-select is no longer recorded as an answer; the confirm screen names
the modules a run would take out of the navigation; the settings links it
offers work before the first completion; and demo mode can fill the
baseline it is shown.
This commit is contained in:
Marc-André Bombeck committed 2026-09-11 10:24:48 +02:00
commit 3642bbe4de
24 files changed
+1018 -122

No files matched your search

@@ -0,0 +1,155 @@
/**
* v1.39 (Wave C, C2) — a failed completion leaves the confirm screen usable.
*
* Two defects, one symptom. `advance()` in the baseline form set its pending
* flag and never reset it on the success path, and `finish()` on the confirm
* screen caught its own error and returned normally — so a failed
* `POST /api/onboarding/complete` (or a 429 on the answer PATCHes before it)
* left "Überspringen" and "Bestätigen und weiter" disabled for the rest of the
* page's life and only a reload recovered (research I10).
*
* The second defect is the retry itself: `finish()` fires one PATCH per
* question the flow never showed, and a failure midway left a partly-passed
* ledger that the next attempt walked from the top again (M-j).
*
* Both are pinned at the seam the component drives, because this repo's
* component tests render server-side and cannot click.
*/
import { describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
defaultOnboardingSteps,
emptyOnboardingNeeds,
type OnboardingStateDto,
} from "@/lib/onboarding/needs";
import { runBaselineAttempt } from "../baseline-form";
import { runConfirmFinish } from "../confirm-screen";
type PassableStep = Parameters<
Parameters<typeof runConfirmFinish>[0]["passStep"]
>[0];
/**
* A flow with no unit-bearing area: Q6 is the question the machine never
* showed, so the confirm screen has to pass it on the ledger before the
* completion.
*/
function state(
overrides: Partial<OnboardingStateDto> = {},
): OnboardingStateDto {
return {
steps: defaultOnboardingSteps().map((step) =>
step.id === "who" ? { ...step, status: "done" as const } : step,
),
needs: { ...emptyOnboardingNeeds(), recordTarget: "me" },
completedAt: null,
firstResult: null,
...overrides,
};
}
/** The ledger as the server would answer it after one step was passed. */
function withPassed(
base: OnboardingStateDto,
ids: readonly string[],
): OnboardingStateDto {
return {
...base,
steps: base.steps.map((step) =>
ids.includes(step.id) ? { ...step, status: "skipped" as const } : step,
),
};
}
describe("runBaselineAttempt", () => {
it("hands the buttons back when the completion rejects", async () => {
const pending: boolean[] = [];
const onError = vi.fn();
await runBaselineAttempt(
(value) => pending.push(value),
async () => {
throw new Error("complete failed");
},
onError,
);
expect(pending).toEqual([true, false]);
expect(onError).toHaveBeenCalledTimes(1);
});
it("hands the buttons back on the success path too", async () => {
const pending: boolean[] = [];
await runBaselineAttempt(
(value) => pending.push(value),
async () => {},
vi.fn(),
);
expect(pending).toEqual([true, false]);
});
it("is the form's only way to move the pending flag", () => {
// A second, inline `setSaving(false)` is how the flag stopped being
// reset on one of the three exits in the first place.
const source = readFileSync(
join(process.cwd(), "src/components/onboarding/baseline-form.tsx"),
"utf8",
);
expect(source).not.toContain("setSaving(false)");
expect(source).not.toContain("setSaving(true)");
});
});
describe("runConfirmFinish", () => {
it("rethrows a failed completion instead of returning normally", async () => {
const ledger = { current: state() };
const passed: PassableStep[] = [];
const navigate = vi.fn();
await expect(
runConfirmFinish({
ledger,
// The server answers the written ledger; only the runner may put it
// back, which is the whole point of the ref.
passStep: async (step) => {
passed.push(step);
return withPassed(ledger.current, [step]);
},
complete: async () => {
throw new Error("429");
},
navigate,
}),
).rejects.toThrow("429");
// Q6 was never shown for a flow with no unit-bearing area, so it is
// passed on the ledger before the completion — and that write survived.
expect(passed).toEqual(["units"]);
expect(navigate).not.toHaveBeenCalled();
});
it("does not pass a step the failed attempt already passed (M-j)", async () => {
const ledger = { current: state() };
const passed: PassableStep[] = [];
const passStep = async (step: PassableStep) => {
passed.push(step);
return withPassed(ledger.current, [step]);
};
let attempt = 0;
const complete = async () => {
attempt += 1;
if (attempt === 1) throw new Error("429");
return { onboarding: ledger.current };
};
const navigate = vi.fn();
await expect(
runConfirmFinish({ ledger, passStep, complete, navigate }),
).rejects.toThrow("429");
await runConfirmFinish({ ledger, passStep, complete, navigate });
// "units" exactly once across both attempts; "first-result" is the
// second attempt's own pass, because these answers offer no task.
expect(passed).toEqual(["units", "first-result"]);
expect(navigate).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,109 @@
/**
* v1.39 (Wave C) — what the confirm screen is allowed to claim.
*
* C5 (research M-l): both of the screen's own Settings links are page routes,
* and `hl_onboarding=pending` is still set while this screen is on — the
* completion is what clears it, and this screen is what calls the completion.
* The proxy sends both straight back to the flow, so on a first run they are
* dead links under a sentence that is not yet true.
*
* C4 (research I6): the screen listed what the answers switch ON and said
* everything else "stays one click away under Settings, Modules" — true of
* reachability, false of the navigation entry. A module the answers do not
* ask for leaves the navigation, and nothing said so.
*
* Rendered server-side, this repo's component-test convention.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { AuthUser } from "@/hooks/use-auth";
import {
defaultOnboardingSteps,
emptyOnboardingNeeds,
type OnboardingStateDto,
} from "@/lib/onboarding/needs";
let currentUser: AuthUser | null = null;
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: currentUser }),
useAccountOnceMounted: () => currentUser,
}));
const { ConfirmScreen } = await import("../confirm-screen");
function state(
overrides: Partial<OnboardingStateDto> = {},
needs: Partial<OnboardingStateDto["needs"]> = {},
): OnboardingStateDto {
return {
steps: defaultOnboardingSteps(),
needs: { ...emptyOnboardingNeeds(), recordTarget: "me", ...needs },
completedAt: null,
firstResult: null,
...overrides,
};
}
function render(
flow: OnboardingStateDto,
modules: Partial<Record<string, boolean>> = {},
): string {
currentUser = {
id: "u1",
heightCm: null,
dateOfBirth: null,
gender: null,
timezone: "Europe/Berlin",
unitPreference: "metric",
modules,
} as unknown as AuthUser;
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">
<ConfirmScreen state={flow} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<ConfirmScreen> — modules leaving the navigation (C4)", () => {
it("names them when the answers do not ask for what the record shows", () => {
const html = render(state({}, { areas: ["sleep"], medication: "no" }), {
medications: true,
});
expect(html).toContain('data-slot="onboarding-confirm-modules-off"');
expect(html).toContain("Medications");
});
it("says nothing when the answers take nothing away", () => {
const html = render(state({}, { areas: ["sleep"], medication: "yes" }), {
medications: true,
});
expect(html).not.toContain('data-slot="onboarding-confirm-modules-off"');
});
});
describe("<ConfirmScreen> — Settings links on a first run (C5)", () => {
it("renders both as plain text while the completion has not run", () => {
const html = render(state());
expect(html).not.toContain('href="/settings/modules"');
expect(html).not.toContain('href="/settings/account"');
// The words stay: they say where the thing lives, which is true.
expect(html).toContain("Settings, Modules");
expect(html).toContain("Settings, Account");
});
it("renders them as links once the flow has been completed once", () => {
const html = render(state({ completedAt: "2026-09-10T08:00:00.000Z" }), {});
expect(html).toContain('href="/settings/modules"');
expect(html).toContain('href="/settings/account"');
});
});
@@ -0,0 +1,139 @@
/**
* v1.39 (Wave C, C1) — the checklist's data queries follow the same rule as
* its visibility.
*
* `shouldShowChecklist` gained `hasEnteredOnboardingFlow` as a third
* disjunct; `checklistRelevant` in the component kept restating the older
* half of the rule, so a wizard-run account past five readings still saw the
* card while every supporting query was `enabled: false` — the medication,
* data-source, notification and insights rows read "not done" for things the
* account had already done (research I9).
*
* Rendered SSR-only, per this repo's component-test convention. `useQuery` is
* mocked to honour `enabled` exactly the way tanstack does — a disabled query
* has no data — so what the rows say here is what the dashboard says.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import type { AuthUser } from "@/hooks/use-auth";
import {
defaultOnboardingSteps,
emptyOnboardingNeeds,
type OnboardingStateDto,
} from "@/lib/onboarding/needs";
let currentUser: AuthUser | null = null;
/** Every `enabled` flag the component asked for, keyed by its query key. */
const enabledByKey = new Map<string, boolean>();
vi.mock("@/hooks/use-auth", () => ({
useAccountOnceMounted: () => currentUser,
}));
vi.mock("@/lib/queries/use-dashboard-snapshot", () => ({
useDashboardSnapshot: () => ({
data: { tiles: { summaries: { weight: { count: 12 } } } },
}),
}));
const RESPONSES: Record<string, unknown> = {
'["medications"]': [{ id: "m1" }, { id: "m2" }, { id: "m3" }],
'["user","ai-provider"]': { aiAvailable: true },
'["integrations","status"]': {
integrations: [{ integration: "whoop", connected: true }],
},
// Deliberately NOT configured: one open row keeps the card on screen, so
// the rows this test is about are actually rendered.
'["notifications","preferences"]': { channels: [{ enabled: false }] },
};
vi.mock("@tanstack/react-query", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
return {
...actual,
useQuery: (options: {
queryKey: readonly unknown[];
enabled?: boolean;
}) => {
const key = JSON.stringify(options.queryKey);
const enabled = options.enabled !== false;
enabledByKey.set(key, enabled);
return { data: enabled ? RESPONSES[key] : undefined };
},
};
});
const { GettingStartedChecklist } =
await import("../getting-started-checklist");
/** A record the flow really finished: Q1 answered, confirmed, task settled. */
function settledOnboarding(): OnboardingStateDto {
return {
steps: defaultOnboardingSteps().map((step) => ({
...step,
status: "done" as const,
})),
needs: { ...emptyOnboardingNeeds(), recordTarget: "me" },
completedAt: "2026-09-10T08:00:00.000Z",
firstResult: {
task: "log-reading",
target: null,
completedAt: "2026-09-10T08:05:00.000Z",
},
} as OnboardingStateDto;
}
function user(overrides: Partial<AuthUser> = {}): AuthUser {
return {
id: "u1",
email: "a@b.c",
username: "a",
heightCm: 175,
dateOfBirth: "1990-01-01T00:00:00.000Z",
gender: "MALE",
onboardingCompletedAt: "2026-09-10T08:00:00.000Z",
onboarding: settledOnboarding(),
...overrides,
} as AuthUser;
}
function render(): string {
enabledByKey.clear();
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<GettingStartedChecklist />
</I18nProvider>,
);
}
describe("<GettingStartedChecklist> — rows for an established account", () => {
it("reads the real medication, data-source and insights state once the flow is behind the account", () => {
currentUser = user();
const html = render();
// The card is on screen: the notifications row is genuinely open. The
// list itself is collapsed on a server paint, so the progress meter is
// what the rows add up to — profile, measurement, medication, data
// source and insights done, notifications not.
expect(html).toContain('data-testid="onboarding-card"');
expect(html).toContain("5 of 6 done");
});
it("keeps the supporting queries enabled while the card can render", () => {
currentUser = user();
render();
expect(enabledByKey.get('["medications"]')).toBe(true);
expect(enabledByKey.get('["integrations","status"]')).toBe(true);
expect(enabledByKey.get('["notifications","preferences"]')).toBe(true);
expect(enabledByKey.get('["user","ai-provider"]')).toBe(true);
});
it("still fetches nothing for a record that never entered the flow and is long past five readings", () => {
currentUser = user({ onboarding: null });
render();
expect(enabledByKey.get('["medications"]')).toBe(false);
expect(enabledByKey.get('["integrations","status"]')).toBe(false);
});
});
@@ -0,0 +1,82 @@
/**
* v1.39 (Wave C, C3) — Q6 cannot be answered with nothing chosen (M-k).
*
* "Weiter" used to be live on an untouched units screen: it wrote
* `{ units: {} }`, which marked the step DONE without recording a preference,
* so the account carried "asked and answered" for a question nobody answered.
* Skip is the way past a question with no opinion, and Skip says so on the
* ledger.
*
* Rendered server-side, this repo's component-test convention.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import {
defaultOnboardingSteps,
emptyOnboardingNeeds,
type OnboardingStateDto,
} from "@/lib/onboarding/needs";
import { UnitsScreen } from "../units-screen";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: null }),
}));
function state(
needs: Partial<OnboardingStateDto["needs"]>,
): OnboardingStateDto {
return {
steps: defaultOnboardingSteps(),
needs: { ...emptyOnboardingNeeds(), recordTarget: "me", ...needs },
completedAt: null,
firstResult: null,
};
}
function render(needs: Partial<OnboardingStateDto["needs"]>): string {
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">
<UnitsScreen state={state(needs)} />
</I18nProvider>
</QueryClientProvider>,
);
}
/**
* Is the primary button's own tag disabled? Read off the tag rather than the
* whole page, and off the attribute rather than the string "disabled", which
* also occurs in the button's `disabled:` utility classes.
*/
function nextIsDisabled(html: string): boolean {
const at = html.indexOf('data-slot="onboarding-next"');
expect(at).toBeGreaterThan(-1);
const open = html.lastIndexOf("<button", at);
return html.slice(open, html.indexOf(">", at) + 1).includes('disabled=""');
}
describe("<UnitsScreen>", () => {
it("does not let the person continue without choosing a unit", () => {
const html = render({ areas: ["glucose"] });
expect(nextIsDisabled(html)).toBe(true);
// Skip is still there: it is the honest way past a question with no
// opinion, and it says "skipped" on the ledger.
expect(html).toContain('data-slot="onboarding-skip"');
});
it("lets the person continue once a unit is chosen", () => {
const html = render({
areas: ["glucose"],
units: { glucoseUnit: "mg/dL", unitPreference: null },
});
expect(nextIsDisabled(html)).toBe(false);
});
});
+70 -39
View File
@@ -71,6 +71,37 @@ interface BaselineFormState {
gender: string;
}
/**
* v1.39 (Wave C, C2) — one attempt at confirming, with the buttons always
* handed back.
*
* `advance()` used to raise its own pending flag and lower it again on two of
* the three exits: the refused-field return and its catch. The third exit —
* the success path, `await onConfirmed()` — never lowered it, and
* `onConfirmed` is the confirm screen's completion, which caught its own
* error and returned normally. So a failed completion disabled "Skip" and
* "Confirm and continue" for the rest of the page's life and only a reload
* recovered (research I10).
*
* The reset lives in a `finally` here, which is the one place it cannot be
* forgotten on a new exit, and the component has no other way to move the
* flag.
*/
export async function runBaselineAttempt(
setPending: (pending: boolean) => void,
attempt: () => Promise<void>,
onError: (err: unknown) => void,
): Promise<void> {
setPending(true);
try {
await attempt();
} catch (err) {
onError(err);
} finally {
setPending(false);
}
}
export function BaselineForm({
initial,
backHref,
@@ -152,50 +183,50 @@ export function BaselineForm({
async function advance(opts: { saveProfile: boolean }) {
if (saving || confirming) return;
setSaving(true);
setFieldErrors({});
try {
if (opts.saveProfile) {
const profileBody = buildBaselineProfileBody(
form,
heightAdapter.toCanonicalCm(form.height),
);
if (Object.keys(profileBody).length > 0) {
const outcome = describeBaselineSaveOutcome(
await putBaselineProfile(profileBody),
t,
baselineFieldLabelKeys(heightAdapter.usesFeetInches),
await runBaselineAttempt(
setSaving,
async () => {
if (opts.saveProfile) {
const profileBody = buildBaselineProfileBody(
form,
heightAdapter.toCanonicalCm(form.height),
);
if (outcome.notice) {
const show =
outcome.notice.tone === "warning" ? toast.warning : toast.error;
show(outcome.notice.message);
if (Object.keys(profileBody).length > 0) {
const outcome = describeBaselineSaveOutcome(
await putBaselineProfile(profileBody),
t,
baselineFieldLabelKeys(heightAdapter.usesFeetInches),
);
if (outcome.notice) {
const show =
outcome.notice.tone === "warning" ? toast.warning : toast.error;
show(outcome.notice.message);
}
if (!outcome.advance) {
// A field was refused. Staying on the step is the point —
// each refused input now says why under itself, the values
// the person typed are still in front of them, and the
// account is not stamped as set up over a value that never
// landed.
setFieldErrors(outcome.fieldErrors);
return;
}
}
if (!outcome.advance) {
// A field was refused. Staying on the step is the point —
// each refused input now says why under itself, the values
// the person typed are still in front of them, and the
// account is not stamped as set up over a value that never
// landed.
setFieldErrors(outcome.fieldErrors);
setSaving(false);
return;
}
}
// Anamnesis — only write when the user actually filled a field
// (the helper returns null for an untouched card, so a
// collapsed card never round-trips).
const aboutMeBody = buildAnamnesisAboutMeBody(baseAboutMe, anamnesis);
if (aboutMeBody) {
await apiPut("/api/coach/about-me", aboutMeBody);
// Anamnesis — only write when the user actually filled a field
// (the helper returns null for an untouched card, so a
// collapsed card never round-trips).
const aboutMeBody = buildAnamnesisAboutMeBody(baseAboutMe, anamnesis);
if (aboutMeBody) {
await apiPut("/api/coach/about-me", aboutMeBody);
}
}
}
await onConfirmed();
} catch (err) {
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
setSaving(false);
}
await onConfirmed();
},
(err) =>
toast.error(localizedApiError(err, t, "onboarding.errorGeneric")),
);
}
return (
+127 -38
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
@@ -20,7 +20,14 @@ 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 {
ONBOARDING_SKIPPABLE_STEP_IDS,
type OnboardingStateDto,
} from "@/lib/onboarding/needs";
/** A step the confirm screen may pass on the ledger without an answer. */
type PassableStepId = (typeof ONBOARDING_SKIPPABLE_STEP_IDS)[number];
import { isOnboardingSettled } from "@/lib/onboarding/needs";
import {
firstResultApplies,
nextScreen,
@@ -51,6 +58,40 @@ import {
* offer, otherwise done. When there is no task, the first-result step is
* passed explicitly so the ledger reads as settled rather than owed.
*/
/**
* v1.39 (Wave C, C2) — the completion sequence, apart from the component so
* a failure partway through it can be pinned without a browser.
*
* Passes every question the flow never showed (the route derives only once
* EVERY question is answered or passed, and a step nobody was asked cannot be
* answered), completes, passes the first-result step when there is no task to
* offer, and navigates. It reports a failure by THROWING — the caller owns the
* buttons and the toast.
*/
export async function runConfirmFinish(args: {
/** The ledger as the last write left it; updated in place as steps pass. */
ledger: { current: OnboardingStateDto };
managedRecordId?: string;
passStep: (step: PassableStepId) => Promise<OnboardingStateDto>;
complete: (
managedRecordId?: string,
) => Promise<{ onboarding?: OnboardingStateDto | null }>;
navigate: (href: string) => void;
}): Promise<void> {
for (const id of questionsToPassBeforeConfirm(args.ledger.current)) {
args.ledger.current = await args.passStep(id);
}
const { onboarding } = await args.complete(args.managedRecordId);
let written = onboarding ?? args.ledger.current;
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 args.passStep("first-result");
}
args.ledger.current = written;
args.navigate(screenHref(nextScreen(written, "confirm") ?? "done"));
}
export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
const { t, locale } = useTranslations();
const router = useRouter();
@@ -58,10 +99,37 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
const complete = useOnboardingComplete();
const answer = useOnboardingAnswer();
const [finishing, setFinishing] = useState(false);
/**
* v1.39 (Wave C, C2) — the ledger as the last write left it (M-j).
* `finish()` sends one PATCH per question the flow never showed; a failure
* midway used to leave a partly-passed ledger that the next attempt walked
* from the top again, because the `state` prop it read was the one this
* screen was rendered with. Every mutation answers the written state, so
* the ref carries the progress across attempts and
* `questionsToPassBeforeConfirm` — which lists pending steps only — then
* skips what the failed attempt already passed.
*/
const ledger = useRef(state);
const needs = state.needs;
/**
* v1.39 (Wave C, C5) — is `hl_onboarding=pending` still set? (research M-l)
*
* Both of this screen's Settings links are page routes, and the cookie is
* cleared by the completion — which is what THIS screen calls. So on a
* first run the proxy 307s both straight back into the flow: two links
* that cannot go anywhere, under a sentence saying where things live. The
* words are true, the links are not, so only the links go.
*/
const settingsReachable =
isOnboardingSettled(state) || state.completedAt !== null;
const forSomeoneElse = needs.recordTarget === "someone-else";
const { chosen, alwaysOn } = confirmedModules(needs);
// v1.39 (Wave C, C4) — `user.modules` is the map the navigation reads, so
// the screen can only name a module the person actually has today.
const { chosen, alwaysOn, wouldSwitchOff } = confirmedModules(
needs,
user?.modules ?? {},
);
const back = previousScreen(state, "confirm");
const glucoseUnit = needs.units.glucoseUnit ?? user?.glucoseUnit ?? null;
@@ -73,37 +141,39 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
* after" just created: the route then applies the derivation to THAT
* record and stamps this one complete without deriving, so the guardian's
* own modules are never re-ordered around the child's answers.
*
* v1.39 (Wave C, C2) — this RETHROWS. `<BaselineForm>` owns the two
* buttons and releases them in its own `finally`, and it can only do that
* for a failure it is told about: the previous version caught, toasted and
* returned normally, which left both buttons disabled until a reload
* (research I10). The two managed-arm callers, which have no form around
* them, go through `finishReporting` below.
*/
async function finish(managedRecordId?: string) {
if (finishing) return;
setFinishing(true);
try {
// A question this flow never showed — Q6 for somebody with no
// unit-bearing area — is passed on the ledger before the completion,
// because the route derives only once EVERY question is answered or
// passed, and a step nobody was asked cannot be answered.
for (const id of questionsToPassBeforeConfirm(state)) {
await answer.mutateAsync({ step: id, status: "skipped" });
}
const { onboarding } = await complete.mutateAsync(
managedRecordId ? { managedRecordId } : undefined,
);
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"));
await runConfirmFinish({
ledger,
managedRecordId,
passStep: (step) => answer.mutateAsync({ step, status: "skipped" }),
complete: (id) =>
complete.mutateAsync(id ? { managedRecordId: id } : undefined),
navigate: (href) => router.push(href),
});
} catch (err) {
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
setFinishing(false);
throw err;
}
}
/** The managed arm's two buttons: nothing above them reports a failure. */
function finishReporting(managedRecordId?: string) {
void finish(managedRecordId).catch((err) => {
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
});
}
const moduleNames = (keys: readonly (keyof typeof MODULE_REGISTRY)[]) =>
keys.map((key) => t(MODULE_REGISTRY[key].labelKey)).join(", ");
@@ -129,6 +199,13 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
})
: t("onboarding.flow.confirm.nothingExtra")}
</p>
{wouldSwitchOff.length > 0 ? (
<p className="text-sm" data-slot="onboarding-confirm-modules-off">
{t("onboarding.flow.confirm.switchesOff", {
modules: moduleNames(wouldSwitchOff),
})}
</p>
) : null}
<p className="text-muted-foreground text-sm">
{t("onboarding.flow.confirm.alwaysOn", {
modules: moduleNames(alwaysOn),
@@ -136,12 +213,18 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
</p>
<p className="text-sm">
{t("onboarding.flow.confirm.everythingElse")}{" "}
<Link
href="/settings/modules"
className="text-primary underline underline-offset-4"
>
{t("onboarding.flow.confirm.modulesLink")}
</Link>
{settingsReachable ? (
<Link
href="/settings/modules"
className="text-primary underline underline-offset-4"
>
{t("onboarding.flow.confirm.modulesLink")}
</Link>
) : (
<span className="font-medium">
{t("onboarding.flow.confirm.modulesLink")}
</span>
)}
</p>
</div>
@@ -176,12 +259,18 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
) : null}
</dl>
<p className="text-sm">
<Link
href="/settings/account"
className="text-primary underline underline-offset-4"
>
{t("onboarding.flow.confirm.changeInSettings")}
</Link>
{settingsReachable ? (
<Link
href="/settings/account"
className="text-primary underline underline-offset-4"
>
{t("onboarding.flow.confirm.changeInSettings")}
</Link>
) : (
<span className="text-muted-foreground">
{t("onboarding.flow.confirm.changeInSettings")}
</span>
)}
</p>
{forSomeoneElse ? (
@@ -196,7 +285,7 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
</div>
<ManagedProfileCreateForm
submitLabel={t("onboarding.flow.confirm.managedCreate")}
onCreated={(profile) => void finish(profile.id)}
onCreated={(profile) => finishReporting(profile.id)}
/>
<p className="text-sm">{t("onboarding.flow.confirm.managedLater")}</p>
<div className="flex flex-wrap items-center justify-between gap-2 pt-2">
@@ -212,7 +301,7 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
<Button
type="button"
variant="outline"
onClick={() => void finish()}
onClick={() => finishReporting()}
disabled={finishing}
className="min-h-11"
data-slot="onboarding-finish-without-profile"
@@ -26,6 +26,7 @@ import { queryKeys } from "@/lib/query-keys";
import {
buildChecklist,
checklistProgress,
isStillInSetup,
shouldShowChecklist,
upcomingVisitCountFrom,
visibleChecklist,
@@ -239,17 +240,27 @@ export function GettingStartedChecklist() {
}, [snapshotData]);
// v1.15.20 — only fetch the checklist's supporting data while the card
// can actually render: the user is still in setup (`shouldShowChecklist`'s
// `onboardingCompletedAt == null || measurementCount < 5` rule) and has
// not dismissed it. Established users used to pay this medications fetch
// (and, pre-v1.5, two more) on every dashboard load for a card that
// never mounts. Gated on the snapshot having resolved so the count is
// real, not the loading-default 0.
// can actually render: the user is still in setup and has not dismissed
// it. Established users used to pay this medications fetch (and, pre-v1.5,
// two more) on every dashboard load for a card that never mounts. Gated on
// the snapshot having resolved so the count is real, not the
// loading-default 0.
//
// v1.39 (Wave C, C1) — the rule is `isStillInSetup`, the same predicate
// `shouldShowChecklist` applies, rather than a restatement of half of it.
// The two drifted apart once already: the visibility rule learned about
// records that ran the setup flow and this gate did not, so the card
// rendered for a wizard-run account past five readings with every
// network-backed row frozen at "not done" (research I9, M-p).
const checklistRelevant =
!!user &&
!dismissedAll &&
snapshotData !== undefined &&
(user.onboardingCompletedAt == null || measurementCount < 5);
isStillInSetup({
onboarding: user.onboarding ?? null,
onboardingCompletedAt: user.onboardingCompletedAt ?? null,
measurementCount,
});
const { data: medsData } = useQuery<Array<{ id: string }>>({
queryKey: queryKeys.medications(),
+11 -4
View File
@@ -54,9 +54,15 @@ export function UnitsScreen({ state }: { state: OnboardingStateDto }) {
const back = previousScreen(state, "units");
const answerBody = unitsAnswerBody({ asked, glucoseUnit, unitPreference });
async function persist(
input:
ReturnType<typeof unitsAnswerBody> | { step: "units"; status: "skipped" },
| NonNullable<ReturnType<typeof unitsAnswerBody>>
| {
step: "units";
status: "skipped";
},
) {
try {
const written = await answer.mutateAsync(input);
@@ -121,9 +127,10 @@ export function UnitsScreen({ state }: { state: OnboardingStateDto }) {
<StepActions
backHref={back ? screenHref(back) : undefined}
onSkip={() => void persist({ step: "units", status: "skipped" })}
onNext={() =>
void persist(unitsAnswerBody({ asked, glucoseUnit, unitPreference }))
}
onNext={() => {
if (answerBody) void persist(answerBody);
}}
nextDisabled={answerBody === null}
pending={answer.isPending}
/>
</section>