Add the six question screens of the setup flow

One question per screen, Q1 to Q6, each a chip set with a real radio or
checkbox inside every chip so the browser owns grouping and keyboard
movement, and a focus ring the five-step picker's hidden inputs never
had. The selection is pre-filled from the stored answers, "Next"
persists it through the answers route and navigates from the state the
server hands back, and Q1 is the one question without a skip.

The questions are data (which step, which answers, one or many, may it
be passed) with two pure translations beside them — stored answers into
a selection, a selection into the strict body — so the wire never sees
a chip this file did not offer. Q6 asks only about the units the ticked
areas need and sends nothing for a value the account already had.

The step page guards every URL with the step machine and stays under
/onboarding/, which is what the first-run redirect lets through.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 15:15:24 +02:00
1 parent 1c2e7c29de
commit 7b279aec72
8 files changed
+956

No files matched your search

+76
View File
@@ -4643,6 +4643,82 @@
"skip": "Skip for now",
"againBody": "Your setup is finished. Answer the questions again to change what HealthLog shows first, or go to the dashboard.",
"setUpAgain": "Set up again"
},
"who": {
"title": "Who is this record for?",
"body": "The answers that follow apply to this record.",
"options": {
"me": "Me",
"someone-else": "Someone I look after",
"both": "Both"
},
"hints": {
"someone-else": "A child or a parent, for example. Their profile is created at the end.",
"both": "Set up your own record now; the profile for the other person is offered at the end."
}
},
"areas": {
"title": "What do you want to keep an eye on?",
"body": "Pick any number. These decide which modules are switched on and what the dashboard shows first.",
"options": {
"blood-pressure": "Blood pressure",
"weight-body": "Weight and body",
"glucose": "Blood glucose",
"sleep": "Sleep and recovery",
"mood": "Mood and mental wellbeing",
"cycle": "Cycle",
"activity": "Activity and workouts",
"labs": "Lab results",
"illness": "Illnesses and symptoms"
}
},
"medication": {
"title": "Do you take medication on a schedule?",
"body": "A schedule switches on the medication module and reminders.",
"options": {
"yes": "Yes",
"no": "No",
"sometimes": "Sometimes"
}
},
"sources": {
"title": "Where do your readings come from today?",
"body": "Pick any number. One connection is offered at the end; none is required.",
"options": {
"manual": "I type them in",
"apple-health": "Apple Health on my iPhone",
"withings": "Withings",
"oura": "Oura",
"whoop": "WHOOP",
"polar": "Polar",
"fitbit": "Fitbit",
"strava": "Strava",
"nightscout": "Nightscout",
"file": "A file I already have"
},
"hints": {
"apple-health": "Synced by the iOS app, or imported from an export.",
"file": "Imported under Settings once you are done here."
}
},
"visit": {
"title": "Is there a doctor's visit coming up?",
"body": "A visit switches on the doctor report and adds preparing for it to your checklist.",
"options": {
"within-a-month": "Yes, within a month",
"later": "Later",
"no": "No"
}
},
"units": {
"title": "Which units do you use?",
"body": "Readings are stored once and shown in the unit you choose. You can change it later under Settings.",
"glucoseLegend": "Blood glucose",
"weightLegend": "Weight and height",
"options": {
"metric": "kg and cm",
"imperial": "lb and ft/in"
}
}
}
},
+102
View File
@@ -0,0 +1,102 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { OnboardingShell } from "@/components/onboarding/onboarding-shell";
import { QuestionScreen } from "@/components/onboarding/question-screen";
import { UnitsScreen } from "@/components/onboarding/units-screen";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { loadOnboardingFlowState } from "@/lib/onboarding/load-flow-state";
import {
ONBOARDING_STEP_IDS,
type OnboardingStateDto,
type OnboardingStepId,
} from "@/lib/onboarding/needs";
import { isQuestionStepId } from "@/lib/onboarding/question-config";
import {
canVisitScreen,
resumeScreen,
stepCounter,
} from "@/lib/onboarding/wizard-steps";
/**
* v1.39 (C2) — one setup screen per step id.
*
* /onboarding/who … /onboarding/visit the five questions
* /onboarding/units Q6, when it applies
* /onboarding/confirm the module map and the profile
* /onboarding/first-result the one task the flow ends on
* /onboarding/done the exit
*
* The URL is guarded by the step machine: going back is always allowed,
* jumping ahead bounces to the screen the flow actually owes, and a screen
* this flow does not contain (units for someone with no unit-bearing area)
* bounces the same way. Everything lives under `/onboarding/`, which is what
* the proxy's first-run redirect lets through — a segment outside it would
* loop.
*
* Server component: the state is loaded once per request (`cache`), the
* screens that hold a selection are the client parts.
*/
export const dynamicParams = false;
export function generateStaticParams() {
return ONBOARDING_STEP_IDS.map((step) => ({ step }));
}
interface PageProps {
params: Promise<{ step: string }>;
}
function asStepId(value: string): OnboardingStepId | null {
return (ONBOARDING_STEP_IDS as readonly string[]).includes(value)
? (value as OnboardingStepId)
: null;
}
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { step } = await params;
const screen = asStepId(step);
const flow = screen ? await loadOnboardingFlowState() : null;
if (!screen || !flow) return {};
const locale = await resolveServerLocale({ userLocale: flow.userLocale });
const { t } = getServerTranslator(locale);
const counter = stepCounter(flow.state, screen);
const name = t(`onboarding.steps.${screen}`);
return {
title: counter ? `${t("onboarding.shell.stepOf", counter)}: ${name}` : name,
};
}
export default async function OnboardingStepPage({ params }: PageProps) {
const { step } = await params;
const screen = asStepId(step);
if (!screen) notFound();
const flow = await loadOnboardingFlowState();
if (!flow) redirect("/auth/login");
const { state, userLocale } = flow;
if (!canVisitScreen(state, screen)) {
const resume = resumeScreen(state);
redirect(resume === "welcome" ? "/onboarding" : `/onboarding/${resume}`);
}
return (
<OnboardingShell screen={screen} state={state} userLocale={userLocale}>
{renderScreen(screen, state)}
</OnboardingShell>
);
}
function renderScreen(screen: OnboardingStepId, state: OnboardingStateDto) {
if (isQuestionStepId(screen)) {
return <QuestionScreen step={screen} state={state} />;
}
if (screen === "units") {
return <UnitsScreen state={state} />;
}
notFound();
}
@@ -0,0 +1,76 @@
/**
* v1.39 (C2) — the chip question screen, as the server paints it.
*
* Vitest runs without a DOM here, so what is pinned is the first paint: the
* stored answer is pre-selected, Q1 cannot be passed and cannot continue
* without an answer, every other question can be passed, and a real radio or
* checkbox sits inside each chip so the browser owns the grouping. The click
* path is the e2e journey's.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import {
defaultOnboardingSteps,
emptyOnboardingNeeds,
type OnboardingStateDto,
} from "@/lib/onboarding/needs";
import { QuestionScreen } from "../question-screen";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
function state(
needs: Partial<OnboardingStateDto["needs"]>,
): OnboardingStateDto {
return {
steps: defaultOnboardingSteps(),
needs: { ...emptyOnboardingNeeds(), ...needs },
completedAt: null,
firstResult: null,
};
}
function render(step: "who" | "areas" | "visit", needs = {}) {
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">
<QuestionScreen step={step} state={state(needs)} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<QuestionScreen>", () => {
it("pre-selects the stored answer", () => {
const html = render("areas", { areas: ["glucose", "labs"] });
expect(html).toContain('data-value="glucose" data-checked="true"');
expect(html).toContain('data-value="labs" data-checked="true"');
expect(html).toContain('data-value="mood" data-checked="false"');
});
it("uses radios for one answer and checkboxes for many", () => {
expect(render("who")).toContain('type="radio"');
expect(render("who")).not.toContain('type="checkbox"');
expect(render("areas")).toContain('type="checkbox"');
});
it("gives Q1 no skip and no way on without an answer", () => {
const html = render("who");
expect(html).not.toContain('data-slot="onboarding-skip"');
expect(html).toMatch(/data-slot="onboarding-next"[^>]*disabled/);
});
it("lets every other question be passed", () => {
expect(render("visit")).toContain('data-slot="onboarding-skip"');
});
it("renders no raw key", () => {
for (const step of ["who", "areas", "visit"] as const) {
expect(render(step)).not.toMatch(/onboarding\.flow\./);
}
});
});
+82
View File
@@ -0,0 +1,82 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* One answer chip of a setup question: a real radio or checkbox inside a
* label, so the browser owns selection, grouping and keyboard movement
* (arrow keys across a radio group, space on a checkbox), and the chip is
* only the paint.
*
* The input is visually hidden and the label carries the focus ring through
* `has-[:focus-visible]`, because a hidden control with no visible focus is
* how a keyboard user loses their place in a chip grid — the five-step
* wizard's picker had exactly that gap.
*/
export function ChoiceChip({
name,
value,
kind,
checked,
onChange,
label,
hint,
Icon,
}: {
name: string;
value: string;
kind: "single" | "multi";
checked: boolean;
onChange: (value: string) => void;
label: string;
hint?: string;
Icon?: LucideIcon;
}) {
return (
<label
className={cn(
"border-border bg-card flex min-h-11 cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors duration-150 ease-out motion-reduce:transition-none",
"hover:border-primary/50",
"has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-offset-2",
checked && "border-primary bg-primary/5",
)}
data-slot="onboarding-choice"
data-value={value}
data-checked={checked}
>
<input
type={kind === "single" ? "radio" : "checkbox"}
name={name}
value={value}
checked={checked}
onChange={() => onChange(value)}
className="sr-only"
/>
{Icon ? (
<span
aria-hidden="true"
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-full transition-colors",
checked
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
>
<Icon className="size-4" />
</span>
) : null}
<span className="min-w-0 flex-1">
<span className="text-foreground block text-sm leading-tight font-medium">
{label}
</span>
{hint ? (
<span className="text-muted-foreground mt-0.5 block text-xs">
{hint}
</span>
) : null}
</span>
</label>
);
}
@@ -0,0 +1,166 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {
Activity,
Brain,
CalendarHeart,
Droplets,
FlaskConical,
HeartPulse,
Moon,
Scale,
Thermometer,
type LucideIcon,
} from "lucide-react";
import { ChoiceChip } from "@/components/onboarding/choice-chip";
import { StepActions } from "@/components/onboarding/step-actions";
import { StepHeading } from "@/components/onboarding/step-heading";
import {
screenHref,
useOnboardingAnswer,
} 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 {
QUESTIONS,
questionAnswerBody,
questionOptionLabelKey,
questionPrefill,
questionSkipBody,
type QuestionStepId,
} from "@/lib/onboarding/question-config";
import { nextScreen, previousScreen } from "@/lib/onboarding/wizard-steps";
/**
* v1.39 (C2) — one question per screen, Q1 to Q5.
*
* The chip set is the whole screen (design spec §Principles 3: one screen,
* one decision). The selection lives in component state while the screen is
* open, pre-filled from the stored answers so a returning person sees what
* they said; "Next" persists it through the answers route and navigates from
* the state the server hands back, so "where next" is the step machine's
* reading of the ledger the server wrote, not this screen's guess.
*
* Q1 is required and has no skip; every other question may be passed, and a
* pass records the pass without touching the stored answer.
*/
const AREA_ICONS: Readonly<Record<string, LucideIcon>> = {
"blood-pressure": HeartPulse,
"weight-body": Scale,
glucose: Droplets,
sleep: Moon,
mood: Brain,
cycle: CalendarHeart,
activity: Activity,
labs: FlaskConical,
illness: Thermometer,
};
/** Answers that carry a second line under the label. */
const OPTION_HINTS: Readonly<Record<string, string>> = {
"who.someone-else": "onboarding.flow.who.hints.someone-else",
"who.both": "onboarding.flow.who.hints.both",
"sources.apple-health": "onboarding.flow.sources.hints.apple-health",
"sources.file": "onboarding.flow.sources.hints.file",
};
export function QuestionScreen({
step,
state,
}: {
step: QuestionStepId;
state: OnboardingStateDto;
}) {
const { t } = useTranslations();
const router = useRouter();
const question = QUESTIONS[step];
const answer = useOnboardingAnswer();
const [selected, setSelected] = useState<string[]>(() =>
questionPrefill(step, state.needs),
);
const choose = (value: string) => {
setSelected((prev) => {
if (question.kind === "single") return [value];
return prev.includes(value)
? prev.filter((v) => v !== value)
: [...prev, value];
});
};
const body = questionAnswerBody(step, selected);
const back = previousScreen(state, step);
async function persist(input: NonNullable<typeof body>) {
try {
const written = await answer.mutateAsync(input);
router.push(screenHref(nextScreen(written, step) ?? "confirm"));
} catch (err) {
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
}
}
const titleId = `onboarding-${step}-title`;
return (
<section aria-labelledby={titleId} className="space-y-6">
<StepHeading
id={titleId}
title={t(`onboarding.flow.${step}.title`)}
description={t(`onboarding.flow.${step}.body`)}
/>
<fieldset
className={
step === "areas"
? "grid grid-cols-1 gap-3 sm:grid-cols-2"
: "grid grid-cols-1 gap-3"
}
data-slot={`onboarding-question-${step}`}
>
<legend className="sr-only">
{t(`onboarding.flow.${step}.title`)}
</legend>
{question.options.map((option) => {
const hintKey = OPTION_HINTS[`${step}.${option}`];
return (
<ChoiceChip
key={option}
name={`onboarding-${step}`}
value={option}
kind={question.kind}
checked={selected.includes(option)}
onChange={choose}
label={t(questionOptionLabelKey(step, option))}
hint={hintKey ? t(hintKey) : undefined}
Icon={step === "areas" ? AREA_ICONS[option] : undefined}
/>
);
})}
</fieldset>
<StepActions
backHref={back ? screenHref(back) : undefined}
onSkip={
question.skippable
? () =>
void persist(
questionSkipBody(step as Exclude<QuestionStepId, "who">),
)
: undefined
}
onNext={() => {
if (body) void persist(body);
}}
nextDisabled={body === null}
pending={answer.isPending}
/>
</section>
);
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { ChoiceChip } from "@/components/onboarding/choice-chip";
import { StepActions } from "@/components/onboarding/step-actions";
import { StepHeading } from "@/components/onboarding/step-heading";
import {
screenHref,
useOnboardingAnswer,
} from "@/components/onboarding/use-onboarding-flow";
import { useAuth } from "@/hooks/use-auth";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
import {
unitQuestionsFor,
unitsAnswerBody,
} from "@/lib/onboarding/question-config";
import { nextScreen, previousScreen } from "@/lib/onboarding/wizard-steps";
type GlucoseUnit = "mg/dL" | "mmol/L";
type UnitPreference = "metric" | "imperial";
/**
* v1.39 (C2) — Q6, the optional units question.
*
* Shown only while an area that needs a unit is among the answers and the
* account does not already hold the preference (the step machine decides;
* this screen is never reached otherwise). Each of the two questions is
* asked only for the area that needs it, and each is pre-filled from what
* the flow recorded or, failing that, from the account's own column — so
* confirming what the account already had sends nothing for that field.
*/
export function UnitsScreen({ state }: { state: OnboardingStateDto }) {
const { t } = useTranslations();
const router = useRouter();
const { user } = useAuth();
const answer = useOnboardingAnswer();
const asked = unitQuestionsFor(state.needs);
const [glucoseUnit, setGlucoseUnit] = useState<GlucoseUnit | null>(
() =>
state.needs.units.glucoseUnit ??
(user?.glucoseUnit === "mmol/L" || user?.glucoseUnit === "mg/dL"
? user.glucoseUnit
: null),
);
const [unitPreference, setUnitPreference] = useState<UnitPreference | null>(
() => state.needs.units.unitPreference ?? user?.unitPreference ?? null,
);
const back = previousScreen(state, "units");
async function persist(
input:
ReturnType<typeof unitsAnswerBody> | { step: "units"; status: "skipped" },
) {
try {
const written = await answer.mutateAsync(input);
router.push(screenHref(nextScreen(written, "units") ?? "confirm"));
} catch (err) {
toast.error(localizedApiError(err, t, "onboarding.errorGeneric"));
}
}
return (
<section aria-labelledby="onboarding-units-title" className="space-y-6">
<StepHeading
id="onboarding-units-title"
title={t("onboarding.flow.units.title")}
description={t("onboarding.flow.units.body")}
/>
{asked.glucose ? (
<fieldset
className="grid grid-cols-2 gap-3"
data-slot="onboarding-question-glucose-unit"
>
<legend className="text-foreground mb-2 text-sm font-medium">
{t("onboarding.flow.units.glucoseLegend")}
</legend>
{(["mg/dL", "mmol/L"] as const).map((option) => (
<ChoiceChip
key={option}
name="onboarding-glucose-unit"
value={option}
kind="single"
checked={glucoseUnit === option}
onChange={() => setGlucoseUnit(option)}
label={option}
/>
))}
</fieldset>
) : null}
{asked.weight ? (
<fieldset
className="grid grid-cols-2 gap-3"
data-slot="onboarding-question-unit-preference"
>
<legend className="text-foreground mb-2 text-sm font-medium">
{t("onboarding.flow.units.weightLegend")}
</legend>
{(["metric", "imperial"] as const).map((option) => (
<ChoiceChip
key={option}
name="onboarding-unit-preference"
value={option}
kind="single"
checked={unitPreference === option}
onChange={() => setUnitPreference(option)}
label={t(`onboarding.flow.units.options.${option}`)}
/>
))}
</fieldset>
) : null}
<StepActions
backHref={back ? screenHref(back) : undefined}
onSkip={() => void persist({ step: "units", status: "skipped" })}
onNext={() =>
void persist(unitsAnswerBody({ asked, glucoseUnit, unitPreference }))
}
pending={answer.isPending}
/>
</section>
);
}
@@ -0,0 +1,137 @@
/**
* v1.39 (C2) — the chip questions as data, and the two translations the
* screen relies on: stored answers → chips to pre-select, and a selection →
* the strict body the answers route accepts.
*/
import { describe, expect, it } from "vitest";
import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry";
import { onboardingAnswerSchema } from "@/lib/validations/onboarding-needs";
import { emptyOnboardingNeeds, ONBOARDING_SOURCE_KEYS } from "../needs";
import {
QUESTION_STEP_IDS,
QUESTIONS,
questionAnswerBody,
questionPrefill,
questionSkipBody,
unitQuestionsFor,
unitsAnswerBody,
} from "../question-config";
describe("QUESTIONS", () => {
it("offers every value of each server vocabulary, in the server's order", () => {
expect(QUESTIONS.areas.options).toEqual(ONBOARDING_AREA_KEYS);
expect(QUESTIONS.sources.options).toEqual(ONBOARDING_SOURCE_KEYS);
expect(QUESTIONS.who.options).toEqual(["me", "someone-else", "both"]);
});
it("makes Q1 the one question that cannot be passed", () => {
expect(QUESTIONS.who.skippable).toBe(false);
for (const step of QUESTION_STEP_IDS.filter((s) => s !== "who")) {
expect(QUESTIONS[step].skippable).toBe(true);
}
});
});
describe("questionPrefill", () => {
it("reads the stored answer of each step back as a selection", () => {
const needs = {
...emptyOnboardingNeeds(),
recordTarget: "both" as const,
areas: ["glucose" as const, "mood" as const],
medication: "sometimes" as const,
sources: ["oura" as const],
visit: "later" as const,
};
expect(questionPrefill("who", needs)).toEqual(["both"]);
expect(questionPrefill("areas", needs)).toEqual(["glucose", "mood"]);
expect(questionPrefill("medication", needs)).toEqual(["sometimes"]);
expect(questionPrefill("sources", needs)).toEqual(["oura"]);
expect(questionPrefill("visit", needs)).toEqual(["later"]);
});
it("is empty for a question never answered", () => {
const needs = emptyOnboardingNeeds();
for (const step of QUESTION_STEP_IDS) {
expect(questionPrefill(step, needs)).toEqual([]);
}
});
});
describe("questionAnswerBody", () => {
it("builds a body the answers route accepts, for every step", () => {
const bodies = [
questionAnswerBody("who", ["me"]),
questionAnswerBody("areas", ["glucose", "labs"]),
questionAnswerBody("medication", ["yes"]),
questionAnswerBody("sources", ["manual", "withings"]),
questionAnswerBody("visit", ["within-a-month"]),
];
for (const body of bodies) {
expect(body).not.toBeNull();
expect(onboardingAnswerSchema.safeParse(body).success).toBe(true);
}
});
it("is null for a single-answer question with nothing chosen", () => {
expect(questionAnswerBody("who", [])).toBeNull();
expect(questionAnswerBody("medication", [])).toBeNull();
expect(questionAnswerBody("visit", [])).toBeNull();
});
it("sends an empty list for a many-answer question with nothing chosen", () => {
expect(questionAnswerBody("areas", [])).toEqual({
step: "areas",
areas: [],
});
});
it("drops a value the step's vocabulary does not contain", () => {
expect(questionAnswerBody("areas", ["glucose", "no-such-area"])).toEqual({
step: "areas",
areas: ["glucose"],
});
expect(questionAnswerBody("who", ["nobody"])).toBeNull();
});
it("passes a step with the skip arm and nothing else", () => {
const body = questionSkipBody("visit");
expect(body).toEqual({ step: "visit", status: "skipped" });
expect(onboardingAnswerSchema.safeParse(body).success).toBe(true);
});
});
describe("units", () => {
it("asks only about the units the ticked areas need", () => {
expect(
unitQuestionsFor({ ...emptyOnboardingNeeds(), areas: ["glucose"] }),
).toEqual({ glucose: true, weight: false });
expect(
unitQuestionsFor({
...emptyOnboardingNeeds(),
areas: ["weight-body", "glucose"],
}),
).toEqual({ glucose: true, weight: true });
});
it("sends only the units that were asked and chosen", () => {
const body = unitsAnswerBody({
asked: { glucose: true, weight: false },
glucoseUnit: "mmol/L",
unitPreference: "imperial",
});
expect(body).toEqual({ step: "units", units: { glucoseUnit: "mmol/L" } });
expect(onboardingAnswerSchema.safeParse(body).success).toBe(true);
});
it("sends an empty answer when nothing was chosen, which leaves the columns alone", () => {
expect(
unitsAnswerBody({
asked: { glucose: true, weight: true },
glucoseUnit: null,
unitPreference: null,
}),
).toEqual({ step: "units", units: {} });
});
});
+186
View File
@@ -0,0 +1,186 @@
/**
* v1.39 (C2) — the five chip questions, as data.
*
* Which step asks what, with which answers, whether one answer or many, and
* whether it may be skipped — and the two pure translations the screen needs:
* the stored answers into a selection to prefill, and a selection into the
* body `PATCH /api/onboarding/answers` accepts. Both are here rather than in
* the component so they can be pinned without a DOM, and so the body that
* reaches the wire is built field by field from a closed vocabulary rather
* than from whatever the chips happened to hold.
*
* The answer vocabularies are the server's (`./needs`, the registry); this
* file only orders them and names their labels. Units (Q6) is not a chip
* set and has its own screen.
*/
import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry";
import type { OnboardingAnswerInput } from "@/lib/validations/onboarding-needs";
import {
ONBOARDING_MEDICATION_ANSWERS,
ONBOARDING_RECORD_TARGETS,
ONBOARDING_SOURCE_KEYS,
ONBOARDING_VISIT_ANSWERS,
type OnboardingNeeds,
} from "./needs";
export const QUESTION_STEP_IDS = [
"who",
"areas",
"medication",
"sources",
"visit",
] as const;
export type QuestionStepId = (typeof QUESTION_STEP_IDS)[number];
export function isQuestionStepId(value: string): value is QuestionStepId {
return (QUESTION_STEP_IDS as readonly string[]).includes(value);
}
export interface QuestionDefinition {
/** One answer (radio) or any number (checkbox). */
kind: "single" | "multi";
/** Q1 is the one question that cannot be passed. */
skippable: boolean;
/** The answer values, in the order they are offered. */
options: readonly string[];
}
export const QUESTIONS: Readonly<Record<QuestionStepId, QuestionDefinition>> =
Object.freeze({
who: {
kind: "single",
skippable: false,
options: ONBOARDING_RECORD_TARGETS,
},
areas: { kind: "multi", skippable: true, options: ONBOARDING_AREA_KEYS },
medication: {
kind: "single",
skippable: true,
options: ONBOARDING_MEDICATION_ANSWERS,
},
sources: {
kind: "multi",
skippable: true,
options: ONBOARDING_SOURCE_KEYS,
},
visit: {
kind: "single",
skippable: true,
options: ONBOARDING_VISIT_ANSWERS,
},
});
/** The i18n key of one answer chip. */
export function questionOptionLabelKey(
step: QuestionStepId,
option: string,
): string {
return `onboarding.flow.${step}.options.${option}`;
}
/** The stored answers of one step, as the chips to pre-select. */
export function questionPrefill(
step: QuestionStepId,
needs: OnboardingNeeds,
): string[] {
switch (step) {
case "who":
return needs.recordTarget ? [needs.recordTarget] : [];
case "areas":
return [...needs.areas];
case "medication":
return needs.medication ? [needs.medication] : [];
case "sources":
return [...needs.sources];
case "visit":
return needs.visit ? [needs.visit] : [];
}
}
function only<T extends string>(
values: readonly T[],
selected: readonly string[],
): T[] {
return selected.filter((value): value is T =>
(values as readonly string[]).includes(value),
);
}
/**
* The body for one answered step, or null when the selection cannot be an
* answer (Q1 with nothing chosen). Every arm names its field; a value that is
* not in the step's vocabulary is dropped rather than sent, so the strict
* server schema never sees a chip this file did not offer.
*/
export function questionAnswerBody(
step: QuestionStepId,
selected: readonly string[],
): OnboardingAnswerInput | null {
switch (step) {
case "who": {
const [recordTarget] = only(ONBOARDING_RECORD_TARGETS, selected);
return recordTarget ? { step, recordTarget } : null;
}
case "areas":
return { step, areas: only(ONBOARDING_AREA_KEYS, selected) };
case "medication": {
const [medication] = only(ONBOARDING_MEDICATION_ANSWERS, selected);
return medication ? { step, medication } : null;
}
case "sources":
return { step, sources: only(ONBOARDING_SOURCE_KEYS, selected) };
case "visit": {
const [visit] = only(ONBOARDING_VISIT_ANSWERS, selected);
return visit ? { step, visit } : null;
}
}
}
/** The body that passes one skippable step without answering it. */
export function questionSkipBody(
step: Exclude<QuestionStepId, "who">,
): OnboardingAnswerInput {
return { step, status: "skipped" };
}
/**
* Q6 — which of the two unit questions this flow asks. Glucose needs a
* glucose unit, weight needs the metric/imperial preference; an area not
* ticked is not asked about, so a person who chose only glucose never sees
* "kg or lb".
*/
export function unitQuestionsFor(needs: OnboardingNeeds): {
glucose: boolean;
weight: boolean;
} {
return {
glucose: needs.areas.includes("glucose"),
weight: needs.areas.includes("weight-body"),
};
}
/**
* The Q6 body. Only the questions that were asked reach the wire, and only
* with a chosen value: an omitted field leaves the account's own column
* alone, which is what "never re-ask a value the account holds" needs when
* a person confirms the one they already had.
*/
export function unitsAnswerBody(input: {
asked: { glucose: boolean; weight: boolean };
glucoseUnit: "mg/dL" | "mmol/L" | null;
unitPreference: "metric" | "imperial" | null;
}): OnboardingAnswerInput {
const units: {
glucoseUnit?: "mg/dL" | "mmol/L";
unitPreference?: "metric" | "imperial";
} = {};
if (input.asked.glucose && input.glucoseUnit) {
units.glucoseUnit = input.glucoseUnit;
}
if (input.asked.weight && input.unitPreference) {
units.unitPreference = input.unitPreference;
}
return { step: "units", units };
}