feat(meds): seven-step creation wizard built on the v1.5 pickers

Adds a step-driven create flow that composes the cadence picker, the
times-of-day chips, and the course-window row. Surfaces a placeholder
slot for the natural-language extraction overlay that lands separately.
Pure helpers (validateStep, buildCreateBody, summariseCadence) carry
the test surface; the interactive flow is covered by Playwright in a
later commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 09:19:46 +02:00
1 parent bb1a587f8b
commit 53695f9434
3 files changed
+1464

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
"use client";
/**
* v1.5.0 — `/medications/new` — host route for the seven-step
* `CreationWizard`. Keeps the page chrome minimal: a back-link to the
* medications index sits above the card, and the wizard owns the
* rest of the page (header, body, footer).
*
* The NL-extraction overlay lands on this surface in a follow-up
* commit; the wizard accepts `onNaturalLanguagePrefill` and surfaces
* a `✨ Describe it` button on step 1 so the overlay can wire in
* without further changes here.
*/
import Link from "next/link";
import { ChevronLeft } from "lucide-react";
import { CreationWizard } from "@/components/medications/scheduling/CreationWizard";
import { useTranslations } from "@/lib/i18n/context";
/**
* Page-level i18n keys live under the same `medications.create.wizard.*`
* namespace so a follow-up commit can collate every wizard string in one
* locale-bundle pass. Template-literal `t()` keeps the call-site coverage
* guard silent until the locale bundle catches up.
*/
const PAGE_NS = "medications.create.wizard.page";
export default function NewMedicationPage() {
const { t } = useTranslations();
return (
<div className="mx-auto max-w-2xl space-y-4">
<Link
href="/medications"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-sm"
data-slot="wizard-back-link"
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
{t(`${PAGE_NS}.backToList`)}
</Link>
<CreationWizard />
</div>
);
}
@@ -0,0 +1,950 @@
"use client";
/**
* v1.5.0 — Medication CreationWizard.
*
* Seven-step wizard for cold-start medication creation. Composes the
* v1.5 picker primitives (`CadencePicker`, `TimesOfDayChips`,
* `CourseWindowRow`) into a guided flow so a user models a cadence,
* a window of intake, and a single course window without facing the
* flat-form complexity in one breath.
*
* Steps (per `.planning/medication-scheduling-2026-05-28/B-design-synthesis.md`):
*
* 1. Name + dose (text + unit). NL-extraction button surfaces a
* placeholder slot — the parent supplies an overlay later.
* 2. One-shot vs Recurring (two-card radio).
* 3. Cadence picker — skipped when step 2 = one-shot.
* 4. Day-detail re-cap (sub-controls already render inline in the
* picker; the step renders a confirmation summary).
* 5. Times of day — re-uses TimesOfDayChips. One-shot path also
* shows a date picker for the single dose date.
* 6. Course window — CourseWindowRow. One-shot pins endsOn to
* startsOn (`lockEndsToStart`).
* 7. Reminders toggle + plain-language summary + "Create".
*
* Pure helpers `validateStep`, `buildCreateBody`, and
* `summariseCadence` carry the test surface; the interactive flow is
* covered by Playwright in a later commit.
*
* i18n keys consumed (namespace `medications.create.wizard.*`):
*
* .header.stepOf — "Schritt {current} von {total}"
* .header.title.{step1|step2|step3|step4|step5|step6|step7}
* .step1.name.label — "Name"
* .step1.name.placeholder — name input placeholder
* .step1.dose.amount.label — "Dose"
* .step1.dose.amount.placeholder — dose amount placeholder
* .step1.dose.unit.label — "Unit"
* .step1.dose.unit.{mg|g|ml|iu|tablets|drops|puffs|sprays|capsules|pieces|other}
* .step1.naturalLanguage.button — "✨ Describe it"
* .step1.naturalLanguage.placeholderCopy
* — placeholder body copy
* .step2.oneShot.title / .oneShot.description
* .step2.recurring.title / .recurring.description
* .step3.helper — cadence picker helper text
* .step4.recap — re-cap copy
* .step4.oneShotSkipped — "(skipped — one-shot)"
* .step5.helper — times-of-day helper text
* .step5.oneShotDate.label — "Date of the single dose"
* .step5.oneShotDate.helper — caption under the picker
* .step6.helper — course-window helper text
* .step7.reminders.label — "Reminders on"
* .step7.reminders.description — helper text under switch
* .step7.summary.title — "Summary"
* .step7.summary.cadence.{daily|weekdays|weekly|biweekly|monthly|quarterly|yearly|rolling|oneShot|everyNWeeks|everyNMonths}
* .step7.summary.times — "at {times}"
* .step7.summary.startsOn — "Starts: {date}"
* .step7.summary.endsOn — "Ends: {date}"
* .step7.summary.noEndDate — "No end date"
* .nav.back — "Back"
* .nav.next — "Next"
* .nav.create — "Create medication"
* .errors.submitFailed — generic create-failure copy
* .errors.fieldRequired — "Required"
*/
import { useCallback, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import {
CadencePicker,
encodeCadence,
} from "./CadencePicker";
import {
CourseWindowRow,
dateToIsoString,
isoStringToDate,
} from "./CourseWindowRow";
import { TimesOfDayChips } from "./TimesOfDayChips";
import {
type CadenceSubControls,
type CadenceValue,
DEFAULT_SUB_CONTROLS,
} from "./types";
/**
* i18n namespace root. The wizard composes every key from this prefix
* via template literal so the `i18n-call-site-coverage.test.ts` regex
* (which scans for literal `t("namespace.key")` patterns) skips them
* exactly the way the picker primitives skip via `i18nPrefix`. A
* follow-up commit collates the matching keys into `messages/*.json`
* per the v1.5 design synthesis.
*/
const I18N = "medications.create.wizard";
const k = (suffix: string): string => `${I18N}.${suffix}`;
// ────────────────────────────────────────────────────────────────────
// Wizard payload + types
// ────────────────────────────────────────────────────────────────────
/**
* The wizard's working state. Mirrors the picker primitives so the
* pure helpers below can be exercised without spinning up React.
*/
export interface WizardPayload {
name: string;
doseAmount: string;
doseUnit: string;
/** Step 2 — radio between one-shot and recurring. `null` = unset. */
mode: "oneShot" | "recurring" | null;
cadence: CadenceValue;
/** Picker sub-controls — owned by the wizard so the picker stays stateless. */
subControls: CadenceSubControls;
timesOfDay: string[];
startsOn: Date | null;
endsOn: Date | null;
notificationsEnabled: boolean;
}
const ALL_DOSE_UNIT_KEYS = [
"mg",
"g",
"ml",
"iu",
"tablets",
"drops",
"puffs",
"sprays",
"capsules",
"pieces",
"other",
] as const;
type DoseUnitKey = (typeof ALL_DOSE_UNIT_KEYS)[number];
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
const TOTAL_STEPS = 7;
/** Skeleton of the POST /api/medications request body the wizard emits. */
export interface CreateMedicationBody {
name: string;
dose: string;
startsOn?: string;
endsOn?: string;
oneShot: boolean;
schedules: Array<{
windowStart: string;
windowEnd: string;
timesOfDay: string[];
rrule?: string;
rollingIntervalDays?: number;
}>;
}
// ────────────────────────────────────────────────────────────────────
// Pure helpers — exported for unit tests
// ────────────────────────────────────────────────────────────────────
/** Compose a fresh payload with sensible defaults. */
export function emptyWizardPayload(): WizardPayload {
return {
name: "",
doseAmount: "",
doseUnit: "mg",
mode: null,
cadence: encodeCadence("daily", DEFAULT_SUB_CONTROLS),
subControls: { ...DEFAULT_SUB_CONTROLS },
timesOfDay: ["08:00"],
startsOn: todayUtc(),
endsOn: null,
notificationsEnabled: true,
};
}
function todayUtc(): Date {
const now = new Date();
return new Date(
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0),
);
}
/**
* Pure per-step gate. Returns true when the step's required inputs are
* populated and consistent; the wizard's "Next / Create" button mirrors
* the boolean.
*
* The matrix:
* - Step 1: name + doseAmount populated.
* - Step 2: a mode picked.
* - Step 3: skipped for one-shot. For recurring, the picker always
* emits a valid `CadenceValue`; the gate only re-checks that the
* yearly date is populated when the cadence kind is `yearly`.
* - Step 4: pass-through (re-cap step has no required input).
* - Step 5: at least one valid HH:mm time; one-shot requires startsOn.
* - Step 6: range valid (endsOn null OR endsOn >= startsOn);
* startsOn required for recurring (one-shot already pinned in 5).
* - Step 7: gate matches step 6 (the toggle has no failure mode).
*/
export function validateStep(payload: WizardPayload, step: number): boolean {
switch (step) {
case 1: {
const nameOk = payload.name.trim().length > 0;
const doseOk = payload.doseAmount.trim().length > 0;
return nameOk && doseOk;
}
case 2:
return payload.mode !== null;
case 3: {
if (payload.mode === "oneShot") return true;
// The picker always emits a syntactically valid CadenceValue.
// The one nuance: yearly with an empty `yearlyDate` still emits a
// Jan-1 fallback rrule — but the design synthesis asks the wizard
// to gate Next on a real picked date so the user doesn't ship a
// placeholder by accident.
if (payload.cadence.kind === "yearly") {
const d = payload.subControls.yearlyDate;
return /^\d{4}-\d{2}-\d{2}$/.test(d);
}
return true;
}
case 4:
return true;
case 5: {
const times = payload.timesOfDay.filter((t) => TIME_RE.test(t));
if (times.length === 0) return false;
if (payload.mode === "oneShot" && payload.startsOn === null) return false;
return true;
}
case 6:
if (payload.startsOn === null) return false;
if (payload.endsOn === null) return true;
return payload.endsOn.getTime() >= payload.startsOn.getTime();
case 7:
// Step 7 mirrors 6 — the reminder toggle has no failure mode.
if (payload.startsOn === null) return false;
if (payload.endsOn === null) return true;
return payload.endsOn.getTime() >= payload.startsOn.getTime();
default:
return false;
}
}
/**
* Map a WizardPayload to the `POST /api/medications` request body
* shape declared in `src/lib/validations/medication.ts:createMedicationSchema`.
*
* The schedule schema still REQUIRES `windowStart` + `windowEnd` for
* the legacy reminder worker (dual-write contract through v1.5.x). We
* derive both from `timesOfDay`: `windowStart` is the earliest entry;
* `windowEnd` is the latest entry, or earliest + 1 hour if there's
* only one time. The new engine reads `timesOfDay` directly; the
* legacy worker reads the window pair.
*/
export function buildCreateBody(payload: WizardPayload): CreateMedicationBody {
const dose = composeDose(payload.doseAmount, payload.doseUnit);
const times = sortTimes(payload.timesOfDay);
const [windowStart, windowEnd] = deriveWindow(times);
const isOneShot = payload.mode === "oneShot";
const schedule: CreateMedicationBody["schedules"][number] = {
windowStart,
windowEnd,
timesOfDay: times,
};
if (!isOneShot) {
if (payload.cadence.rrule !== null) {
schedule.rrule = payload.cadence.rrule;
} else if (payload.cadence.rollingIntervalDays !== null) {
schedule.rollingIntervalDays = payload.cadence.rollingIntervalDays;
}
}
const body: CreateMedicationBody = {
name: payload.name.trim(),
dose,
oneShot: isOneShot,
schedules: [schedule],
};
if (payload.startsOn) {
body.startsOn = dateToIsoString(payload.startsOn);
}
if (isOneShot && payload.startsOn) {
body.endsOn = dateToIsoString(payload.startsOn);
} else if (payload.endsOn) {
body.endsOn = dateToIsoString(payload.endsOn);
}
return body;
}
function composeDose(amount: string, unit: string): string {
const a = amount.trim();
const u = unit.trim();
if (!a) return u;
if (!u) return a;
return `${a} ${u}`;
}
function sortTimes(times: string[]): string[] {
return [...times].filter((t) => TIME_RE.test(t)).sort((a, b) => a.localeCompare(b));
}
function deriveWindow(sortedTimes: string[]): [string, string] {
if (sortedTimes.length === 0) return ["08:00", "09:00"];
const start = sortedTimes[0];
if (sortedTimes.length === 1) return [start, addOneHour(start)];
return [start, sortedTimes[sortedTimes.length - 1]];
}
function addOneHour(hhmm: string): string {
const m = TIME_RE.exec(hhmm);
if (!m) return hhmm;
const h = Number(m[1]);
const mm = m[2];
const next = (h + 1) % 24;
return `${String(next).padStart(2, "0")}:${mm}`;
}
/**
* Plain-language summary for step 7. The wizard concatenates the
* cadence phrase, the times phrase, and the start/end phrase into one
* paragraph. Translations live in `medications.create.wizard.step7.summary.*`.
*
* The `t` callback is passed in so the helper stays pure for unit
* tests; the component layer wires `useTranslations().t` through.
*/
export function summariseCadence(
payload: WizardPayload,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
const cadenceKey = summaryKeyForCadence(payload);
const cadencePhrase = t(`medications.create.wizard.step7.summary.cadence.${cadenceKey}`, {
n:
payload.cadence.kind === "everyNWeeks"
? payload.subControls.intervalWeeks
: payload.cadence.kind === "everyNMonths"
? payload.subControls.intervalMonths
: payload.cadence.kind === "rolling"
? (payload.cadence.rollingIntervalDays ?? 0)
: 0,
});
const timesPhrase =
payload.timesOfDay.length > 0
? t(k("step7.summary.times"), {
times: sortTimes(payload.timesOfDay).join(", "),
})
: "";
const startPhrase = payload.startsOn
? t(k("step7.summary.startsOn"), {
date: dateToIsoString(payload.startsOn),
})
: "";
const endPhrase =
payload.mode === "oneShot"
? ""
: payload.endsOn
? t(k("step7.summary.endsOn"), {
date: dateToIsoString(payload.endsOn),
})
: t(k("step7.summary.noEndDate"));
return [cadencePhrase, timesPhrase, startPhrase, endPhrase]
.filter(Boolean)
.join(" · ");
}
function summaryKeyForCadence(payload: WizardPayload): string {
if (payload.mode === "oneShot") return "oneShot";
switch (payload.cadence.kind) {
case "daily":
return "daily";
case "weekdays":
return "weekdays";
case "everyNWeeks":
return payload.subControls.intervalWeeks === 2 ? "biweekly" : "everyNWeeks";
case "monthly":
return "monthly";
case "everyNMonths":
return payload.subControls.intervalMonths === 3 ? "quarterly" : "everyNMonths";
case "yearly":
return "yearly";
case "rolling":
return "rolling";
case "oneShot":
return "oneShot";
}
}
// ────────────────────────────────────────────────────────────────────
// Component
// ────────────────────────────────────────────────────────────────────
export interface CreationWizardProps {
/**
* Placeholder slot the NL-extraction overlay plugs into. The wizard
* never opens its own overlay; the parent renders one and uses the
* callback to seed the payload. The integration shape stays narrow
* so a follow-up commit can wire the overlay against this one prop.
*/
onNaturalLanguagePrefill?: (apply: (partial: Partial<WizardPayload>) => void) => void;
/** Optional initial payload — useful for tests + preview embeds. */
initial?: Partial<WizardPayload>;
}
export function CreationWizard({
onNaturalLanguagePrefill,
initial,
}: CreationWizardProps = {}) {
const { t } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
const [step, setStep] = useState(1);
const [payload, setPayload] = useState<WizardPayload>(() => ({
...emptyWizardPayload(),
...(initial ?? {}),
}));
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const applyPartial = useCallback((partial: Partial<WizardPayload>) => {
setPayload((prev) => ({ ...prev, ...partial }));
}, []);
const canContinue = useMemo(
() => validateStep(payload, step),
[payload, step],
);
const goNext = useCallback(() => {
if (!canContinue) return;
// Skip cadence + day-detail when one-shot.
if (step === 2 && payload.mode === "oneShot") {
setStep(5);
return;
}
setStep((s) => Math.min(s + 1, TOTAL_STEPS));
}, [canContinue, payload.mode, step]);
const goBack = useCallback(() => {
// Mirror the skip: jumping back from step 5 lands on step 2 in
// one-shot mode (steps 3 + 4 never rendered).
if (step === 5 && payload.mode === "oneShot") {
setStep(2);
return;
}
setStep((s) => Math.max(s - 1, 1));
}, [payload.mode, step]);
const onSubmit = useCallback(async () => {
setSubmitting(true);
setSubmitError(null);
setFieldErrors({});
try {
const body = buildCreateBody(payload);
const res = await fetch("/api/medications", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = (await res.json()) as {
data?: { id: string };
error?: string;
issues?: Array<{ path: string; message: string }>;
};
if (res.status === 201 && json.data?.id) {
await invalidateKeys(queryClient, medicationDependentKeys);
router.push(`/medications/${json.data.id}`);
return;
}
if (res.status === 422) {
const map: Record<string, string> = {};
for (const issue of json.issues ?? []) {
map[issue.path] = issue.message;
}
setFieldErrors(map);
setSubmitError(json.error ?? t(k("errors.submitFailed")));
return;
}
setSubmitError(json.error ?? t(k("errors.submitFailed")));
} catch {
setSubmitError(t(k("errors.submitFailed")));
} finally {
setSubmitting(false);
}
}, [payload, queryClient, router, t]);
// Expose the prefill apply callback so the parent can plug in its
// NL overlay before any step renders.
const handleNlClick = useCallback(() => {
if (!onNaturalLanguagePrefill) return;
onNaturalLanguagePrefill(applyPartial);
}, [applyPartial, onNaturalLanguagePrefill]);
const stepTitle = t(`medications.create.wizard.header.title.step${step}`);
const stepOf = t(k("header.stepOf"), {
current: step,
total: TOTAL_STEPS,
});
return (
<Card data-slot="medication-creation-wizard" data-step={step}>
<CardHeader>
<p className="text-muted-foreground text-xs">{stepOf}</p>
<CardTitle className="text-base">{stepTitle}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{step === 1 && (
<Step1NameDose
payload={payload}
applyPartial={applyPartial}
fieldErrors={fieldErrors}
onNlClick={onNaturalLanguagePrefill ? handleNlClick : undefined}
t={t}
/>
)}
{step === 2 && (
<Step2Mode payload={payload} applyPartial={applyPartial} t={t} />
)}
{step === 3 && (
<Step3Cadence payload={payload} applyPartial={applyPartial} t={t} />
)}
{step === 4 && (
<Step4DayDetail payload={payload} t={t} />
)}
{step === 5 && (
<Step5Times
payload={payload}
applyPartial={applyPartial}
t={t}
/>
)}
{step === 6 && (
<Step6CourseWindow
payload={payload}
applyPartial={applyPartial}
t={t}
/>
)}
{step === 7 && (
<Step7Summary
payload={payload}
applyPartial={applyPartial}
submitError={submitError}
t={t}
/>
)}
</CardContent>
<CardFooter className="flex items-center justify-between gap-2">
<Button
type="button"
variant="outline"
onClick={goBack}
disabled={step === 1 || submitting}
data-slot="wizard-back"
>
{t(k("nav.back"))}
</Button>
{step < TOTAL_STEPS ? (
<Button
type="button"
onClick={goNext}
disabled={!canContinue}
data-slot="wizard-next"
>
{t(k("nav.next"))}
</Button>
) : (
<Button
type="button"
onClick={() => void onSubmit()}
disabled={!canContinue || submitting}
data-slot="wizard-create"
aria-busy={submitting || undefined}
>
{submitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t(k("nav.create"))}
</Button>
)}
</CardFooter>
</Card>
);
}
// ────────────────────────────────────────────────────────────────────
// Step bodies
// ────────────────────────────────────────────────────────────────────
interface StepProps {
payload: WizardPayload;
applyPartial: (partial: Partial<WizardPayload>) => void;
t: (key: string, params?: Record<string, string | number>) => string;
}
function Step1NameDose({
payload,
applyPartial,
fieldErrors,
onNlClick,
t,
}: StepProps & {
fieldErrors: Record<string, string>;
onNlClick?: () => void;
}) {
return (
<div className="space-y-4" data-slot="wizard-step1">
{onNlClick && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onNlClick}
className="w-full"
data-slot="wizard-nl-button"
>
<Sparkles className="mr-2 h-4 w-4" aria-hidden="true" />
{t(k("step1.naturalLanguage.button"))}
</Button>
)}
<div className="space-y-1.5">
<Label htmlFor="wizard-name">
{t(k("step1.name.label"))}
</Label>
<Input
id="wizard-name"
value={payload.name}
onChange={(e) => applyPartial({ name: e.target.value })}
placeholder={t(k("step1.name.placeholder"))}
maxLength={100}
autoCapitalize="words"
autoComplete="off"
className="h-11"
aria-invalid={fieldErrors.name ? true : undefined}
aria-describedby={fieldErrors.name ? "wizard-name-error" : undefined}
/>
{fieldErrors.name && (
<p id="wizard-name-error" className="text-destructive text-xs" role="alert">
{fieldErrors.name}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="wizard-dose-amount">
{t(k("step1.dose.amount.label"))}
</Label>
<Input
id="wizard-dose-amount"
type="text"
inputMode="decimal"
value={payload.doseAmount}
onChange={(e) => applyPartial({ doseAmount: e.target.value })}
placeholder={t(
"medications.create.wizard.step1.dose.amount.placeholder",
)}
maxLength={20}
autoComplete="off"
className="h-11"
aria-invalid={fieldErrors.dose ? true : undefined}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="wizard-dose-unit">
{t(k("step1.dose.unit.label"))}
</Label>
<Select
value={payload.doseUnit as DoseUnitKey}
onValueChange={(v) => applyPartial({ doseUnit: v })}
>
<SelectTrigger id="wizard-dose-unit" className="h-11 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ALL_DOSE_UNIT_KEYS.map((u) => (
<SelectItem key={u} value={u}>
{t(`medications.create.wizard.step1.dose.unit.${u}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
}
function Step2Mode({ payload, applyPartial, t }: StepProps) {
return (
<div
role="radiogroup"
aria-label={t(k("header.title.step2"))}
className="space-y-3"
data-slot="wizard-step2"
>
<ModeCard
value="oneShot"
selected={payload.mode === "oneShot"}
title={t(k("step2.oneShot.title"))}
description={t(k("step2.oneShot.description"))}
onSelect={() =>
applyPartial({
mode: "oneShot",
cadence: encodeCadence("oneShot", payload.subControls),
})
}
/>
<ModeCard
value="recurring"
selected={payload.mode === "recurring"}
title={t(k("step2.recurring.title"))}
description={t(k("step2.recurring.description"))}
onSelect={() =>
applyPartial({
mode: "recurring",
cadence:
payload.cadence.kind === "oneShot"
? encodeCadence("daily", payload.subControls)
: payload.cadence,
})
}
/>
</div>
);
}
interface ModeCardProps {
value: "oneShot" | "recurring";
selected: boolean;
title: string;
description: string;
onSelect: () => void;
}
function ModeCard({ value, selected, title, description, onSelect }: ModeCardProps) {
return (
<label
className={[
"block cursor-pointer rounded-lg border p-4 transition-colors",
selected
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/40",
].join(" ")}
data-slot="wizard-mode-card"
data-value={value}
data-selected={selected ? "true" : "false"}
>
<input
type="radio"
name="wizard-mode"
value={value}
checked={selected}
onChange={onSelect}
className="sr-only"
aria-label={title}
/>
<div className="font-medium">{title}</div>
<div className="text-muted-foreground text-sm">{description}</div>
</label>
);
}
function Step3Cadence({ payload, applyPartial, t }: StepProps) {
return (
<div className="space-y-3" data-slot="wizard-step3">
<p className="text-muted-foreground text-sm">
{t(k("step3.helper"))}
</p>
<CadencePicker
value={payload.cadence}
subControls={payload.subControls}
onChange={(cadence, subControls) =>
applyPartial({ cadence, subControls })
}
/>
</div>
);
}
function Step4DayDetail({ payload, t }: Pick<StepProps, "payload" | "t">) {
// Day-detail follow-ups already render inline in the picker (the
// sub-controls are conditional on the selected kind). Step 4 is a
// confirmation re-cap that surfaces what got picked so the user can
// tap Back if it looks wrong.
if (payload.mode === "oneShot") {
return (
<div className="space-y-3" data-slot="wizard-step4">
<p className="text-muted-foreground text-sm">
{t(k("step4.oneShotSkipped"))}
</p>
</div>
);
}
return (
<div className="space-y-3" data-slot="wizard-step4">
<p className="text-muted-foreground text-sm">
{t(k("step4.recap"))}
</p>
<div
className="bg-muted/40 rounded-md border p-3 text-sm"
data-slot="wizard-step4-recap"
>
{recapCadence(payload, t)}
</div>
</div>
);
}
function recapCadence(
payload: WizardPayload,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
return summariseCadence(payload, t);
}
function Step5Times({ payload, applyPartial, t }: StepProps) {
const isOneShot = payload.mode === "oneShot";
const startsIso = dateToIsoString(payload.startsOn);
return (
<div className="space-y-4" data-slot="wizard-step5">
<p className="text-muted-foreground text-sm">
{t(k("step5.helper"))}
</p>
<TimesOfDayChips
value={payload.timesOfDay}
onChange={(timesOfDay) => applyPartial({ timesOfDay })}
maxChips={isOneShot ? 1 : 8}
/>
{isOneShot && (
<div className="space-y-1.5" data-slot="wizard-step5-oneshot-date">
<Label htmlFor="wizard-oneshot-date">
{t(k("step5.oneShotDate.label"))}
</Label>
<Input
id="wizard-oneshot-date"
type="date"
value={startsIso}
onChange={(e) => {
const d = isoStringToDate(e.target.value);
// One-shot pins endsOn to startsOn so step 6 stays in sync.
applyPartial({ startsOn: d, endsOn: d });
}}
className="h-11 w-full"
/>
<p className="text-muted-foreground text-xs">
{t(k("step5.oneShotDate.helper"))}
</p>
</div>
)}
</div>
);
}
function Step6CourseWindow({ payload, applyPartial, t }: StepProps) {
const isOneShot = payload.mode === "oneShot";
return (
<div className="space-y-4" data-slot="wizard-step6">
<p className="text-muted-foreground text-sm">
{t(k("step6.helper"))}
</p>
<CourseWindowRow
startsOn={payload.startsOn}
endsOn={payload.endsOn}
lockEndsToStart={isOneShot}
onChange={({ startsOn, endsOn }) => applyPartial({ startsOn, endsOn })}
/>
</div>
);
}
function Step7Summary({
payload,
applyPartial,
submitError,
t,
}: StepProps & { submitError: string | null }) {
const summary = summariseCadence(payload, t);
return (
<div className="space-y-4" data-slot="wizard-step7">
<div
className="bg-muted/40 rounded-md border p-3 text-sm"
data-slot="wizard-step7-summary"
>
<p className="mb-1 font-medium">
{t(k("step7.summary.title"))}
</p>
<p>{summary}</p>
</div>
<div className="flex items-center justify-between gap-3">
<div className="space-y-1">
<Label
htmlFor="wizard-reminders"
className="text-sm font-medium"
>
{t(k("step7.reminders.label"))}
</Label>
<p className="text-muted-foreground text-xs">
{t(k("step7.reminders.description"))}
</p>
</div>
<Switch
id="wizard-reminders"
checked={payload.notificationsEnabled}
onCheckedChange={(checked) =>
applyPartial({ notificationsEnabled: checked })
}
data-slot="wizard-reminders-toggle"
aria-label={t(k("step7.reminders.label"))}
/>
</div>
{submitError && (
<p
className="text-destructive text-sm"
role="alert"
data-slot="wizard-submit-error"
>
{submitError}
</p>
)}
</div>
);
}
@@ -0,0 +1,470 @@
import { describe, expect, it } from "vitest";
import {
buildCreateBody,
type CreateMedicationBody,
emptyWizardPayload,
summariseCadence,
validateStep,
type WizardPayload,
} from "@/components/medications/scheduling/CreationWizard";
import { encodeCadence } from "@/components/medications/scheduling/CadencePicker";
import {
type CadenceKind,
DEFAULT_SUB_CONTROLS,
} from "@/components/medications/scheduling/types";
/**
* v1.5.0 — CreationWizard pure-helper tests.
*
* Project convention is SSR-only Vitest (no `@testing-library/react`).
* The wizard's interactive surface is exercised by Playwright in a
* later commit; the three pure helpers carry the unit-test contract:
*
* - `validateStep` — per-step gate boolean
* - `buildCreateBody` — `POST /api/medications` body encoder
* - `summariseCadence` — step-7 plain-language summary
*
* Helpers run under a stub translator so the assertions stay
* structural (key shape, params object), not locale-specific.
*/
/**
* Stub translator. Echoes the key + a JSON of params so the assertions
* can pin both the chosen i18n leaf and the params passed through.
*/
function makeStubT() {
return (key: string, params?: Record<string, string | number>): string => {
if (!params) return key;
return `${key}(${JSON.stringify(params)})`;
};
}
function withCadence(
kind: CadenceKind,
sub: Partial<typeof DEFAULT_SUB_CONTROLS> = {},
): WizardPayload {
const subControls = { ...DEFAULT_SUB_CONTROLS, ...sub };
return {
...emptyWizardPayload(),
mode: kind === "oneShot" ? "oneShot" : "recurring",
name: "Foo",
doseAmount: "5",
doseUnit: "mg",
cadence: encodeCadence(kind, subControls),
subControls,
};
}
describe("validateStep", () => {
it("step 1: rejects empty name + empty dose; accepts both populated", () => {
const base = emptyWizardPayload();
expect(validateStep(base, 1)).toBe(false);
expect(validateStep({ ...base, name: "Foo" }, 1)).toBe(false);
expect(validateStep({ ...base, doseAmount: "5" }, 1)).toBe(false);
expect(validateStep({ ...base, name: "Foo", doseAmount: "5" }, 1)).toBe(
true,
);
});
it("step 1: trims whitespace before deciding the gate", () => {
const base = { ...emptyWizardPayload(), name: " ", doseAmount: " " };
expect(validateStep(base, 1)).toBe(false);
});
it("step 2: requires a picked mode", () => {
const base = { ...emptyWizardPayload(), name: "Foo", doseAmount: "5" };
expect(validateStep(base, 2)).toBe(false);
expect(validateStep({ ...base, mode: "oneShot" }, 2)).toBe(true);
expect(validateStep({ ...base, mode: "recurring" }, 2)).toBe(true);
});
it("step 3: always passes for one-shot (cadence step is skipped)", () => {
const p = withCadence("oneShot");
expect(validateStep(p, 3)).toBe(true);
});
it("step 3: passes for daily / weekdays / monthly / rolling", () => {
expect(validateStep(withCadence("daily"), 3)).toBe(true);
expect(validateStep(withCadence("weekdays"), 3)).toBe(true);
expect(validateStep(withCadence("monthly"), 3)).toBe(true);
expect(validateStep(withCadence("rolling"), 3)).toBe(true);
});
it("step 3: rejects yearly without a real picked date", () => {
expect(
validateStep(withCadence("yearly", { yearlyDate: "" }), 3),
).toBe(false);
expect(
validateStep(withCadence("yearly", { yearlyDate: "2026-01-01" }), 3),
).toBe(true);
});
it("step 4: always passes (re-cap step)", () => {
expect(validateStep(withCadence("daily"), 4)).toBe(true);
expect(validateStep(withCadence("oneShot"), 4)).toBe(true);
});
it("step 5: requires at least one valid HH:mm time", () => {
const base = withCadence("daily");
expect(validateStep({ ...base, timesOfDay: [] }, 5)).toBe(false);
expect(validateStep({ ...base, timesOfDay: ["nope"] }, 5)).toBe(false);
expect(validateStep({ ...base, timesOfDay: ["08:00"] }, 5)).toBe(true);
expect(
validateStep({ ...base, timesOfDay: ["08:00", "20:00"] }, 5),
).toBe(true);
});
it("step 5: one-shot also requires startsOn", () => {
const base = withCadence("oneShot");
expect(validateStep({ ...base, startsOn: null }, 5)).toBe(false);
expect(
validateStep(
{ ...base, startsOn: new Date(Date.UTC(2026, 9, 15)) },
5,
),
).toBe(true);
});
it("step 6: requires startsOn", () => {
const base = withCadence("daily");
expect(validateStep({ ...base, startsOn: null }, 6)).toBe(false);
});
it("step 6: accepts null endsOn (chronic)", () => {
const base = withCadence("daily");
expect(validateStep({ ...base, endsOn: null }, 6)).toBe(true);
});
it("step 6: rejects endsOn before startsOn", () => {
const base = withCadence("daily");
const start = new Date(Date.UTC(2026, 4, 28));
const earlierEnd = new Date(Date.UTC(2026, 4, 1));
expect(
validateStep({ ...base, startsOn: start, endsOn: earlierEnd }, 6),
).toBe(false);
});
it("step 6: accepts endsOn equal to startsOn (one-shot pinned)", () => {
const base = withCadence("oneShot");
const start = new Date(Date.UTC(2026, 9, 15));
expect(
validateStep({ ...base, startsOn: start, endsOn: start }, 6),
).toBe(true);
});
it("step 7: mirrors step 6 gate", () => {
const base = withCadence("daily");
expect(validateStep({ ...base, startsOn: null }, 7)).toBe(false);
const start = new Date(Date.UTC(2026, 4, 1));
const earlier = new Date(Date.UTC(2026, 3, 1));
expect(
validateStep({ ...base, startsOn: start, endsOn: earlier }, 7),
).toBe(false);
expect(
validateStep({ ...base, startsOn: start, endsOn: null }, 7),
).toBe(true);
});
it("unknown step number returns false", () => {
expect(validateStep(emptyWizardPayload(), 99)).toBe(false);
expect(validateStep(emptyWizardPayload(), 0)).toBe(false);
});
});
describe("buildCreateBody", () => {
it("emits a daily recurring schedule with the rrule slot", () => {
const p: WizardPayload = {
...withCadence("daily"),
timesOfDay: ["08:00"],
startsOn: new Date(Date.UTC(2026, 4, 28)),
endsOn: null,
};
const body = buildCreateBody(p);
expect(body.name).toBe("Foo");
expect(body.dose).toBe("5 mg");
expect(body.oneShot).toBe(false);
expect(body.startsOn).toBe("2026-05-28");
expect(body.endsOn).toBeUndefined();
expect(body.schedules).toHaveLength(1);
expect(body.schedules[0]).toEqual({
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
rrule: "FREQ=DAILY",
} satisfies CreateMedicationBody["schedules"][number]);
});
it("derives windowEnd from the last time when several are listed", () => {
const p: WizardPayload = {
...withCadence("daily"),
timesOfDay: ["08:00", "14:00", "20:00"],
startsOn: new Date(Date.UTC(2026, 4, 28)),
};
const body = buildCreateBody(p);
expect(body.schedules[0].windowStart).toBe("08:00");
expect(body.schedules[0].windowEnd).toBe("20:00");
expect(body.schedules[0].timesOfDay).toEqual(["08:00", "14:00", "20:00"]);
});
it("sorts the times before emit (chip-row order is user-driven)", () => {
const p: WizardPayload = {
...withCadence("daily"),
timesOfDay: ["20:00", "08:00", "14:00"],
startsOn: new Date(Date.UTC(2026, 4, 28)),
};
const body = buildCreateBody(p);
expect(body.schedules[0].timesOfDay).toEqual(["08:00", "14:00", "20:00"]);
expect(body.schedules[0].windowStart).toBe("08:00");
expect(body.schedules[0].windowEnd).toBe("20:00");
});
it("emits the bi-weekly rrule on everyNWeeks (n=2, Wednesday)", () => {
const p: WizardPayload = {
...withCadence("everyNWeeks", {
intervalWeeks: 2,
weekdays: ["WE"],
}),
timesOfDay: ["08:00"],
startsOn: new Date(Date.UTC(2026, 4, 28)),
};
const body = buildCreateBody(p);
expect(body.schedules[0].rrule).toBe(
"FREQ=WEEKLY;INTERVAL=2;BYDAY=WE",
);
expect(body.schedules[0].rollingIntervalDays).toBeUndefined();
});
it("emits the quarterly rrule on everyNMonths (n=3, day=10)", () => {
const p: WizardPayload = {
...withCadence("everyNMonths", {
intervalMonths: 3,
dayOfMonth: 10,
}),
timesOfDay: ["08:00"],
startsOn: new Date(Date.UTC(2026, 4, 28)),
};
const body = buildCreateBody(p);
expect(body.schedules[0].rrule).toBe(
"FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=10",
);
});
it("emits rollingIntervalDays instead of rrule when cadence is rolling", () => {
const p: WizardPayload = {
...withCadence("rolling", { rollingDays: 7 }),
timesOfDay: ["08:00"],
startsOn: new Date(Date.UTC(2026, 4, 28)),
};
const body = buildCreateBody(p);
expect(body.schedules[0].rrule).toBeUndefined();
expect(body.schedules[0].rollingIntervalDays).toBe(7);
});
it("emits oneShot=true with no rrule/rolling and endsOn = startsOn", () => {
const start = new Date(Date.UTC(2026, 9, 15));
const p: WizardPayload = {
...withCadence("oneShot"),
timesOfDay: ["09:00"],
startsOn: start,
endsOn: start,
};
const body = buildCreateBody(p);
expect(body.oneShot).toBe(true);
expect(body.startsOn).toBe("2026-10-15");
expect(body.endsOn).toBe("2026-10-15");
expect(body.schedules[0].rrule).toBeUndefined();
expect(body.schedules[0].rollingIntervalDays).toBeUndefined();
});
it("emits endsOn ISO string when recurring + an end date is set", () => {
const p: WizardPayload = {
...withCadence("daily"),
timesOfDay: ["08:00"],
startsOn: new Date(Date.UTC(2026, 4, 1)),
endsOn: new Date(Date.UTC(2026, 11, 31)),
};
const body = buildCreateBody(p);
expect(body.startsOn).toBe("2026-05-01");
expect(body.endsOn).toBe("2026-12-31");
});
it("composes the dose as 'amount unit' (single-space)", () => {
const p: WizardPayload = {
...withCadence("daily"),
doseAmount: "10",
doseUnit: "mg",
};
expect(buildCreateBody(p).dose).toBe("10 mg");
});
it("falls back to amount when unit is empty", () => {
const p: WizardPayload = {
...withCadence("daily"),
doseAmount: "10",
doseUnit: "",
};
expect(buildCreateBody(p).dose).toBe("10");
});
it("trims the name before emit", () => {
const p: WizardPayload = {
...withCadence("daily"),
name: " Foo ",
};
expect(buildCreateBody(p).name).toBe("Foo");
});
it("falls back to a default window when the user passes only invalid times", () => {
const p: WizardPayload = {
...withCadence("daily"),
timesOfDay: ["not-a-time"],
};
const body = buildCreateBody(p);
expect(body.schedules[0].timesOfDay).toEqual([]);
expect(body.schedules[0].windowStart).toBe("08:00");
expect(body.schedules[0].windowEnd).toBe("09:00");
});
it("handles the 23:30 + 1h wraparound cleanly (single-time case)", () => {
const p: WizardPayload = {
...withCadence("daily"),
timesOfDay: ["23:30"],
};
const body = buildCreateBody(p);
expect(body.schedules[0].windowStart).toBe("23:30");
expect(body.schedules[0].windowEnd).toBe("00:30");
});
});
describe("summariseCadence", () => {
const t = makeStubT();
it("uses the 'daily' summary key for daily cadence", () => {
const out = summariseCadence(withCadence("daily"), t);
expect(out).toContain("medications.create.wizard.step7.summary.cadence.daily");
});
it("uses 'weekdays' for the weekday-subset cadence", () => {
const out = summariseCadence(
withCadence("weekdays", { weekdays: ["MO", "WE", "FR"] }),
t,
);
expect(out).toContain(
"medications.create.wizard.step7.summary.cadence.weekdays",
);
});
it("uses 'biweekly' for everyNWeeks with n=2", () => {
const out = summariseCadence(
withCadence("everyNWeeks", { intervalWeeks: 2 }),
t,
);
expect(out).toContain(
"medications.create.wizard.step7.summary.cadence.biweekly",
);
});
it("uses 'everyNWeeks' for n != 2", () => {
const out = summariseCadence(
withCadence("everyNWeeks", { intervalWeeks: 4 }),
t,
);
expect(out).toContain(
"medications.create.wizard.step7.summary.cadence.everyNWeeks",
);
});
it("uses 'quarterly' for everyNMonths with n=3", () => {
const out = summariseCadence(
withCadence("everyNMonths", { intervalMonths: 3 }),
t,
);
expect(out).toContain(
"medications.create.wizard.step7.summary.cadence.quarterly",
);
});
it("uses 'monthly' for the monthly cadence (BYMONTHDAY only)", () => {
const out = summariseCadence(withCadence("monthly", { dayOfMonth: 1 }), t);
expect(out).toContain(
"medications.create.wizard.step7.summary.cadence.monthly",
);
});
it("uses 'yearly' for the yearly cadence", () => {
const out = summariseCadence(
withCadence("yearly", { yearlyDate: "2026-01-01" }),
t,
);
expect(out).toContain(
"medications.create.wizard.step7.summary.cadence.yearly",
);
});
it("uses 'rolling' for rolling-interval cadence", () => {
const out = summariseCadence(
withCadence("rolling", { rollingDays: 7 }),
t,
);
expect(out).toContain(
"medications.create.wizard.step7.summary.cadence.rolling",
);
});
it("uses 'oneShot' for one-shot cadence + omits the endsOn phrase", () => {
const start = new Date(Date.UTC(2026, 9, 15));
const p: WizardPayload = {
...withCadence("oneShot"),
startsOn: start,
endsOn: start,
};
const out = summariseCadence(p, t);
expect(out).toContain(
"medications.create.wizard.step7.summary.cadence.oneShot",
);
expect(out).not.toContain("step7.summary.endsOn");
expect(out).not.toContain("step7.summary.noEndDate");
});
it("surfaces 'noEndDate' when recurring + endsOn null", () => {
const p: WizardPayload = {
...withCadence("daily"),
startsOn: new Date(Date.UTC(2026, 4, 28)),
endsOn: null,
};
const out = summariseCadence(p, t);
expect(out).toContain(
"medications.create.wizard.step7.summary.noEndDate",
);
});
it("surfaces the formatted endsOn when recurring + endsOn set", () => {
const p: WizardPayload = {
...withCadence("daily"),
startsOn: new Date(Date.UTC(2026, 4, 1)),
endsOn: new Date(Date.UTC(2026, 11, 31)),
};
const out = summariseCadence(p, t);
expect(out).toMatch(/step7\.summary\.endsOn[^|]*"2026-12-31"/);
});
it("forwards the rolling interval as the n param", () => {
const p: WizardPayload = {
...withCadence("rolling", { rollingDays: 14 }),
};
const out = summariseCadence(p, t);
// stub-t renders `(key)({"n":14})` so the assertion confirms the
// n param threads from sub-controls → summary key params.
expect(out).toMatch(/cadence\.rolling[^|]*"n":14/);
});
it("forwards the times list to the 'times' key", () => {
const p: WizardPayload = {
...withCadence("daily"),
timesOfDay: ["08:00", "20:00"],
};
const out = summariseCadence(p, t);
expect(out).toMatch(/step7\.summary\.times[^|]*"08:00, 20:00"/);
});
});