diff --git a/src/app/medications/new/page.tsx b/src/app/medications/new/page.tsx new file mode 100644 index 000000000..298ebf794 --- /dev/null +++ b/src/app/medications/new/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/src/components/medications/scheduling/CreationWizard.tsx b/src/components/medications/scheduling/CreationWizard.tsx new file mode 100644 index 000000000..d137b0493 --- /dev/null +++ b/src/components/medications/scheduling/CreationWizard.tsx @@ -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 { + 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) => void) => void; + /** Optional initial payload — useful for tests + preview embeds. */ + initial?: Partial; +} + +export function CreationWizard({ + onNaturalLanguagePrefill, + initial, +}: CreationWizardProps = {}) { + const { t } = useTranslations(); + const router = useRouter(); + const queryClient = useQueryClient(); + + const [step, setStep] = useState(1); + const [payload, setPayload] = useState(() => ({ + ...emptyWizardPayload(), + ...(initial ?? {}), + })); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [fieldErrors, setFieldErrors] = useState>({}); + + const applyPartial = useCallback((partial: Partial) => { + 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 = {}; + 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 ( + + +

{stepOf}

+ {stepTitle} +
+ + {step === 1 && ( + + )} + {step === 2 && ( + + )} + {step === 3 && ( + + )} + {step === 4 && ( + + )} + {step === 5 && ( + + )} + {step === 6 && ( + + )} + {step === 7 && ( + + )} + + + + {step < TOTAL_STEPS ? ( + + ) : ( + + )} + +
+ ); +} + +// ──────────────────────────────────────────────────────────────────── +// Step bodies +// ──────────────────────────────────────────────────────────────────── + +interface StepProps { + payload: WizardPayload; + applyPartial: (partial: Partial) => void; + t: (key: string, params?: Record) => string; +} + +function Step1NameDose({ + payload, + applyPartial, + fieldErrors, + onNlClick, + t, +}: StepProps & { + fieldErrors: Record; + onNlClick?: () => void; +}) { + return ( +
+ {onNlClick && ( + + )} + +
+ + 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 && ( + + )} +
+ +
+
+ + 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} + /> +
+
+ + +
+
+
+ ); +} + +function Step2Mode({ payload, applyPartial, t }: StepProps) { + return ( +
+ + applyPartial({ + mode: "oneShot", + cadence: encodeCadence("oneShot", payload.subControls), + }) + } + /> + + applyPartial({ + mode: "recurring", + cadence: + payload.cadence.kind === "oneShot" + ? encodeCadence("daily", payload.subControls) + : payload.cadence, + }) + } + /> +
+ ); +} + +interface ModeCardProps { + value: "oneShot" | "recurring"; + selected: boolean; + title: string; + description: string; + onSelect: () => void; +} + +function ModeCard({ value, selected, title, description, onSelect }: ModeCardProps) { + return ( + + ); +} + +function Step3Cadence({ payload, applyPartial, t }: StepProps) { + return ( +
+

+ {t(k("step3.helper"))} +

+ + applyPartial({ cadence, subControls }) + } + /> +
+ ); +} + +function Step4DayDetail({ payload, t }: Pick) { + // 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 ( +
+

+ {t(k("step4.oneShotSkipped"))} +

+
+ ); + } + return ( +
+

+ {t(k("step4.recap"))} +

+
+ {recapCadence(payload, t)} +
+
+ ); +} + +function recapCadence( + payload: WizardPayload, + t: (key: string, params?: Record) => string, +): string { + return summariseCadence(payload, t); +} + +function Step5Times({ payload, applyPartial, t }: StepProps) { + const isOneShot = payload.mode === "oneShot"; + const startsIso = dateToIsoString(payload.startsOn); + return ( +
+

+ {t(k("step5.helper"))} +

+ applyPartial({ timesOfDay })} + maxChips={isOneShot ? 1 : 8} + /> + {isOneShot && ( +
+ + { + 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" + /> +

+ {t(k("step5.oneShotDate.helper"))} +

+
+ )} +
+ ); +} + +function Step6CourseWindow({ payload, applyPartial, t }: StepProps) { + const isOneShot = payload.mode === "oneShot"; + return ( +
+

+ {t(k("step6.helper"))} +

+ applyPartial({ startsOn, endsOn })} + /> +
+ ); +} + +function Step7Summary({ + payload, + applyPartial, + submitError, + t, +}: StepProps & { submitError: string | null }) { + const summary = summariseCadence(payload, t); + return ( +
+
+

+ {t(k("step7.summary.title"))} +

+

{summary}

+
+ +
+
+ +

+ {t(k("step7.reminders.description"))} +

+
+ + applyPartial({ notificationsEnabled: checked }) + } + data-slot="wizard-reminders-toggle" + aria-label={t(k("step7.reminders.label"))} + /> +
+ + {submitError && ( +

+ {submitError} +

+ )} +
+ ); +} diff --git a/src/components/medications/scheduling/__tests__/CreationWizard.test.tsx b/src/components/medications/scheduling/__tests__/CreationWizard.test.tsx new file mode 100644 index 000000000..3ab630e3d --- /dev/null +++ b/src/components/medications/scheduling/__tests__/CreationWizard.test.tsx @@ -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 => { + if (!params) return key; + return `${key}(${JSON.stringify(params)})`; + }; +} + +function withCadence( + kind: CadenceKind, + sub: Partial = {}, +): 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"/); + }); +});