diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts
index dd822043e..855b7c650 100644
--- a/src/__tests__/session-surface-guard.test.ts
+++ b/src/__tests__/session-surface-guard.test.ts
@@ -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
diff --git a/src/components/onboarding/confirm-screen.tsx b/src/components/onboarding/confirm-screen.tsx
index 6734638d4..5f3338f96 100644
--- a/src/components/onboarding/confirm-screen.tsx
+++ b/src/components/onboarding/confirm-screen.tsx
@@ -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 ? (
<>
@@ -157,15 +158,15 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
{/* v1.28 — the flagship AI value, made reachable at the one screen
every fresh user passes through. The daily briefing / Coach need
diff --git a/src/components/onboarding/first-result-screen.tsx b/src/components/onboarding/first-result-screen.tsx
index f818654a3..9e4a22ed7 100644
--- a/src/components/onboarding/first-result-screen.tsx
+++ b/src/components/onboarding/first-result-screen.tsx
@@ -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(
- 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`)}
/>
-
+ Promise;
}) {
switch (offer.task) {
@@ -173,11 +163,7 @@ function TaskBody({
) : null;
case "add-medication":
return (
-
+
);
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;
}) {
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(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);
}}
/>
@@ -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 (
-
-
-
-
-
{title}
- {detail ?
{detail}
: null}
-
+
+ {detail ?
{detail}
: null}
);
}
diff --git a/src/components/onboarding/question-screen.tsx b/src/components/onboarding/question-screen.tsx
index 336794c2c..a151437ee 100644
--- a/src/components/onboarding/question-screen.tsx
+++ b/src/components/onboarding/question-screen.tsx
@@ -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) {
try {
diff --git a/src/components/settings/account-section/setup-card.tsx b/src/components/settings/account-section/setup-card.tsx
index 1979e2582..47d19d28d 100644
--- a/src/components/settings/account-section/setup-card.tsx
+++ b/src/components/settings/account-section/setup-card.tsx
@@ -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 (
diff --git a/src/lib/onboarding/wizard-steps.ts b/src/lib/onboarding/wizard-steps.ts
index d53b2210c..ee4ae3f09 100644
--- a/src/lib/onboarding/wizard-steps.ts
+++ b/src/lib/onboarding/wizard-steps.ts
@@ -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.
diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts
index 5f8c6037b..7a9b7d909 100644
--- a/src/lib/openapi/error-codes.ts
+++ b/src/lib/openapi/error-codes.ts
@@ -234,15 +234,13 @@ export const ERROR_CODE_CATALOGUE: Readonly> =
"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"],
diff --git a/src/lib/query-keys/onboarding.ts b/src/lib/query-keys/onboarding.ts
index 27579b56f..9dedec5e8 100644
--- a/src/lib/query-keys/onboarding.ts
+++ b/src/lib/query-keys/onboarding.ts
@@ -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"]`