Tighten the setup flow after a review of the whole diff

The done screen moves focus to its heading like every other screen; the
confirm screen's preference list holds only dt/dd pairs and the settings
link stands beside it; Back from Q1 is not offered once the flow has
been entered, because the front door would resume past the welcome and
bounce straight back; the medication task records no target on the
ledger, as the contract says, and keeps the id on the screen; and the
two success affordances go through the outcome module rather than a
hand-rolled tick. The error-code catalogue lists the answers and restart
codes and no longer the retired step ones, and the session-surface guard
learns the one new deriving helper the onboarding pages read through.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 16:09:18 +02:00
1 parent 2686da2803
commit 8c7cdcfc2b
9 files changed
+78 -89

No files matched your search

+12 -6
View File
@@ -129,11 +129,15 @@ describe("S1 — the direct session-resolution set is frozen", () => {
"RSC prefetch of the session user's mood list, skipped under a switch",
"app/page.tsx":
"RSC prefetch of the session user's dashboard snapshot, skipped under a switch",
// Onboarding routing. Reads the actor's own onboarding step to redirect;
// renders no record data.
// Onboarding routing. Reads the actor's OWN setup state through
// `loadOnboardingFlowState` (a `getSession` wrapper, listed in the regex
// below) to guard the step URL and to render the screens; the three
// writes behind them refuse under a switch, so the pages read the same
// record the routes write.
"app/onboarding/[step]/page.tsx":
"redirects on the actor's onboarding step",
"app/onboarding/page.tsx": "redirects on the actor's onboarding step",
"guards the step URL on the actor's own setup state",
"app/onboarding/page.tsx":
"resumes or welcomes on the actor's own setup state",
};
/**
@@ -147,10 +151,12 @@ describe("S1 — the direct session-resolution set is frozen", () => {
* `getUnswitchedSession` (`lib/auth/acting-carrier.ts`) is listed for the
* reason a wrapper is the easiest way to disappear from a guard: it returns
* `getSession()`'s answer or null, so a caller of it derives an identity
* exactly as much as a caller of the thing it wraps.
* exactly as much as a caller of the thing it wraps. v1.39 (C2) —
* `loadOnboardingFlowState` (`lib/onboarding/load-flow-state.ts`) is the
* same shape: `getSession()` plus the caller's own setup row.
*/
const SESSION_DERIVING_HELPERS =
/\b(getSession|getUnswitchedSession|getSessionUserLocale|validateSessionFromCookieValue|validateSessionWithCreatedAt)\s*\(/;
/\b(getSession|getUnswitchedSession|getSessionUserLocale|validateSessionFromCookieValue|validateSessionWithCreatedAt|loadOnboardingFlowState)\s*\(/;
/**
* Reaching for the cookie by name is the way around the helpers. Both the
+9 -8
View File
@@ -133,6 +133,7 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
className="bg-card border-border grid grid-cols-1 gap-x-3 gap-y-2 rounded-xl border p-4 text-sm sm:grid-cols-[auto_1fr] md:p-6"
data-slot="onboarding-confirm-preferences"
>
{/* Grouped dt/dd pairs only: a stray child breaks the list's semantics. */}
{glucoseUnit ? (
<>
<dt className="text-muted-foreground">
@@ -157,15 +158,15 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
<dd>{user.timezone}</dd>
</>
) : null}
<dd className="sm:col-span-2">
<Link
href="/settings/account"
className="text-primary text-sm underline-offset-4 hover:underline"
>
{t("onboarding.flow.confirm.changeInSettings")}
</Link>
</dd>
</dl>
<p className="text-sm">
<Link
href="/settings/account"
className="text-primary underline-offset-4 hover:underline"
>
{t("onboarding.flow.confirm.changeInSettings")}
</Link>
</p>
{forSomeoneElse ? (
<div className="space-y-4" data-slot="onboarding-confirm-managed">
+8 -13
View File
@@ -18,6 +18,7 @@ import {
import { Button } from "@/components/ui/button";
import { MedicalDisclaimer } from "@/components/common/medical-disclaimer";
import { SampleBriefingCard } from "@/components/onboarding/sample-briefing-card";
import { StepHeading } from "@/components/onboarding/step-heading";
import { useOnboardingAnswer } from "@/components/onboarding/use-onboarding-flow";
import { useAuth } from "@/hooks/use-auth";
import { useAccountSwitch } from "@/hooks/use-account-switch";
@@ -125,22 +126,16 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
<CheckCircle2 className="size-10" />
</span>
<header className="space-y-2">
{/* Onboarding hero H1: intentionally semibold, not the app-wide bold PageHeader H1 (UI-STANDARDS §5 hero exception). Do not sweep to font-bold. */}
<h1
<div className="mx-auto max-w-md space-y-2">
<StepHeading
id="onboarding-done-title"
tabIndex={-1}
className="text-2xl font-semibold tracking-tight"
>
{t("onboarding.done.title")}
</h1>
<p className="text-muted-foreground mx-auto max-w-md text-base leading-relaxed">
{t("onboarding.done.body")}
</p>
<p className="text-muted-foreground mx-auto max-w-md text-sm leading-relaxed">
title={t("onboarding.done.title")}
description={t("onboarding.done.body")}
/>
<p className="text-muted-foreground text-sm leading-relaxed">
{t("onboarding.done.learning")}
</p>
</header>
</div>
{/* v1.28 — the flagship AI value, made reachable at the one screen
every fresh user passes through. The daily briefing / Coach need
@@ -5,11 +5,12 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { CheckCircle2, Loader2 } from "lucide-react";
import { Loader2 } from "lucide-react";
import { MeasurementForm } from "@/components/measurements/measurement-form";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
import { MedicationWizardDialog } from "@/components/medications/wizard/medication-wizard-dialog";
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import { StepActions } from "@/components/onboarding/step-actions";
import { StepHeading } from "@/components/onboarding/step-heading";
import {
@@ -74,9 +75,6 @@ export function FirstResultScreen({ state }: { state: OnboardingStateDto }) {
state.firstResult?.task === offer?.task &&
state.firstResult?.completedAt !== null,
);
const [resultTarget, setResultTarget] = useState<string | null>(
state.firstResult?.target ?? null,
);
async function complete(target: string | null) {
if (!offer) return;
@@ -85,7 +83,6 @@ export function FirstResultScreen({ state }: { state: OnboardingStateDto }) {
step: "first-result",
firstResult: { task: offer.task, target, completed: true },
});
setResultTarget(target);
setCompleted(true);
} catch (err) {
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
@@ -123,12 +120,7 @@ export function FirstResultScreen({ state }: { state: OnboardingStateDto }) {
description={t(`onboarding.flow.first-result.${offer.task}.body`)}
/>
<TaskBody
offer={offer}
completed={completed}
resultTarget={resultTarget}
onComplete={complete}
/>
<TaskBody offer={offer} completed={completed} onComplete={complete} />
<StepActions
backHref={back ? screenHref(back) : undefined}
@@ -154,12 +146,10 @@ function targetLabel(
function TaskBody({
offer,
completed,
resultTarget,
onComplete,
}: {
offer: Offer;
completed: boolean;
resultTarget: string | null;
onComplete: (target: string | null) => Promise<void>;
}) {
switch (offer.task) {
@@ -173,11 +163,7 @@ function TaskBody({
) : null;
case "add-medication":
return (
<AddMedicationTask
completed={completed}
medicationId={resultTarget}
onComplete={onComplete}
/>
<AddMedicationTask completed={completed} onComplete={onComplete} />
);
case "log-reading":
return offer.target ? (
@@ -267,16 +253,18 @@ interface MedicationDetail {
function AddMedicationTask({
completed,
medicationId,
onComplete,
}: {
completed: boolean;
medicationId: string | null;
onComplete: (target: string | null) => Promise<void>;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const [open, setOpen] = useState(false);
// The id stays on this screen: the ledger's `target` is a source or area
// key and is null for the medication task (see `OnboardingFirstResult`),
// so a revisit after completion shows the unnamed result.
const [medicationId, setMedicationId] = useState<string | null>(null);
const detail = useQuery({
queryKey: queryKeys.medicationDetail(medicationId ?? ""),
@@ -328,7 +316,8 @@ function AddMedicationTask({
navigateOnCreate={false}
onSuccess={(id) => {
setOpen(false);
void onComplete(id);
setMedicationId(id);
void onComplete(null);
}}
/>
</div>
@@ -507,23 +496,24 @@ function LatestReadingTile({
/* ── the result ────────────────────────────────────────────────────────── */
/**
* What the task produced. The written-outcome line is the one success
* affordance the codebase renders (`src/components/outcome/`); the value
* itself sits under it in content colour, because it is the reading, the
* medication or the connection the person came for.
*/
function ResultTile({ title, detail }: { title: string; detail?: string }) {
return (
<div
className="bg-card border-border flex items-start gap-3 rounded-xl border p-4 md:p-6"
className="bg-card border-border space-y-2 rounded-xl border p-4 md:p-6"
data-slot="onboarding-first-result-done"
role="status"
>
<span
aria-hidden="true"
className="bg-primary/10 text-primary flex size-9 shrink-0 items-center justify-center rounded-full"
>
<CheckCircle2 className="size-5" />
</span>
<div className="min-w-0 space-y-1">
<p className="text-sm font-medium">{title}</p>
{detail ? <p className="text-sm">{detail}</p> : null}
</div>
<WrittenOutcomeLine
outcome="success"
message={title}
testId="onboarding-first-result-outcome"
/>
{detail ? <p className="text-sm font-medium">{detail}</p> : null}
</div>
);
}
+10 -2
View File
@@ -25,7 +25,10 @@ import {
} from "@/components/onboarding/use-onboarding-flow";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
import {
hasEnteredOnboardingFlow,
type OnboardingStateDto,
} from "@/lib/onboarding/needs";
import {
QUESTIONS,
questionAnswerBody,
@@ -95,7 +98,12 @@ export function QuestionScreen({
};
const body = questionAnswerBody(step, selected);
const back = previousScreen(state, step);
// Back from Q1 leads to the welcome screen only while the flow has not
// been entered: once Q1 is answered the front door resumes at the step
// owed, so a Back link there would bounce straight back here.
const previous = previousScreen(state, step);
const back =
previous === "welcome" && hasEnteredOnboardingFlow(state) ? null : previous;
async function persist(input: NonNullable<typeof body>) {
try {
@@ -4,6 +4,8 @@ import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { ListChecks, Loader2 } from "lucide-react";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
@@ -40,12 +42,13 @@ export function SetupCard() {
}
function showChecklist() {
const hadState = resetChecklistDismissals();
toast.success(
hadState
? t("settings.setup.checklistShown")
: t("settings.setup.checklistNothingHidden"),
);
// Something was hidden and is back: a written outcome. Nothing was
// hidden: an empty one — there was nothing to bring back.
if (resetChecklistDismissals()) {
toastWrittenOutcome("success", t("settings.setup.checklistShown"));
} else {
toastWrittenOutcome("empty", t("settings.setup.checklistNothingHidden"));
}
}
return (
-12
View File
@@ -14,7 +14,6 @@
import type { OnboardingSourceKey } from "./needs";
import {
hasEnteredOnboardingFlow,
ONBOARDING_STEP_IDS,
type OnboardingFirstResultTask,
type OnboardingNeeds,
type OnboardingStateDto,
@@ -23,17 +22,6 @@ import {
export type OnboardingScreenId = "welcome" | OnboardingStepId;
export const ONBOARDING_SCREEN_IDS: readonly OnboardingScreenId[] = [
"welcome",
...ONBOARDING_STEP_IDS,
];
export function isOnboardingScreenId(
value: string,
): value is OnboardingScreenId {
return (ONBOARDING_SCREEN_IDS as readonly string[]).includes(value);
}
/**
* The Q4 sources whose connection the browser can finish on its own — an
* OAuth handshake or a URL-plus-secret form on Settings → Integrations.
+4 -6
View File
@@ -234,15 +234,13 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"nutrient.water.invalid_day",
],
onboarding: [
"onboarding.answers.invalid",
"onboarding.answers.rateLimited",
"onboarding.complete.invalid",
"onboarding.disclaimer.invalid",
"onboarding.step.completed",
"onboarding.step.concurrent",
"onboarding.step.invalid",
"onboarding.step.outOfOrder",
"onboarding.step.rateLimited",
"onboarding.restart.invalid",
"onboarding.restart.rateLimited",
"onboarding.tour.invalid",
"onboarding.user.notFound",
],
personal_records: ["personal_records.invalid_query"],
practitioner: ["practitioner.invalid"],
+3 -3
View File
@@ -18,12 +18,12 @@ export const onboardingKeys = {
/** `POST /api/onboarding/disclaimer` — the one-time acknowledgment. */
onboardingDisclaimerMutation: () => ["onboarding", "disclaimer"] as const,
/**
* The latest reading of one type, read back after the first-result step
* The latest reading of one area, read back after the first-result step
* logged it so the screen can show the value on its tile. Rides under the
* `["measurements"]` prefix so `measurementDependentKeys` evicts it.
*/
onboardingLatestReading: (type: string) =>
["measurements", "onboarding-latest", type] as const,
onboardingLatestReading: (area: string) =>
["measurements", "onboarding-latest", area] as const,
/**
* The upcoming-visit count the checklist's "prepare the visit" row reads
* off the visits list's meta. Its own key under the `["encounters"]`