From 105fcee2420bdb7047523ff65ad967296b77b1dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 28 May 2026 18:48:51 +0200 Subject: [PATCH] refactor(meds): route /medications/new and the edit trigger through the new dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy CreationWizard and the flat MedicationForm retire in favour of MedicationWizardDialog. /medications/new keeps working through a redirect that opens the dialog from the list page; the "Bearbeiten" action on a card opens the same dialog with the hydrated payload. The PhaseConfigDialog drops along with the flat form — it was only reachable from the kebab menu and stays out of the wizard's surface; a future release can resurface the per-phase configuration once the wizard grows compose-mode for multi-schedule medications. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/medications/new/page.tsx | 214 +-- src/app/medications/page.tsx | 140 +- .../medications/medication-form.tsx | 1327 ----------------- .../medications/phase-config-dialog.tsx | 268 ---- .../medications/scheduling/CreationWizard.tsx | 1058 ------------- .../__tests__/CreationWizard.test.tsx | 562 ------- 6 files changed, 70 insertions(+), 3499 deletions(-) delete mode 100644 src/components/medications/medication-form.tsx delete mode 100644 src/components/medications/phase-config-dialog.tsx delete mode 100644 src/components/medications/scheduling/CreationWizard.tsx delete mode 100644 src/components/medications/scheduling/__tests__/CreationWizard.test.tsx diff --git a/src/app/medications/new/page.tsx b/src/app/medications/new/page.tsx index 045a91a22..ba3aecd48 100644 --- a/src/app/medications/new/page.tsx +++ b/src/app/medications/new/page.tsx @@ -1,209 +1,15 @@ -"use client"; +import { redirect } from "next/navigation"; /** - * v1.5.0 — `/medications/new` — host route for the medication-create - * wizard. 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). + * v1.5.4 — `/medications/new` retires. * - * Mounts the `NaturalLanguageExtractor` overlay so the wizard's - * "✨ Describe it" button has somewhere to land. The wizard hands us - * an `apply(partial)` callback; the page opens the overlay, lets the - * extractor call our `onPrefill`, then merges the result onto the - * wizard payload through that callback. + * The medication-create surface moved into the modal wizard mounted + * on `/medications` (`` opens whenever the + * list page renders with `?new=1` in the URL). This page exists only + * to keep legacy bookmarks and external links pointed at the right + * surface; the redirect lands on the list page with the query param + * that triggers the wizard. */ - -import { useCallback, useRef, useState } from "react"; -import Link from "next/link"; -import { ChevronLeft } from "lucide-react"; - -import { - CreationWizard, - type WizardPayload, -} from "@/components/medications/scheduling/CreationWizard"; -import { - NaturalLanguageExtractor, - type WizardPayload as ExtractorPayload, -} from "@/components/medications/scheduling/NaturalLanguageExtractor"; -import { - encodeCadence, -} from "@/components/medications/scheduling/CadencePicker"; -import { - DEFAULT_SUB_CONTROLS, - type CadenceSubControls, - type WeekdayToken, -} from "@/components/medications/scheduling/types"; -import { isoStringToDate } from "@/components/medications/scheduling/CourseWindowRow"; -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, locale } = useTranslations(); - const [nlOpen, setNlOpen] = useState(false); - // Holds the wizard-supplied apply callback across renders. The wizard - // invokes `onNaturalLanguagePrefill(apply)` on every "✨ Describe it" - // click; we stash the function in a ref so the overlay's `onPrefill` - // can invoke it when the extraction returns a result. - const applyRef = useRef<((partial: Partial) => void) | null>( - null, - ); - - const handleNaturalLanguagePrefill = useCallback( - (apply: (partial: Partial) => void) => { - applyRef.current = apply; - setNlOpen(true); - }, - [], - ); - - const handlePrefill = useCallback( - (partial: Partial) => { - const apply = applyRef.current; - if (!apply) return; - apply(extractorToWizardPartial(partial)); - }, - [], - ); - - const handleClose = useCallback(() => { - setNlOpen(false); - }, []); - - return ( -
- -
- ); -} - -/** - * Map the extractor's flat result shape onto the wizard's payload - * shape. Two cross-shape concerns the adapter handles: - * - * - the extractor returns `dose` as a combined string ("5 mg") while - * the wizard splits the amount + unit into separate inputs, so we - * parse the leading number off `dose` and drop the unit token onto - * `doseUnit` when the extractor didn't already pin one. - * - the extractor's `cadenceKind` + weekday / interval / day fields - * are normalised into a `CadenceValue` + `CadenceSubControls` pair - * so the wizard's picker hydrates cleanly when the user lands on - * Step 3. - * - * When `oneShot: true` is returned, the adapter also drops the - * extractor-supplied `timesOfDay` so the wizard's Step 5 forces the - * user to pick the dose time explicitly — the extractor never asked - * for it and the wizard's "08:00" default would otherwise stick. - */ -function extractorToWizardPartial( - result: Partial, -): Partial { - const out: Partial = {}; - - if (typeof result.name === "string") out.name = result.name; - - if (typeof result.dose === "string") { - const parsed = parseDoseExpression(result.dose); - out.doseAmount = parsed.amount; - if (parsed.unit && !result.doseUnit) { - out.doseUnit = parsed.unit; - } - } - if (typeof result.doseUnit === "string") { - out.doseUnit = result.doseUnit; - } - - // Cadence resolution. One-shot wins; otherwise hydrate the picker - // off the extractor's cadenceKind + companion fields. - if (result.oneShot === true) { - out.mode = "oneShot"; - out.subControls = subControlsFromExtractor(result); - out.cadence = encodeCadence("oneShot", out.subControls); - // Force an explicit pick on Step 5 — the wizard's default 08:00 - // shouldn't ride along for a one-shot prefill that never named - // a time. - out.timesOfDay = []; - } else if (result.cadenceKind) { - out.mode = "recurring"; - out.subControls = subControlsFromExtractor(result); - out.cadence = encodeCadence(result.cadenceKind, out.subControls); - } - - // If the caller specified times, honour them — except when oneShot - // already cleared the array above. - if ( - out.timesOfDay === undefined && - Array.isArray(result.timesOfDay) && - result.timesOfDay.length > 0 - ) { - out.timesOfDay = [...result.timesOfDay]; - } - - if (typeof result.startsOn === "string") { - out.startsOn = isoStringToDate(result.startsOn); - } - if (typeof result.endsOn === "string") { - out.endsOn = isoStringToDate(result.endsOn); - } - // One-shot pins endsOn to startsOn (Step 5's date handler does this - // for the interactive path; pre-fill mirrors the contract). - if (result.oneShot === true && out.startsOn) { - out.endsOn = out.startsOn; - } - - return out; -} - -function subControlsFromExtractor( - result: Partial, -): CadenceSubControls { - const base: CadenceSubControls = { ...DEFAULT_SUB_CONTROLS }; - if (Array.isArray(result.weekdays) && result.weekdays.length > 0) { - base.weekdays = result.weekdays.filter((w): w is WeekdayToken => - ["MO", "TU", "WE", "TH", "FR", "SA", "SU"].includes(w), - ); - } - if (typeof result.intervalWeeks === "number") { - base.intervalWeeks = result.intervalWeeks; - } - if (typeof result.intervalMonths === "number") { - base.intervalMonths = result.intervalMonths; - } - if (typeof result.dayOfMonth === "number") { - base.dayOfMonth = result.dayOfMonth; - } - if (typeof result.rollingIntervalDays === "number") { - base.rollingDays = result.rollingIntervalDays; - } - return base; -} - -const DOSE_EXPR_RE = /^\s*(\d+(?:[.,]\d+)?)\s*(.*)$/; - -function parseDoseExpression(input: string): { amount: string; unit: string } { - const match = DOSE_EXPR_RE.exec(input.trim()); - if (!match) return { amount: "", unit: input.trim() }; - const amount = match[1] ?? ""; - const unit = (match[2] ?? "").trim(); - return { amount, unit }; +export default function NewMedicationRedirect() { + redirect("/medications?new=1"); } diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index e15481ffe..03ab9910f 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { @@ -10,7 +11,8 @@ import { queryKeys, } from "@/lib/query-keys"; import { parseScheduleRecurrence } from "@/lib/medication-schedule"; -import { MedicationForm } from "@/components/medications/medication-form"; +import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog"; +import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload"; import { MedicationCard } from "@/components/medications/medication-card"; import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card"; import { Button } from "@/components/ui/button"; @@ -22,7 +24,6 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { DropdownMenu, DropdownMenuContent, @@ -91,17 +92,29 @@ interface Medication { export default function MedicationsPage() { const { isAuthenticated, isLoading: authLoading } = useAuth(); const { t } = useTranslations(); - const [dialogOpen, setDialogOpen] = useState(false); + const router = useRouter(); + const searchParams = useSearchParams(); + // v1.5.4 — the retired `/medications/new` route redirects here with + // `?new=1`, so legacy bookmarks keep landing on the create wizard. + // The initial open state reads the query param synchronously so the + // dialog opens on the very first render; a follow-up effect strips + // the param from the URL so a manual close + refresh stays closed. + const shouldOpenFromUrl = searchParams?.get("new") === "1"; + const [dialogOpen, setDialogOpen] = useState(shouldOpenFromUrl); const [editingMed, setEditingMed] = useState(null); const [importMedId, setImportMedId] = useState(null); const [apiMed, setApiMed] = useState<{ id: string; name: string; } | null>(null); - // v1.4.27 R4 RC2 — DOM handle the medication form portals its - // action-row into so the sheet branch can sticky-pin Save / Cancel - // above the keyboard. - const [footerEl, setFooterEl] = useState(null); + + useEffect(() => { + if (shouldOpenFromUrl) { + // Drop the query param so a refresh after closing the dialog + // doesn't keep reopening it. + router.replace("/medications"); + } + }, [shouldOpenFromUrl, router]); const { data: medications, @@ -282,84 +295,51 @@ export default function MedicationsPage() { onClose={() => setImportMedId(null)} /> - {/* Create/Edit Sheet — bottom-sheet on `} - > - ({ - windowStart: s.windowStart, - windowEnd: s.windowEnd, - label: s.label ?? "", - dose: s.dose ?? "", - ...parseScheduleRecurrence(s.daysOfWeek), - // v1.5 — pass-through the new scheduling fields so - // the form can render the CadencePicker + chips in - // the row's actual cadence rather than the - // legacy-inferred fallback. - timesOfDay: s.timesOfDay, - rrule: s.rrule, - rollingIntervalDays: s.rollingIntervalDays, - reminderGraceMinutes: s.reminderGraceMinutes, - })), - } - : undefined - } - editActions={ - editingMed - ? { - onImportIntakes: () => setImportMedId(editingMed.id), - onApiAccess: () => - setApiMed({ - id: editingMed.id, - name: editingMed.name, - }), - } - : undefined - } - onSuccess={closeDialog} - onCancel={closeDialog} - /> - + mode={editingMed ? "edit" : "create"} + initial={editingMed ? medicationToPayload(editingMed) : undefined} + onSuccess={closeDialog} + /> ); } +/** + * Map a `Medication` row from `GET /api/medications` onto the + * `MedicationPayload` shape the wizard's edit-path hydrator consumes. + * Mirrors the schedule pass-through the v1.5.3 flat form relied on so + * legacy cadences round-trip through the bridge cleanly. + */ +function medicationToPayload(med: Medication): MedicationPayload { + return { + id: med.id, + name: med.name, + dose: med.dose, + category: med.category, + treatmentClass: med.treatmentClass, + notificationsEnabled: med.notificationsEnabled, + startsOn: med.startsOn ? new Date(med.startsOn) : null, + endsOn: med.endsOn ? new Date(med.endsOn) : null, + oneShot: med.oneShot ?? false, + schedules: med.schedules.map((s) => ({ + windowStart: s.windowStart, + windowEnd: s.windowEnd, + label: s.label ?? null, + dose: s.dose ?? null, + ...parseScheduleRecurrence(s.daysOfWeek), + timesOfDay: s.timesOfDay, + rrule: s.rrule ?? null, + rollingIntervalDays: s.rollingIntervalDays ?? null, + })), + }; +} + function IntakeImportDialog({ medicationId, onClose, diff --git a/src/components/medications/medication-form.tsx b/src/components/medications/medication-form.tsx deleted file mode 100644 index 502021f1a..000000000 --- a/src/components/medications/medication-form.tsx +++ /dev/null @@ -1,1327 +0,0 @@ -"use client"; - -// i18n keys added: -// medications.scheduling.cadence.section — "Cadence" section header -// medications.scheduling.timesOfDay.section — "Times of day" section header -// medications.scheduling.courseWindow.section — "Course window" section header -// medications.scheduling.oneShot.section — "Single dose" toggle label -// medications.scheduling.legacyWindow.section — "Reminder window (legacy)" header -// medications.scheduling.legacyWindow.help — explainer for the legacy fallback -// The picker primitives consume `medications.scheduling.cadence.*`, -// `medications.scheduling.timesOfDay.*`, and -// `medications.scheduling.courseWindow.*` via template-string keys -// (see CadencePicker.tsx, TimesOfDayChips.tsx, CourseWindowRow.tsx). - -import { useId, useState } from "react"; -import { createPortal } from "react-dom"; -import { useQueryClient } from "@tanstack/react-query"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - Bell, - BellOff, - Clock, - Eraser, - Loader2, - MoreHorizontal, - MoreVertical, - Pause, - Play, - Plus, - RotateCcw, - Terminal, - Trash2, - Upload, -} from "lucide-react"; -import { toast } from "sonner"; -import { useTranslations } from "@/lib/i18n/context"; -import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; -import { PhaseConfigDialog } from "@/components/medications/phase-config-dialog"; -import { CadencePicker } from "@/components/medications/scheduling/CadencePicker"; -import { - TimesOfDayChips, - addTime, -} from "@/components/medications/scheduling/TimesOfDayChips"; -import { CourseWindowRow } from "@/components/medications/scheduling/CourseWindowRow"; -import { - type CadenceKind, - type CadenceSubControls, - type CadenceValue, - DEFAULT_SUB_CONTROLS, -} from "@/components/medications/scheduling/types"; - -/** - * Cadence kinds the schedule-level picker exposes when the - * medication-level `oneShot` switch is off. `"oneShot"` lives on the - * medication-level switch above; surfacing it on the per-schedule - * picker would invite a contradictory shape (`oneShot = false` at the - * medication level + `cadence.kind = "oneShot"` on a schedule). - */ -const RECURRING_CADENCE_KINDS: readonly CadenceKind[] = [ - "daily", - "weekdays", - "everyNWeeks", - "monthly", - "everyNMonths", - "yearly", - "rolling", -]; -import { - inferCadenceFromLegacy, - legacyPairFromCadence, -} from "@/components/medications/scheduling/legacy-bridge"; - -// DOSE_UNITS built dynamically via t() in the component - -type TranslateFn = ( - key: string, - params?: Record, -) => string; - -function getDayLabelsShort(t: TranslateFn): string[] { - return [ - t("medications.daysSun"), - t("medications.daysMon"), - t("medications.daysTue"), - t("medications.daysWed"), - t("medications.daysThu"), - t("medications.daysFri"), - t("medications.daysSat"), - ]; -} - -/** - * Internal form-level schedule shape. Carries the v1.5 picker state - * (cadence + sub-controls + timesOfDay) alongside the legacy mirror - * fields the route still persists during the v1.5.x dual-write - * window. `legacyOnLoad` flips true when the snapshot loaded from - * the API had no v1.5 fields populated — that's the cue to surface - * the legacy windowStart/windowEnd inputs as a defensive fallback. - */ -interface Schedule { - /** Picker output — the schedule's canonical cadence value. */ - cadence: CadenceValue; - /** Sub-controls remembered for the picker so radio toggles don't - * drop the user's previous selection. */ - cadenceSub: CadenceSubControls; - /** Times of day the dose is taken (HH:mm, sorted, capped at 8). */ - timesOfDay: string[]; - /** Per-schedule label override (e.g. "Morning"). */ - label: string; - /** Per-schedule dose override (e.g. "5 mg"). */ - dose: string; - /** Legacy reminder window start (HH:mm). Dual-written for v1.5.x. */ - windowStart: string; - /** Legacy reminder window end (HH:mm). Dual-written for v1.5.x. */ - windowEnd: string; - /** True when the schedule loaded with no v1.5 fields → surface the - * legacy windowStart/windowEnd inputs as a fallback during the - * migration window. */ - legacyOnLoad: boolean; -} - -interface InitialSchedule { - windowStart: string; - windowEnd: string; - label: string; - dose: string; - daysOfWeek?: number[]; - intervalWeeks?: number; - /** v1.5 — first-class times-of-day. */ - timesOfDay?: string[]; - /** v1.5 — RFC 5545 RRULE string for calendar-anchored cadences. */ - rrule?: string | null; - /** v1.5 — flexible-rolling interval in days. */ - rollingIntervalDays?: number | null; - /** v1.5 — reminder grace window in minutes. */ - reminderGraceMinutes?: number | null; -} - -interface MedicationFormProps { - onSuccess?: () => void; - onCancel?: () => void; - /** - * v1.4.27 R4 RC2 — when the form is mounted inside a - * `` the caller passes the sheet's footer slot - * element here. The kebab + Cancel + Save row portals into that slot - * so the bottom-sheet branch can sticky-pin it; the Save button keeps - * its `
` association via the HTML `form` attribute. - */ - footerSlot?: HTMLElement | null; - editActions?: { - onImportIntakes: () => void; - onApiAccess: () => void; - }; - initial?: { - id: string; - name: string; - dose: string; - category: string; - /** v1.4.25 W4d — Prisma treatment class. Defaults to GENERIC. */ - treatmentClass?: string; - /** v1.4.25 W4d — only relevant for inventory-tracked meds. */ - dosesPerUnit?: number | null; - active: boolean; - notificationsEnabled?: boolean; - /** v1.5 — medication-level course start date. */ - startsOn?: Date | null; - /** v1.5 — medication-level course end date. */ - endsOn?: Date | null; - /** v1.5 — single-administration medication. */ - oneShot?: boolean; - schedules: Array; - }; -} - -const DEFAULT_CADENCE: CadenceValue = { - kind: "daily", - rrule: "FREQ=DAILY", - rollingIntervalDays: null, - oneShot: false, -}; - -const DEFAULT_SCHEDULE: Schedule = { - cadence: { ...DEFAULT_CADENCE }, - cadenceSub: { ...DEFAULT_SUB_CONTROLS }, - timesOfDay: ["08:00"], - label: "", - dose: "", - windowStart: "08:00", - windowEnd: "09:00", - legacyOnLoad: false, -}; - -/** Try to split "400mg" into ["400", "mg"] */ -function parseDose(dose: string): { amount: string; unit: string } { - const match = /^(\d+(?:[.,]\d+)?)\s*(.*)$/.exec(dose.trim()); - if (match) return { amount: match[1], unit: match[2] }; - return { amount: "", unit: dose }; -} - -function parseTimeToMinutes(value: string): number | null { - const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value); - if (!m) return null; - return Number(m[1]) * 60 + Number(m[2]); -} - -function minutesToHHmm(total: number): string { - const normalised = ((total % (24 * 60)) + 24 * 60) % (24 * 60); - const h = String(Math.floor(normalised / 60)).padStart(2, "0"); - const m = String(normalised % 60).padStart(2, "0"); - return `${h}:${m}`; -} - -/** - * Derive a `(windowStart, windowEnd)` mirror from the picker chips so - * the route's legacy plumb still has values to persist during the - * v1.5.x dual-write window. Default 60-minute grace window matches - * the legacy default the historical form started new schedules with. - */ -function deriveLegacyWindow( - timesOfDay: string[], - fallbackStart: string, - fallbackEnd: string, -): { windowStart: string; windowEnd: string } { - if (timesOfDay.length === 0) { - return { windowStart: fallbackStart, windowEnd: fallbackEnd }; - } - const first = timesOfDay[0]; - const startMin = parseTimeToMinutes(first); - if (startMin === null) { - return { windowStart: fallbackStart, windowEnd: fallbackEnd }; - } - return { - windowStart: first, - windowEnd: minutesToHHmm(startMin + 60), - }; -} - -function sortSchedulesByFirstTime(list: Schedule[]): Schedule[] { - return [...list] - .map((schedule, index) => ({ schedule, index })) - .sort((a, b) => { - const aFirst = a.schedule.timesOfDay[0] ?? a.schedule.windowStart; - const bFirst = b.schedule.timesOfDay[0] ?? b.schedule.windowStart; - const aMin = parseTimeToMinutes(aFirst); - const bMin = parseTimeToMinutes(bFirst); - if (aMin === null && bMin === null) return a.index - b.index; - if (aMin === null) return 1; - if (bMin === null) return -1; - if (aMin !== bMin) return aMin - bMin; - return a.index - b.index; - }) - .map((item) => item.schedule); -} - -function hydrateScheduleFromInitial(s: InitialSchedule): Schedule { - const legacy = { - daysOfWeek: s.daysOfWeek ?? [], - intervalWeeks: s.intervalWeeks ?? 1, - }; - const inferred = inferCadenceFromLegacy(legacy); - // The schedule is "legacy on load" when it has no v1.5 fields - // populated — timesOfDay empty AND no rrule AND no rollingIntervalDays. - // The migration backfills `timesOfDay = [windowStart]` for every - // pre-v1.5 row, so an explicit empty array means a row that hasn't - // been touched by the migration yet (defensive fallback). - const hasV15Fields = - (s.timesOfDay && s.timesOfDay.length > 0) || - !!s.rrule || - typeof s.rollingIntervalDays === "number"; - - const timesOfDay = - s.timesOfDay && s.timesOfDay.length > 0 - ? [...s.timesOfDay] - : [s.windowStart]; - - return { - cadence: inferred.value, - cadenceSub: inferred.subControls, - timesOfDay, - label: s.label ?? "", - dose: s.dose ?? "", - windowStart: s.windowStart, - windowEnd: s.windowEnd, - legacyOnLoad: !hasV15Fields, - }; -} - -export function MedicationForm({ - onSuccess, - onCancel, - footerSlot, - editActions, - initial, -}: MedicationFormProps) { - const queryClient = useQueryClient(); - const { t } = useTranslations(); - - // v1.4.27 R4 RC2 — stable form id so the portalled Save button keeps - // its `` association via the HTML `form` attribute even when - // DOM-mounted in the `` footer slot. - const formId = useId(); - - const doseUnits = [ - "mg", - "g", - "ml", - t("medications.unitDrops"), - t("medications.unitTablets"), - t("medications.unitCapsules"), - t("medications.unitPieces"), - "IE", - "µg", - ]; - - const [name, setName] = useState(initial?.name ?? ""); - - const initialDose = parseDose(initial?.dose ?? ""); - const [doseAmount, setDoseAmount] = useState(initialDose.amount); - const [doseUnit, setDoseUnit] = useState(initialDose.unit); - - const [category, setCategory] = useState(initial?.category ?? "OTHER"); - // v1.4.25 W4d — treatment class. GLP1 unlocks the weekly-cadence - // preset below and the GLP-1 medication-card variant on the medications - // page. The default GENERIC keeps every existing medication rendering - // exactly as before. - const [treatmentClass, setTreatmentClass] = useState( - initial?.treatmentClass ?? "GENERIC", - ); - const [dosesPerUnit, setDosesPerUnit] = useState( - initial?.dosesPerUnit ? String(initial.dosesPerUnit) : "", - ); - const [active, setActive] = useState(initial?.active ?? true); - const [notificationsEnabled, setNotificationsEnabled] = useState( - initial?.notificationsEnabled ?? true, - ); - - // v1.5 — medication-level course window + one-shot flag. - const [startsOn, setStartsOn] = useState( - initial?.startsOn ?? null, - ); - const [endsOn, setEndsOn] = useState(initial?.endsOn ?? null); - const [oneShot, setOneShot] = useState(initial?.oneShot ?? false); - - const [schedules, setSchedules] = useState( - sortSchedulesByFirstTime( - initial?.schedules?.length - ? initial.schedules.map(hydrateScheduleFromInitial) - : [{ ...DEFAULT_SCHEDULE }], - ), - ); - const [loading, setLoading] = useState(false); - const [deleting, setDeleting] = useState(false); - const [purging, setPurging] = useState(false); - const [error, setError] = useState(null); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [purgeDialogOpen, setPurgeDialogOpen] = useState(false); - const [phaseConfigOpen, setPhaseConfigOpen] = useState(false); - - // v1.4.27 MB3 — link the form-level error banner to every required - // input via `aria-describedby` so screen readers announce the - // validation failure on save. - const errorId = useId(); - const errorDescriptor = error ? errorId : undefined; - - const isEdit = !!initial; - const dose = doseAmount - ? `${doseAmount}${doseUnit ? ` ${doseUnit}` : ""}` - : doseUnit; - - function patchSchedule(index: number, patch: Partial) { - setSchedules((prev) => - prev.map((s, i) => (i === index ? { ...s, ...patch } : s)), - ); - } - - function patchScheduleAndResort(index: number, patch: Partial) { - setSchedules((prev) => - sortSchedulesByFirstTime( - prev.map((s, i) => (i === index ? { ...s, ...patch } : s)), - ), - ); - } - - function addSchedule() { - if (oneShot) return; - setSchedules((prev) => - sortSchedulesByFirstTime([...prev, { ...DEFAULT_SCHEDULE }]), - ); - } - - function removeSchedule(index: number) { - if (schedules.length <= 1) return; - setSchedules((prev) => prev.filter((_, i) => i !== index)); - } - - function resetCreateForm() { - if (isEdit) return; - setName(""); - setDoseAmount(""); - setDoseUnit(""); - setCategory("OTHER"); - setTreatmentClass("GENERIC"); - setDosesPerUnit(""); - setActive(true); - setNotificationsEnabled(true); - setStartsOn(null); - setEndsOn(null); - setOneShot(false); - setSchedules([{ ...DEFAULT_SCHEDULE }]); - setError(null); - } - - /** - * v1.4.25 W4d — "Once weekly on …" preset, restated against the - * v1.5 picker. One tap on a weekday writes the picker into the - * `weekdays` kind with the chosen day and revealss the cadence row - * via the picker's own conditional sub-controls. The preference - * still persists across sessions so the next GLP-1 medication - * starts on the same weekday. - */ - function applyWeeklyPreset(weekdayIndex0Sun: number) { - try { - localStorage.setItem( - "medication-form:last-weekly-weekday", - String(weekdayIndex0Sun), - ); - } catch { - /* private mode — silent */ - } - // Convert 0-6 (Sun-anchored) to the BYDAY token order the picker - // expects. Day 0 (Sun) maps to "SU"; day 1 (Mon) maps to "MO"; - // and so on through day 6 (Sat) → "SA". - const tokenMap: Record< - number, - (typeof DEFAULT_SUB_CONTROLS)["weekdays"][number] - > = { - 0: "SU", - 1: "MO", - 2: "TU", - 3: "WE", - 4: "TH", - 5: "FR", - 6: "SA", - }; - const token = tokenMap[weekdayIndex0Sun]; - if (!token) return; - setSchedules((prev) => - prev.map((s, i) => - i === 0 - ? { - ...s, - cadence: { - kind: "weekdays", - rrule: `FREQ=WEEKLY;BYDAY=${token}`, - rollingIntervalDays: null, - oneShot: false, - }, - cadenceSub: { ...s.cadenceSub, weekdays: [token] }, - } - : s, - ), - ); - } - - function readLastWeeklyWeekday(): number { - try { - const raw = localStorage.getItem("medication-form:last-weekly-weekday"); - const n = raw === null ? NaN : Number(raw); - return Number.isInteger(n) && n >= 0 && n <= 6 ? n : 3; // Wednesday default - } catch { - return 3; - } - } - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setError(null); - setLoading(true); - - const url = isEdit ? `/api/medications/${initial.id}` : "/api/medications"; - const method = isEdit ? "PUT" : "POST"; - - try { - const courseStartsOnIso = - startsOn !== null ? toIsoDateOnly(startsOn) : null; - const courseEndsOnIso = endsOn !== null ? toIsoDateOnly(endsOn) : null; - - const serialisedSchedules = sortSchedulesByFirstTime(schedules).map( - (s) => { - // Dual-write — derive the legacy mirror from the picker so - // the route's `serializeScheduleRecurrence` and the v1.5.x - // reader-fallback still see a consistent pair. - const legacyPair = legacyPairFromCadence(s.cadence, s.cadenceSub); - const window = deriveLegacyWindow( - s.timesOfDay, - s.windowStart, - s.windowEnd, - ); - return { - windowStart: window.windowStart, - windowEnd: window.windowEnd, - label: s.label || undefined, - dose: s.dose || undefined, - daysOfWeek: - legacyPair.daysOfWeek.length > 0 - ? legacyPair.daysOfWeek - : undefined, - intervalWeeks: legacyPair.intervalWeeks, - timesOfDay: - s.timesOfDay.length > 0 ? s.timesOfDay : undefined, - // v1.5 — pass-through the picker output. Daily / rolling / - // oneShot all map to the right route branches below; the - // PUT layer also normalises endsOn / mints a default rrule. - ...(s.cadence.rrule !== null && { rrule: s.cadence.rrule }), - ...(s.cadence.rollingIntervalDays !== null && { - rollingIntervalDays: s.cadence.rollingIntervalDays, - }), - }; - }, - ); - - const res = await fetch(url, { - method, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name, - dose, - category, - treatmentClass, - ...(dosesPerUnit - ? { dosesPerUnit: Number(dosesPerUnit) } - : isEdit - ? { dosesPerUnit: null } - : {}), - ...(isEdit - ? { - active, - ...(notificationsEnabled !== - (initial?.notificationsEnabled ?? true) && { - notificationsEnabled, - }), - } - : {}), - // v1.5 — medication-level course window + one-shot flag. - // Only emit fields that diverged from the defaults so a - // legacy medication that the user opened and saved without - // touching the course window doesn't get a NULL stamped on - // its existing startsOn. - ...(startsOn !== null && { startsOn: courseStartsOnIso }), - ...(endsOn !== null && { endsOn: courseEndsOnIso }), - ...(oneShot && { oneShot: true }), - schedules: serialisedSchedules, - }), - }); - - const json = await res.json(); - if (!res.ok) { - setError(json.error); - setLoading(false); - return; - } - - await invalidateKeys(queryClient, medicationDependentKeys); - toast.success(t("common.saved")); - onSuccess?.(); - } catch { - setError(t("medications.saveError")); - } finally { - setLoading(false); - } - } - - async function handleDelete() { - if (!initial) return; - setError(null); - setDeleting(true); - try { - const res = await fetch(`/api/medications/${initial.id}`, { - method: "DELETE", - }); - const json = await res.json().catch(() => null); - if (!res.ok) { - setError(json?.error ?? t("medications.deleteError")); - return; - } - - await invalidateKeys(queryClient, medicationDependentKeys); - onSuccess?.(); - } catch { - setError(t("medications.deleteError")); - } finally { - setDeleting(false); - } - } - - async function handlePurge() { - if (!initial) return; - setError(null); - setPurging(true); - try { - const res = await fetch(`/api/medications/${initial.id}/intake/purge`, { - method: "DELETE", - }); - const json = await res.json().catch(() => null); - if (!res.ok) { - setError(json?.error ?? t("medications.purgeError")); - return; - } - - await invalidateKeys(queryClient, medicationDependentKeys); - setPurgeDialogOpen(false); - } catch { - setError(t("medications.purgeError")); - } finally { - setPurging(false); - } - } - - return ( - -
- - setName(e.target.value)} - placeholder={t("medications.namePlaceholder")} - required - maxLength={100} - enterKeyHint="next" - autoCapitalize="words" - autoComplete="off" - aria-required="true" - aria-invalid={!!error || undefined} - aria-describedby={errorDescriptor} - /> -
- -
- - -
- - {/* v1.4.25 W4d — treatment-class selector. Surfaces the GLP-1 - option without crowding the existing clinical-category - dropdown (those are orthogonal taxonomies). GENERIC keeps the - form rendering exactly as v1.4.24; GLP1 opens the - weekly-cadence preset + pen-inventory inputs below. */} -
- - -

- {t("medications.formTreatmentClassHelp")} -

-
- -
-
- - setDoseAmount(e.target.value)} - placeholder={t("medications.dosePlaceholder")} - required - maxLength={20} - enterKeyHint="next" - autoComplete="off" - aria-required="true" - aria-invalid={!!error || undefined} - aria-describedby={errorDescriptor} - /> -
-
- - setDoseUnit(e.target.value)} - placeholder="mg" - maxLength={30} - className="w-full" - enterKeyHint="next" - autoComplete="off" - /> -
-
- - {doseUnits.map((u) => ( - - - {treatmentClass === "GLP1" && ( - <> - {/* v1.4.25 W4d — once-weekly preset. Now writes through the - v1.5 cadence picker (kind = "weekdays" + single BYDAY - token) so the row stays consistent with whatever the - user has picked in the dedicated picker below. */} -
-
- -

- {t("medications.glp1WeeklyPresetHelp")} -

-
-
- {getDayLabelsShort(t).map((label, dayIndex) => { - const firstSchedule = schedules[0]; - const tokenForDay = ( - ["SU", "MO", "TU", "WE", "TH", "FR", "SA"] as const - )[dayIndex]; - const isSelected = - firstSchedule?.cadence.kind === "weekdays" && - firstSchedule.cadenceSub.weekdays.length === 1 && - firstSchedule.cadenceSub.weekdays[0] === tokenForDay; - return ( - - ); - })} -
- {!schedules.some( - (s) => - s.cadence.kind === "weekdays" && - s.cadenceSub.weekdays.length === 1, - ) && ( - - )} -
-
- - setDosesPerUnit(e.target.value)} - placeholder={t("medications.glp1DosesPerUnitPlaceholder")} - aria-invalid={!!error || undefined} - aria-describedby={errorDescriptor} - /> -

- {t("medications.glp1DosesPerUnitHelp")} -

-
- - )} - - {/* v1.5 — medication-level course window. Mirrors the wizard's - step 6. The one-shot toggle below locks endsOn to startsOn - and the cadence picker disappears entirely; the single dose - time stays editable through the TimesOfDayChips singleton. */} -
- - {t(`medications.scheduling.courseWindow.section`)} - - { - setStartsOn(nextStarts); - setEndsOn(nextEnds); - }} - /> -
- { - setOneShot(checked); - if (checked) { - // Flipping into one-shot collapses to a single - // schedule with one timesOfDay entry. Warn the user - // when this would drop additional schedules so the - // collapse stops being a silent data loss. - if (schedules.length > 1) { - toast.warning( - t("medications.scheduling.oneShot.collapseWarning"), - ); - } - setSchedules((prev) => { - const head = prev[0] ?? { ...DEFAULT_SCHEDULE }; - const singleTime = - head.timesOfDay[0] ?? head.windowStart ?? "08:00"; - return [ - { - ...head, - timesOfDay: [singleTime], - cadence: { - kind: "oneShot", - rrule: null, - rollingIntervalDays: null, - oneShot: true, - }, - }, - ]; - }); - // endsOn locks to startsOn through CourseWindowRow's - // `lockEndsToStart` prop; mirror it locally too so the - // submit pulls the right value. - setEndsOn(startsOn); - } - }} - aria-label={t(`medications.scheduling.oneShot.section`)} - /> - -
-
- -
-
- - {!oneShot && ( - - )} -
- - {schedules.map((s, i) => ( -
-
-

- - {s.label.trim() || - `${t("medications.formSchedule")} ${i + 1}`} - -

- - - - - - {schedules.length > 1 && ( - removeSchedule(i)} - > - - {t("medications.removeSchedule")} - - )} - - -
- -
-
- - - patchSchedule(i, { label: e.target.value }) - } - placeholder={t("medications.labelPlaceholder")} - maxLength={50} - enterKeyHint="next" - autoCapitalize="sentences" - autoComplete="off" - /> -
-
- - - patchSchedule(i, { dose: e.target.value }) - } - placeholder={dose || t("medications.defaultDose")} - maxLength={50} - enterKeyHint="done" - autoComplete="off" - /> -
-
- - {/* v1.5 — Cadence picker. Hidden when oneShot is on; the - course-window row above already carries the single - dose date in that case. */} - {!oneShot && ( -
- - - patchSchedule(i, { - cadence: nextValue, - cadenceSub: nextSub, - }) - } - /> -
- )} - - {/* v1.5 — Times-of-day chips. Single time picker when - oneShot is on; multi-chip otherwise. */} -
- - { - // Keep the legacy window mirror in sync so the - // route's dual-write still has values to persist. - const win = deriveLegacyWindow( - next, - s.windowStart, - s.windowEnd, - ); - patchScheduleAndResort(i, { - timesOfDay: next, - windowStart: win.windowStart, - windowEnd: win.windowEnd, - }); - }} - /> -
- - {/* Legacy reminder-window fallback — surfaced only for - schedules loaded with no v1.5 fields populated. The - migration backfills `timesOfDay = [windowStart]` so - this section disappears for every row that has gone - through the v1.5 migration. */} - {s.legacyOnLoad && ( -
- -

- {t(`medications.scheduling.legacyWindow.help`)} -

-
-
- - { - const nextStart = e.target.value; - // Keep timesOfDay in sync — the legacy field - // is the source-of-truth while the schedule - // is still in legacyOnLoad mode. - patchScheduleAndResort(i, { - windowStart: nextStart, - timesOfDay: addTime( - s.timesOfDay.length > 0 ? s.timesOfDay : [], - nextStart, - 8, - ), - }); - }} - maxLength={5} - /> -
-
- - - patchSchedule(i, { windowEnd: e.target.value }) - } - maxLength={5} - /> -
-
-
- )} -
- ))} -
- - {error && ( - - )} - - {(() => { - const footerNode = ( -
- - - - - - {isEdit ? ( - <> - {editActions && ( - <> - - - {t("medications.importIntakesAction")} - - - - {t("medications.apiEndpointAction")} - - setPhaseConfigOpen(true)} - > - - {t("medications.phaseConfig")} - - - - )} - setActive((prev) => !prev)} - > - {active ? ( - - ) : ( - - )} - {active - ? t("medications.pause") - : t("medications.activate")} - - - setNotificationsEnabled((prev) => !prev) - } - > - {notificationsEnabled ? ( - - ) : ( - - )} - {notificationsEnabled - ? t("medications.disableNotifications") - : t("medications.enableNotifications")} - - - setPurgeDialogOpen(true)} - > - - {t("medications.purgeRecords")} - - setDeleteDialogOpen(true)} - > - - {t("common.delete")} - - - ) : ( - - - {t("medications.formReset")} - - )} - - - -
- {onCancel && ( - - )} - -
-
- ); - return footerSlot ? createPortal(footerNode, footerSlot) : footerNode; - })()} - - - - - - {t("medications.deleteConfirm")} - - - {t("medications.deleteConfirmDescription")} - - - - {t("common.cancel")} - - {t("common.delete")} - - - - - - - - - - {t("medications.purgeConfirmTitle")} - - - {t("medications.purgeConfirmDescription")} - - - - - {t("common.cancel")} - - - {purging ? ( - - ) : null} - {t("medications.purgeRecords")} - - - - - - {isEdit && initial?.id && ( - - )} - - ); -} - -/** - * Format a Date as YYYY-MM-DD using its UTC components — matches the - * encoding the route layer parses via `z.iso.date()` and avoids the - * cross-timezone drift that `toISOString().slice(0, 10)` introduces - * for users east of UTC. - */ -function toIsoDateOnly(d: Date): string { - const y = d.getUTCFullYear(); - const m = String(d.getUTCMonth() + 1).padStart(2, "0"); - const day = String(d.getUTCDate()).padStart(2, "0"); - return `${y}-${m}-${day}`; -} diff --git a/src/components/medications/phase-config-dialog.tsx b/src/components/medications/phase-config-dialog.tsx deleted file mode 100644 index 7c3ea59cb..000000000 --- a/src/components/medications/phase-config-dialog.tsx +++ /dev/null @@ -1,268 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Loader2, RotateCcw } from "lucide-react"; -import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; - -interface PhaseConfigData { - greenValue: number; - greenMode: "MINUTES" | "PERCENT"; - yellowValue: number; - yellowMode: "MINUTES" | "PERCENT"; - orangeValue: number; - orangeMode: "MINUTES" | "PERCENT"; - redValue: number; - redMode: "MINUTES" | "PERCENT"; -} - -const DEFAULTS: PhaseConfigData = { - greenValue: 60, - greenMode: "MINUTES", - yellowValue: 30, - yellowMode: "MINUTES", - orangeValue: 0, - orangeMode: "MINUTES", - redValue: 240, - redMode: "MINUTES", -}; - -interface PhaseConfigDialogProps { - medicationId: string; - open: boolean; - onOpenChange: (open: boolean) => void; -} - -type PhaseKey = "green" | "yellow" | "orange" | "red"; - -const PHASES: { - key: PhaseKey; - color: string; - dotClass: string; - direction: "before" | "after"; -}[] = [ - { - key: "green", - color: "green", - dotClass: "bg-green-500", - direction: "before", - }, - { - key: "yellow", - color: "yellow", - dotClass: "bg-yellow-500", - direction: "before", - }, - { - key: "orange", - color: "orange", - dotClass: "bg-orange-500", - direction: "after", - }, - { key: "red", color: "red", dotClass: "bg-red-500", direction: "after" }, -]; - -export function PhaseConfigDialog({ - medicationId, - open, - onOpenChange, -}: PhaseConfigDialogProps) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [localForm, setLocalForm] = useState(null); - const [statusMessage, setStatusMessage] = useState(null); - - const { data, isLoading } = useQuery({ - queryKey: queryKeys.medicationPhaseConfig(medicationId), - queryFn: async () => { - const res = await fetch(`/api/medications/${medicationId}/phase-config`); - const json = await res.json(); - return json.data as PhaseConfigData; - }, - enabled: open, - }); - - // Use local overrides if user has edited, otherwise use fetched data or defaults - const form = localForm ?? data ?? DEFAULTS; - - const saveMutation = useMutation({ - mutationFn: async (config: PhaseConfigData) => { - const res = await fetch(`/api/medications/${medicationId}/phase-config`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(config), - }); - if (!res.ok) throw new Error("Save failed"); - return res.json(); - }, - onSuccess: () => { - setLocalForm(null); - queryClient.invalidateQueries({ - queryKey: queryKeys.medicationPhaseConfig(medicationId), - }); - setStatusMessage(t("medications.phaseSaved")); - setTimeout(() => setStatusMessage(null), 2000); - }, - }); - - const resetMutation = useMutation({ - mutationFn: async () => { - const res = await fetch(`/api/medications/${medicationId}/phase-config`, { - method: "DELETE", - }); - if (!res.ok) throw new Error("Reset failed"); - return res.json(); - }, - onSuccess: () => { - setLocalForm(null); - queryClient.invalidateQueries({ - queryKey: queryKeys.medicationPhaseConfig(medicationId), - }); - setStatusMessage(t("medications.phaseReset")); - setTimeout(() => setStatusMessage(null), 2000); - }, - }); - - function updateValue(phase: PhaseKey, value: number) { - const valueKey = `${phase}Value` as keyof PhaseConfigData; - setLocalForm({ ...form, [valueKey]: value }); - } - - function toggleMode(phase: PhaseKey) { - const modeKey = `${phase}Mode` as keyof PhaseConfigData; - setLocalForm({ - ...form, - [modeKey]: form[modeKey] === "MINUTES" ? "PERCENT" : "MINUTES", - }); - } - - const phaseLabel = (key: PhaseKey): string => { - switch (key) { - case "green": - return t("medications.phaseGreen"); - case "yellow": - return t("medications.phaseYellow"); - case "orange": - return t("medications.phaseOrange"); - case "red": - return t("medications.phaseRed"); - } - }; - - return ( - - -
- - -
- - } - > - {isLoading ? ( -
- -
- ) : ( -
- {PHASES.map(({ key, dotClass, direction }) => { - const valueKey = `${key}Value` as keyof PhaseConfigData; - const modeKey = `${key}Mode` as keyof PhaseConfigData; - const value = form[valueKey] as number; - const mode = form[modeKey] as "MINUTES" | "PERCENT"; - - return ( - // v1.4.27 MB7 / CF-50 — stack the row on ` - - ); - })} -
- )} - - {statusMessage && ( -
- {statusMessage} -
- )} -
- ); -} diff --git a/src/components/medications/scheduling/CreationWizard.tsx b/src/components/medications/scheduling/CreationWizard.tsx deleted file mode 100644 index f0564a4e7..000000000 --- a/src/components/medications/scheduling/CreationWizard.tsx +++ /dev/null @@ -1,1058 +0,0 @@ -"use client"; - -/** - * v1.5.0 — Medication CreationWizard. - * - * Multi-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 — only shown for recurring; the picker's - * conditional sub-controls (weekday chips / day-of-month / - * yearly date) render inline. One-shot path skips this step. - * 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". - * - * The recurring path walks 6 displayed steps (1, 2, 3, 5, 6, 7 in raw - * numbering — step 4 was retired as the picker covers day-detail - * inline). The one-shot path walks 5 displayed steps (1, 2, 5, 6, 7). - * The displayed counter compresses to the path the user actually - * walks so the progress chip stays honest. - * - * Pure helpers `validateStep`, `buildCreateBody`, - * `summariseCadence`, and `progressIndices` 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|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 - * .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.weekdaysDetail — ", on {days}" - * .step7.summary.dayOfMonthDetail — ", on day {day}." - * .step7.summary.everyNMonthsDetail — ", every {months} months on day {day}." - * .step7.summary.yearlyDetail — ", on {date}." - * .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, useEffect, useMemo, useRef, 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)$/; - -/** - * Raw step numbers the wizard renders. Step 4 (day detail) was retired - * — the cadence picker covers it inline. The displayed step index is - * derived from `progressIndices(payload.mode)` so a one-shot user sees - * "Schritt 3 von 5" rather than "Schritt 5 von 7". - */ -const RECURRING_STEPS: readonly number[] = [1, 2, 3, 5, 6, 7]; -const ONE_SHOT_STEPS: readonly number[] = [1, 2, 5, 6, 7]; - -/** - * The ordered list of raw step numbers for the active mode. When the - * mode is unset the wizard hasn't passed step 2 yet, so we conservatively - * surface the recurring list — both branches share steps 1 + 2. - */ -export function progressIndices( - mode: WizardPayload["mode"], -): readonly number[] { - return mode === "oneShot" ? ONE_SHOT_STEPS : RECURRING_STEPS; -} - -/** 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: only walked for recurring. Picker emits a valid - * `CadenceValue` on every interaction; the gate only re-checks - * that the yearly date is populated when the cadence kind is - * `yearly`. - * - 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). - * - * Raw step 4 was retired — the cadence picker carries its day-detail - * sub-controls inline. The case stays absent from the switch so a - * stale caller that passes `4` lands on the default-false branch. - */ -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 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 cadenceDetail = cadenceDetailPhrase(payload, t); - const cadenceLine = cadencePhrase + cadenceDetail; - 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 [cadenceLine, timesPhrase, startPhrase, endPhrase] - .filter(Boolean) - .join(" · "); -} - -/** - * Trailing phrase that interpolates the picker's sub-control values - * into the cadence summary so the user sees the actual weekdays / - * day-of-month / yearly date rather than the category label. Returns - * an empty string for cadences whose category label already carries - * the full information (daily / rolling / oneShot). - */ -function cadenceDetailPhrase( - payload: WizardPayload, - t: (key: string, params?: Record) => string, -): string { - if (payload.mode === "oneShot") return ""; - switch (payload.cadence.kind) { - case "weekdays": - return weekdaysDetail(payload.subControls.weekdays, t); - case "everyNWeeks": - return weekdaysDetail(payload.subControls.weekdays, t); - case "monthly": - return t(k("step7.summary.dayOfMonthDetail"), { - day: payload.subControls.dayOfMonth, - }); - case "everyNMonths": - return t(k("step7.summary.everyNMonthsDetail"), { - months: payload.subControls.intervalMonths, - day: payload.subControls.dayOfMonth, - }); - case "yearly": - return payload.subControls.yearlyDate - ? t(k("step7.summary.yearlyDetail"), { - date: payload.subControls.yearlyDate, - }) - : ""; - default: - return ""; - } -} - -function weekdaysDetail( - tokens: readonly string[], - t: (key: string, params?: Record) => string, -): string { - if (tokens.length === 0) return ""; - const names = tokens - .map((tok) => - t(`medications.scheduling.cadence.weekdays.long.${tok.toLowerCase()}`), - ) - .filter(Boolean); - if (names.length === 0) return ""; - return t(k("step7.summary.weekdaysDetail"), { days: names.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>({}); - - // Ref on the step body so we can pull focus into the first - // interactive control whenever the wizard advances. Without this a - // keyboard / screen-reader user stays parked on the Back / Next - // button across step transitions instead of landing on the new - // step's input. - const bodyRef = useRef(null); - const isFirstRenderRef = useRef(true); - - const applyPartial = useCallback((partial: Partial) => { - setPayload((prev) => ({ ...prev, ...partial })); - }, []); - - // Focus the first interactive element when the step changes. - // Skip the initial render so opening the wizard never steals focus - // from the page chrome. - useEffect(() => { - if (isFirstRenderRef.current) { - isFirstRenderRef.current = false; - return; - } - const node = bodyRef.current; - if (!node) return; - const target = node.querySelector( - 'input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])', - ); - if (!target) return; - // preventScroll keeps the focus jump silent for reduced-motion - // users; the surrounding layout already keeps the body in view. - target.focus({ preventScroll: true }); - }, [step]); - - const canContinue = useMemo( - () => validateStep(payload, step), - [payload, step], - ); - - const stepList = useMemo( - () => progressIndices(payload.mode), - [payload.mode], - ); - const totalSteps = stepList.length; - const currentIndex = stepList.indexOf(step); - // currentIndex can be -1 in the transient case where the user is on - // raw step 3 and toggles back to one-shot at step 2. Defensive - // re-anchoring keeps the indicator monotone. - const safeIndex = currentIndex >= 0 ? currentIndex : 0; - const displayStep = safeIndex + 1; - const isLastStep = displayStep === totalSteps; - - const goNext = useCallback(() => { - if (!canContinue) return; - setStep((s) => { - const list = progressIndices(payload.mode); - const idx = list.indexOf(s); - if (idx < 0) return list[0]; - const nextIdx = Math.min(idx + 1, list.length - 1); - return list[nextIdx]; - }); - }, [canContinue, payload.mode]); - - const goBack = useCallback(() => { - setStep((s) => { - const list = progressIndices(payload.mode); - const idx = list.indexOf(s); - if (idx <= 0) return list[0]; - return list[idx - 1]; - }); - }, [payload.mode]); - - 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: displayStep, - total: totalSteps, - }); - - return ( - - -

{stepOf}

- {stepTitle} -
- - {step === 1 && ( - - )} - {step === 2 && ( - - )} - {step === 3 && ( - - )} - {step === 5 && ( - - )} - {step === 6 && ( - - )} - {step === 7 && ( - - )} - - - - {!isLastStep ? ( - - ) : ( - - )} - -
- ); -} - -// ──────────────────────────────────────────────────────────────────── -// 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 ( - - ); -} - -/** - * The cadence kinds offered by the wizard's Step 3. One-shot is the - * canonical responsibility of Step 2's mode picker — exposing it here - * would invite a contradictory `mode = "recurring"` + `cadence.kind = - * "oneShot"` payload. The picker accepts an optional `allowedKinds` - * filter that we always pass with the recurring-only list. - */ -const STEP3_ALLOWED_KINDS = [ - "daily", - "weekdays", - "everyNWeeks", - "monthly", - "everyNMonths", - "yearly", - "rolling", -] as const; - -function Step3Cadence({ payload, applyPartial, t }: StepProps) { - return ( -
-

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

- - applyPartial({ cadence, subControls }) - } - /> -
- ); -} - -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 deleted file mode 100644 index a56d4c5c8..000000000 --- a/src/components/medications/scheduling/__tests__/CreationWizard.test.tsx +++ /dev/null @@ -1,562 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - buildCreateBody, - type CreateMedicationBody, - emptyWizardPayload, - progressIndices, - 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: retired — the picker covers day-detail inline, so the gate returns false", () => { - expect(validateStep(withCadence("daily"), 4)).toBe(false); - expect(validateStep(withCadence("oneShot"), 4)).toBe(false); - }); - - 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"/); - }); - - it("appends a weekdays-detail phrase with the picked long names", () => { - const p = withCadence("weekdays", { weekdays: ["MO", "WE", "FR"] }); - const out = summariseCadence(p, t); - expect(out).toContain( - "medications.create.wizard.step7.summary.weekdaysDetail", - ); - // Each token resolves to its long-name key under the cadence - // namespace; the stub-t echoes the keys joined by ", ". - expect(out).toMatch(/weekdays\.long\.mo[^|]*weekdays\.long\.we[^|]*weekdays\.long\.fr/); - }); - - it("appends a weekdays-detail phrase for everyNWeeks (interval + picked days)", () => { - const p = withCadence("everyNWeeks", { - intervalWeeks: 3, - weekdays: ["TU"], - }); - const out = summariseCadence(p, t); - expect(out).toContain( - "medications.create.wizard.step7.summary.weekdaysDetail", - ); - expect(out).toMatch(/weekdays\.long\.tu/); - }); - - it("appends a day-of-month-detail phrase for monthly cadence", () => { - const p = withCadence("monthly", { dayOfMonth: 15 }); - const out = summariseCadence(p, t); - expect(out).toContain( - "medications.create.wizard.step7.summary.dayOfMonthDetail", - ); - expect(out).toMatch(/dayOfMonthDetail[^|]*"day":15/); - }); - - it("appends an everyNMonths-detail phrase with months + day", () => { - const p = withCadence("everyNMonths", { - intervalMonths: 3, - dayOfMonth: 10, - }); - const out = summariseCadence(p, t); - expect(out).toContain( - "medications.create.wizard.step7.summary.everyNMonthsDetail", - ); - expect(out).toMatch(/everyNMonthsDetail[^|]*"months":3[^|]*"day":10/); - }); - - it("appends a yearly-detail phrase with the picked date when set", () => { - const p = withCadence("yearly", { yearlyDate: "2026-10-15" }); - const out = summariseCadence(p, t); - expect(out).toContain( - "medications.create.wizard.step7.summary.yearlyDetail", - ); - expect(out).toMatch(/yearlyDetail[^|]*"date":"2026-10-15"/); - }); - - it("omits the yearly-detail phrase when no date has been picked yet", () => { - const p = withCadence("yearly", { yearlyDate: "" }); - const out = summariseCadence(p, t); - expect(out).not.toContain("yearlyDetail"); - }); - - it("emits no trailing detail for daily / rolling / oneShot cadences", () => { - const trailers = [ - "weekdaysDetail", - "dayOfMonthDetail", - "everyNMonthsDetail", - "yearlyDetail", - ]; - for (const kind of ["daily", "rolling", "oneShot"] as const) { - const out = summariseCadence(withCadence(kind), t); - for (const trailer of trailers) { - expect(out, `${kind} surfaced ${trailer}`).not.toContain(trailer); - } - } - }); -}); - -describe("progressIndices", () => { - it("returns the recurring 6-step path when the mode is recurring", () => { - expect(progressIndices("recurring")).toEqual([1, 2, 3, 5, 6, 7]); - }); - - it("returns the one-shot 5-step path when the mode is oneShot", () => { - expect(progressIndices("oneShot")).toEqual([1, 2, 5, 6, 7]); - }); - - it("falls back to the recurring path while the mode is still unset", () => { - // Step 1 / step 2 both render under either path; the recurring - // list keeps the displayed counter monotone before the user picks - // a mode. - expect(progressIndices(null)).toEqual([1, 2, 3, 5, 6, 7]); - }); -});