diff --git a/messages/en.json b/messages/en.json index 20c28a1a5..551ea6af8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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" + } } } }, diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx new file mode 100644 index 000000000..d4d0ded2a --- /dev/null +++ b/src/app/onboarding/[step]/page.tsx @@ -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 { + 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 ( + + {renderScreen(screen, state)} + + ); +} + +function renderScreen(screen: OnboardingStepId, state: OnboardingStateDto) { + if (isQuestionStepId(screen)) { + return ; + } + if (screen === "units") { + return ; + } + notFound(); +} diff --git a/src/components/onboarding/__tests__/question-screen.test.tsx b/src/components/onboarding/__tests__/question-screen.test.tsx new file mode 100644 index 000000000..142c18e1d --- /dev/null +++ b/src/components/onboarding/__tests__/question-screen.test.tsx @@ -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 { + return { + steps: defaultOnboardingSteps(), + needs: { ...emptyOnboardingNeeds(), ...needs }, + completedAt: null, + firstResult: null, + }; +} + +function render(step: "who" | "areas" | "visit", needs = {}) { + return renderToStaticMarkup( + + + + + , + ); +} + +describe("", () => { + 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\./); + } + }); +}); diff --git a/src/components/onboarding/choice-chip.tsx b/src/components/onboarding/choice-chip.tsx new file mode 100644 index 000000000..c2e67c6b7 --- /dev/null +++ b/src/components/onboarding/choice-chip.tsx @@ -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 ( + + ); +} diff --git a/src/components/onboarding/question-screen.tsx b/src/components/onboarding/question-screen.tsx new file mode 100644 index 000000000..336794c2c --- /dev/null +++ b/src/components/onboarding/question-screen.tsx @@ -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> = { + "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> = { + "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(() => + 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) { + 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 ( +
+ + +
+ + {t(`onboarding.flow.${step}.title`)} + + {question.options.map((option) => { + const hintKey = OPTION_HINTS[`${step}.${option}`]; + return ( + + ); + })} +
+ + + void persist( + questionSkipBody(step as Exclude), + ) + : undefined + } + onNext={() => { + if (body) void persist(body); + }} + nextDisabled={body === null} + pending={answer.isPending} + /> +
+ ); +} diff --git a/src/components/onboarding/units-screen.tsx b/src/components/onboarding/units-screen.tsx new file mode 100644 index 000000000..b2fec48bd --- /dev/null +++ b/src/components/onboarding/units-screen.tsx @@ -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( + () => + state.needs.units.glucoseUnit ?? + (user?.glucoseUnit === "mmol/L" || user?.glucoseUnit === "mg/dL" + ? user.glucoseUnit + : null), + ); + const [unitPreference, setUnitPreference] = useState( + () => state.needs.units.unitPreference ?? user?.unitPreference ?? null, + ); + + const back = previousScreen(state, "units"); + + async function persist( + input: + ReturnType | { 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 ( +
+ + + {asked.glucose ? ( +
+ + {t("onboarding.flow.units.glucoseLegend")} + + {(["mg/dL", "mmol/L"] as const).map((option) => ( + setGlucoseUnit(option)} + label={option} + /> + ))} +
+ ) : null} + + {asked.weight ? ( +
+ + {t("onboarding.flow.units.weightLegend")} + + {(["metric", "imperial"] as const).map((option) => ( + setUnitPreference(option)} + label={t(`onboarding.flow.units.options.${option}`)} + /> + ))} +
+ ) : null} + + void persist({ step: "units", status: "skipped" })} + onNext={() => + void persist(unitsAnswerBody({ asked, glucoseUnit, unitPreference })) + } + pending={answer.isPending} + /> +
+ ); +} diff --git a/src/lib/onboarding/__tests__/question-config.test.ts b/src/lib/onboarding/__tests__/question-config.test.ts new file mode 100644 index 000000000..0151f4204 --- /dev/null +++ b/src/lib/onboarding/__tests__/question-config.test.ts @@ -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: {} }); + }); +}); diff --git a/src/lib/onboarding/question-config.ts b/src/lib/onboarding/question-config.ts new file mode 100644 index 000000000..547b28cd0 --- /dev/null +++ b/src/lib/onboarding/question-config.ts @@ -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> = + 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( + 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, +): 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 }; +}