mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
1 parent
1c2e7c29de
commit
7b279aec72
8 files changed
+956
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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\./);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: {} });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user