mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
2686da2803
commit
8c7cdcfc2b
9 files changed
+78
-89
No files matched your search
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"]`
|
||||
|
||||
Reference in new issue
Block a user