Apply the review of the setup flow: the child's answers reach the child

"Someone I look after" derived the child's answers onto the guardian's
own record. The completion now takes the managed record the profile
step just created, checks the same access rule the guardian's modules
route applies, and lands the same derivation and dashboard seed on that
record through the same record-keyed write; the guardian's own flow is
stamped complete and latched without touching its map. Three
integration cases pin it, and the child journey reads the child's map
through a real switch — it asserted the guardian's map before, which
pinned the wrong record.

Also from the review: the checklist's visit row read a meta the visits
list never publishes and now counts its body; the first-result tile
read the measurements list as an array and now shows the reading; the
demo allowlist admits the disclaimer and tour writes, with a structural
test over every onboarding route file; restart clears the old first
result, and the screen trusts the ledger before painting a result as
done; the headroom sweep's raw-key check matches the flow's own key
shapes; the demo migration script stops copying the dropped column; the
questions-to-pass rule and the shell's onboarding-path rule are pure and
pinned; the baseline form composes the shared action row; the schema
diff loses the unrelated formatting churn.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 18:19:34 +02:00
1 parent 5c5a592177
commit 6adbee581e
27 files changed
+594 -146

No files matched your search

+2 -2
View File
@@ -28,6 +28,7 @@ import { OfflineBanner } from "./offline-banner";
import { SharedRecordBanner } from "./shared-record-banner";
import { SharedRecordUnavailable } from "./shared-record-unavailable";
import { ModulePageGate } from "@/components/layout/module-page-gate";
import { isOnboardingPathname } from "@/lib/onboarding/wizard-steps";
import { RecordScopeHydrationGate } from "./record-scope-hydration-gate";
import { SidebarNav } from "./sidebar-nav";
import { TopBar } from "./top-bar";
@@ -133,8 +134,7 @@ export function AuthShell({
// The whole setup flow, not only its front door: every screen lives under
// `/onboarding/<step>` (v1.39 C2), and an exact match here put the
// questions inside the full app chrome, sidebar and all.
const isOnboardingPage =
pathname === "/onboarding" || pathname.startsWith("/onboarding/");
const isOnboardingPage = isOnboardingPathname(pathname);
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
// v1.9.0 — the document-level scrollbar-gutter (globals.css) is reserved
+9 -45
View File
@@ -1,11 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
BaselineFields,
type BaselineFieldErrors,
@@ -29,6 +26,7 @@ import {
describeBaselineSaveOutcome,
putBaselineProfile,
} from "@/components/onboarding/baseline-form-utils";
import { StepActions } from "@/components/onboarding/step-actions";
/**
* The profile half of the confirm screen: display name, height, date of
@@ -55,7 +53,7 @@ import {
* filled later from Settings.
*/
export interface BaselineInitialValues {
interface BaselineInitialValues {
heightCm: number | null;
dateOfBirth: string | null;
gender: string | null;
@@ -225,47 +223,13 @@ export function BaselineForm({
disabled={saving}
/>
<div
className="flex flex-wrap items-center justify-between gap-2 pt-2"
data-slot="onboarding-step-actions"
>
{backHref ? (
<Button asChild variant="ghost" className="min-h-11 min-w-11">
<Link href={backHref} data-slot="onboarding-back">
{t("onboarding.shell.back")}
</Link>
</Button>
) : (
<span aria-hidden="true" />
)}
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={() => advance({ saveProfile: false })}
disabled={saving || confirming}
className="min-h-11 min-w-11"
data-slot="onboarding-skip"
>
{t("onboarding.shell.skip")}
</Button>
<Button
type="button"
onClick={() => advance({ saveProfile: true })}
disabled={saving || confirming}
className="min-h-11 min-w-11"
data-slot="onboarding-next"
>
{saving || confirming ? (
<Loader2
aria-hidden="true"
className="size-4 animate-spin motion-reduce:animate-none"
/>
) : null}
{t("onboarding.flow.confirm.cta")}
</Button>
</div>
</div>
<StepActions
backHref={backHref}
onSkip={() => void advance({ saveProfile: false })}
onNext={() => void advance({ saveProfile: true })}
nextLabel={t("onboarding.flow.confirm.cta")}
pending={saving || confirming}
/>
</section>
);
}
+15 -16
View File
@@ -20,15 +20,12 @@ 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 {
ONBOARDING_SKIPPABLE_STEP_IDS,
type OnboardingStateDto,
} from "@/lib/onboarding/needs";
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
import {
firstResultApplies,
nextScreen,
previousScreen,
questionScreens,
questionsToPassBeforeConfirm,
} from "@/lib/onboarding/wizard-steps";
/**
@@ -71,7 +68,13 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
const unitPreference =
needs.units.unitPreference ?? user?.unitPreference ?? "metric";
async function finish() {
/**
* Complete the flow. `managedRecordId` is the profile "someone I look
* 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.
*/
async function finish(managedRecordId?: string) {
if (finishing) return;
setFinishing(true);
try {
@@ -79,16 +82,12 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
// 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.
const shown = new Set<string>(questionScreens(state));
for (const id of ONBOARDING_SKIPPABLE_STEP_IDS) {
if (id === "first-result") continue;
const pending =
state.steps.find((step) => step.id === id)?.status === "pending";
if (!shown.has(id) && pending) {
await answer.mutateAsync({ step: id, status: "skipped" });
}
for (const id of questionsToPassBeforeConfirm(state)) {
await answer.mutateAsync({ step: id, status: "skipped" });
}
const { onboarding } = await complete.mutateAsync();
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:
@@ -197,7 +196,7 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
</div>
<ManagedProfileCreateForm
submitLabel={t("onboarding.flow.confirm.managedCreate")}
onCreated={() => void finish()}
onCreated={(profile) => void finish(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">
@@ -71,8 +71,13 @@ export function FirstResultScreen({ state }: { state: OnboardingStateDto }) {
const offer = chooseFirstResultTask(state.needs);
const back = previousScreen(state, "first-result");
// Completed only when the LEDGER says so as well: a restart puts the step
// back to pending, and a stored result from the previous run must not paint
// this screen as done, or "Next" would go to done without ever writing the
// step and the flow would resume here forever.
const [completed, setCompleted] = useState(
state.firstResult?.task === offer?.task &&
state.steps.find((step) => step.id === "first-result")?.status === "done" &&
state.firstResult?.task === offer?.task &&
state.firstResult?.completedAt !== null,
);
@@ -425,9 +430,10 @@ function LatestReadingTile({
queryFn: async () =>
Promise.all(
storedTypes.map((type) =>
apiGet<MeasurementRow[]>(
// The list route answers `{ measurements, meta }`, not a bare array.
apiGet<{ measurements: MeasurementRow[] }>(
`/api/measurements?type=${encodeURIComponent(type)}&limit=1`,
).then((rows) => rows[0] ?? null),
).then((page) => page.measurements[0] ?? null),
),
),
});
@@ -26,10 +26,11 @@ import {
buildChecklist,
checklistProgress,
shouldShowChecklist,
upcomingVisitCountFrom,
visibleChecklist,
type ChecklistItemId,
} from "@/lib/onboarding/checklist";
import { apiFetchEnvelope, apiGet } from "@/lib/api/api-fetch";
import { apiGet } from "@/lib/api/api-fetch";
import {
CHECKLIST_DISMISSED_ALL_KEY as DISMISSED_ALL_KEY,
CHECKLIST_DISMISSED_ITEMS_KEY as DISMISSED_ITEMS_KEY,
@@ -89,10 +90,6 @@ const ITEM_LABEL_KEYS: Record<
},
};
interface EncounterListMeta {
upcoming?: number;
}
interface IntegrationsStatus {
integrations?: Array<{ connected?: boolean; enabled?: boolean }>;
}
@@ -306,14 +303,12 @@ export function GettingStartedChecklist() {
// for it: a visit within a month. Reads the visits list's own meta count so
// the row flips done the moment one is on the calendar.
const visitAsked = user?.onboarding?.needs.visit === "within-a-month";
const { data: encountersMeta } = useQuery<EncounterListMeta>({
const { data: upcomingVisitCount } = useQuery<number>({
queryKey: queryKeys.onboardingUpcomingVisits(),
queryFn: async () => {
const { meta } = await apiFetchEnvelope<unknown, EncounterListMeta>(
"/api/encounters",
);
return meta ?? {};
},
queryFn: async () =>
upcomingVisitCountFrom(
await apiGet<{ upcoming: unknown[] }>("/api/encounters"),
),
enabled: checklistRelevant && visitAsked,
});
@@ -346,7 +341,7 @@ export function GettingStartedChecklist() {
notificationsConfigured,
insightsConfigured,
dismissedIds,
upcomingVisitCount: encountersMeta?.upcoming ?? 0,
upcomingVisitCount: upcomingVisitCount ?? 0,
// v1.39 (C1) — the setup answers order these rows: medication first
// for somebody who said they take one daily, the data-source row first
// for somebody who named a wearable. The payload resolves them for the
@@ -366,7 +361,7 @@ export function GettingStartedChecklist() {
notificationsConfigured,
insightsConfigured,
dismissedIds,
encountersMeta?.upcoming,
upcomingVisitCount,
],
);
@@ -509,6 +504,8 @@ export function GettingStartedChecklist() {
return (
<li
key={item.id}
data-item-id={item.id}
data-done={item.done}
className="hover:bg-accent/40 group flex items-center gap-3 rounded-md px-2 py-2"
>
<span
@@ -31,7 +31,7 @@ import {
* action row (`StepActions`), because what "next" persists differs per
* screen and the shell should not know.
*/
export interface OnboardingShellProps {
interface OnboardingShellProps {
screen: OnboardingScreenId;
state: OnboardingStateDto;
userLocale: string | null;
@@ -46,16 +46,17 @@ interface CompleteEnvelope {
/**
* `POST /api/onboarding/complete` — the confirm screen, and "skip for now".
*
* The body is always empty: profile values go through `PUT /api/auth/profile`
* (the canonical write, with its per-field refusals), never through the
* completion stamp's optional fields.
* Profile values never ride this body: they go through `PUT /api/auth/profile`
* (the canonical write, with its per-field refusals). The one field the
* screen sends is `managedRecordId`, after "someone I look after" created the
* profile, so the derivation lands on that record and not on the caller's.
*/
export function useOnboardingComplete() {
const queryClient = useQueryClient();
return useMutation({
mutationKey: queryKeys.onboardingCompleteMutation(),
mutationFn: async () =>
apiPost<CompleteEnvelope>("/api/onboarding/complete", {}),
mutationFn: async (input?: { managedRecordId: string }) =>
apiPost<CompleteEnvelope>("/api/onboarding/complete", input ?? {}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
},
+1 -1
View File
@@ -86,7 +86,7 @@ export function WelcomeScreen({ variant }: { variant: "fresh" | "again" }) {
if (pending || !acknowledged) return;
try {
await acknowledge.mutateAsync();
await complete.mutateAsync();
await complete.mutateAsync(undefined);
markChecklistExpanded();
router.push("/");
} catch (err) {