From 0a0af4b77211e0774fda4f75eee12fc15f56fa8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 28 May 2026 18:47:57 +0200 Subject: [PATCH] feat(meds): modal-wizard skeleton + dialog/sheet shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new MedicationWizardDialog routes through a Dialog on desktop and a bottom Sheet on mobile via the existing ResponsiveSheet primitive, with the wizard's own header inside the body and the footer carrying Back / Next / Save. The dialog tracks an eight-step state machine whose visible counter follows the path the user actually walks — one-shot collapses to 5 steps, daily to 7, every other cadence to 8. The pure helpers (path table, validation gate, request-body builder, summary formatter, edit-path hydration) live alongside the dialog shell so the next commits can plug step content in without touching the wiring. Step content is empty in this commit; the next commits fill the eight steps. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../wizard/MedicationWizardDialog.tsx | 448 ++++++++++++ .../medications/wizard/nl-bridge.ts | 114 +++ .../medications/wizard/steps/Step1Name.tsx | 12 + .../medications/wizard/steps/Step2Class.tsx | 7 + .../medications/wizard/steps/Step3Dose.tsx | 7 + .../medications/wizard/steps/Step4Window.tsx | 7 + .../medications/wizard/steps/Step5Cadence.tsx | 7 + .../wizard/steps/Step6SubCadence.tsx | 7 + .../medications/wizard/steps/Step7Times.tsx | 7 + .../medications/wizard/steps/Step8Summary.tsx | 18 + .../medications/wizard/wizard-payload.ts | 685 ++++++++++++++++++ 11 files changed, 1319 insertions(+) create mode 100644 src/components/medications/wizard/MedicationWizardDialog.tsx create mode 100644 src/components/medications/wizard/nl-bridge.ts create mode 100644 src/components/medications/wizard/steps/Step1Name.tsx create mode 100644 src/components/medications/wizard/steps/Step2Class.tsx create mode 100644 src/components/medications/wizard/steps/Step3Dose.tsx create mode 100644 src/components/medications/wizard/steps/Step4Window.tsx create mode 100644 src/components/medications/wizard/steps/Step5Cadence.tsx create mode 100644 src/components/medications/wizard/steps/Step6SubCadence.tsx create mode 100644 src/components/medications/wizard/steps/Step7Times.tsx create mode 100644 src/components/medications/wizard/steps/Step8Summary.tsx create mode 100644 src/components/medications/wizard/wizard-payload.ts diff --git a/src/components/medications/wizard/MedicationWizardDialog.tsx b/src/components/medications/wizard/MedicationWizardDialog.tsx new file mode 100644 index 000000000..47b0a62cd --- /dev/null +++ b/src/components/medications/wizard/MedicationWizardDialog.tsx @@ -0,0 +1,448 @@ +"use client"; + +/** + * v1.5.4 — Modal medication-creation wizard. + * + * Replaces the v1.5.3 `` card with a popup that lives + * inside `` (centred dialog on `md+`, bottom sheet on + * narrow viewports). The dialog renders its own header inside the + * body — counter + icon plate + title + subline — followed by the + * step's single focused field, and a sticky footer with Back / Next + * (or Save / Änderungen speichern). + * + * Path tables — visible counter follows the route the user walks: + * + * ONE_SHOT_PATH = [1, 2, 3, 4, 8] + * DAILY_PATH = [1, 2, 3, 4, 5, 7, 8] + * RECURRING_PATH = [1, 2, 3, 4, 5, 6, 7, 8] + * + * The same component drives create AND edit. On edit the body + * hydrates from `MedicationPayload`, the header title flips to + * "{name} bearbeiten", and the final CTA flips to + * "Änderungen speichern". + * + * i18n namespace: `medications.wizard.*`. Cadence row labels + + * descriptions live under `medications.wizard.cadence.*` so the + * locale-integrity guard surfaces every missing key the moment the + * dialog reaches a new locale. + */ + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { + Bell, + CalendarRange, + Clock, + FlaskConical, + Loader2, + Pill, + Repeat, + Sparkles, + Tag, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { useTranslations } from "@/lib/i18n/context"; +import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; + +import { NaturalLanguageExtractor } from "@/components/medications/scheduling/NaturalLanguageExtractor"; + +import { + buildCreateBody, + emptyWizardPayload, + hydrateWizardPayload, + type MedicationPayload, + progressIndices, + type WizardPayload, +} from "./wizard-payload"; +import { Step1Name } from "./steps/Step1Name"; +import { Step2Class } from "./steps/Step2Class"; +import { Step3Dose } from "./steps/Step3Dose"; +import { Step4Window } from "./steps/Step4Window"; +import { Step5Cadence } from "./steps/Step5Cadence"; +import { Step6SubCadence } from "./steps/Step6SubCadence"; +import { Step7Times } from "./steps/Step7Times"; +import { Step8Summary } from "./steps/Step8Summary"; +import { validateStep } from "./wizard-payload"; +import { extractorToWizardPartial } from "./nl-bridge"; + +export interface MedicationWizardDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + mode: "create" | "edit"; + /** Required when `mode === "edit"`. */ + initial?: MedicationPayload; + /** Fires with the medication id on a successful create / save. */ + onSuccess?: (id: string) => void; +} + +const STEP_ICONS = { + 1: Pill, + 2: Tag, + 3: FlaskConical, + 4: CalendarRange, + 5: Repeat, + 6: Repeat, + 7: Clock, + 8: Bell, +} as const; + +type StepNumber = keyof typeof STEP_ICONS; + +export function MedicationWizardDialog(props: MedicationWizardDialogProps) { + // Key the inner state container on (open + mode + medication id) so + // every open gets a fresh state tree. React unmounts the inner + // component when the key changes, which sidesteps the "setState in + // useEffect" anti-pattern that resetting on open via an effect + // would otherwise trigger. The outer shell stays mounted so the + // dialog's close animation runs cleanly. + const stateKey = `${props.open ? "open" : "closed"}:${props.mode}:${props.initial?.id ?? "new"}`; + return ; +} + +function WizardDialogShell({ + open, + onOpenChange, + mode, + initial, + onSuccess, +}: MedicationWizardDialogProps) { + const { t, locale } = useTranslations(); + const router = useRouter(); + const queryClient = useQueryClient(); + + const [step, setStep] = useState(1); + const [payload, setPayload] = useState(() => + mode === "edit" && initial + ? hydrateWizardPayload(initial) + : emptyWizardPayload(), + ); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [nlOpen, setNlOpen] = useState(false); + const bodyRef = useRef(null); + const isFirstRenderRef = useRef(true); + + const applyPartial = useCallback((partial: Partial) => { + setPayload((prev) => ({ ...prev, ...partial })); + }, []); + + const stepList = useMemo( + () => progressIndices(payload.mode, payload.cadence.kind), + [payload.mode, payload.cadence.kind], + ); + const totalSteps = stepList.length; + // The displayed counter prefers the slot the current step lives + // in. When the step pointer drifts off-path (e.g. picking + // "Einmalig" on Step 5 collapses the path to `[1, 2, 3, 4, 8]`), + // the counter pins on the slot whose step number is `<= step` so + // the body keeps rendering the user's actual current step and + // the visible counter doesn't jump ahead of them. + const currentIndex = stepList.indexOf(step); + const fallbackIndex = (() => { + if (currentIndex >= 0) return currentIndex; + let last = 0; + for (let i = 0; i < stepList.length; i++) { + if (stepList[i] <= step) last = i; + } + return last; + })(); + const displayStep = fallbackIndex + 1; + const isLastStep = displayStep === totalSteps; + const isFirstStep = displayStep === 1; + const progress = (displayStep / totalSteps) * 100; + + const canContinue = useMemo( + () => validateStep(payload, step), + [payload, step], + ); + + const goNext = useCallback(() => { + if (!canContinue) return; + const list = progressIndices(payload.mode, payload.cadence.kind); + // Step the user forward to the next path step that's strictly + // greater than the current step. If the current step is on the + // path, that's just the slot after it; if it's off-path (Step 5 + // after picking "Einmalig"), we still land on the first higher + // step in the path, which is the one-shot summary at step 8. + const forward = list.find((n) => n > step); + if (forward !== undefined) { + setStep(forward as StepNumber); + } + }, [canContinue, payload.mode, payload.cadence.kind, step]); + + const goBack = useCallback(() => { + const list = progressIndices(payload.mode, payload.cadence.kind); + const backward = [...list].reverse().find((n) => n < step); + if (backward !== undefined) { + setStep(backward as StepNumber); + } + }, [payload.mode, payload.cadence.kind, step]); + + // Focus the first interactive control on each step change. The + // initial render skips so opening the dialog does not steal focus + // from the trigger button. + 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"])', + ); + target?.focus({ preventScroll: true }); + }, [step]); + + const onSubmit = useCallback(async () => { + setSubmitting(true); + setSubmitError(null); + try { + const body = buildCreateBody(payload); + const url = + mode === "edit" && initial + ? `/api/medications/${initial.id}` + : "/api/medications"; + const method = mode === "edit" ? "PUT" : "POST"; + + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const json = (await res.json()) as { + data?: { id: string }; + error?: string; + }; + const successStatus = mode === "edit" ? 200 : 201; + if (res.status === successStatus && json.data?.id) { + await invalidateKeys(queryClient, medicationDependentKeys); + toast.success(t("common.saved")); + onSuccess?.(json.data.id); + onOpenChange(false); + if (mode === "create") { + router.push(`/medications/${json.data.id}`); + } + return; + } + setSubmitError(json.error ?? t("medications.wizard.errors.submitFailed")); + } catch { + setSubmitError(t("medications.wizard.errors.submitFailed")); + } finally { + setSubmitting(false); + } + }, [ + initial, + mode, + onOpenChange, + onSuccess, + payload, + queryClient, + router, + t, + ]); + + const Icon = STEP_ICONS[step]; + + const headerTitle = + mode === "edit" && initial + ? t("medications.wizard.header.editTitle", { name: initial.name }) + : t("medications.wizard.header.createTitle"); + + const stepTitle = t(`medications.wizard.steps.step${step}.title`); + const stepSubline = t(`medications.wizard.steps.step${step}.subline`); + const stepOf = t("medications.wizard.header.stepOf", { + current: displayStep, + total: totalSteps, + }); + + const primaryCtaLabel = isLastStep + ? mode === "edit" + ? t("medications.wizard.nav.saveEdit") + : t("medications.wizard.nav.save") + : t("medications.wizard.nav.next"); + + return ( + <> + + + {!isLastStep ? ( + + ) : ( + + )} + + } + > +
+ {/* Progress + counter */} +
+ +
+

{stepOf}

+ {step === 1 && mode === "create" && ( + + )} +
+
+ + {/* Step body */} +
+
+ +
+

+ {stepTitle} +

+

+ {stepSubline} +

+
+
+ +
+ {step === 1 && ( + + )} + {step === 2 && ( + + )} + {step === 3 && ( + + )} + {step === 4 && ( + + )} + {step === 5 && ( + + )} + {step === 6 && ( + + )} + {step === 7 && ( + + )} + {step === 8 && ( + + )} +
+
+
+
+ {/* Natural-language extractor — same dialog the v1.5.3 page mounted. + Only available on the create path; edit flows already know + what the medication looks like. */} + {mode === "create" && ( + setNlOpen(false)} + onPrefill={(partial) => { + applyPartial(extractorToWizardPartial(partial)); + }} + locale={locale} + /> + )} + + ); +} diff --git a/src/components/medications/wizard/nl-bridge.ts b/src/components/medications/wizard/nl-bridge.ts new file mode 100644 index 000000000..43d3eae38 --- /dev/null +++ b/src/components/medications/wizard/nl-bridge.ts @@ -0,0 +1,114 @@ +/** + * v1.5.4 — natural-language extractor → wizard payload adapter. + * + * The `NaturalLanguageExtractor` dialog returns a flat partial that + * mirrors the route's response shape. The wizard's payload splits + * dose into amount + unit and carries a structured cadence value, so + * the bridge below maps one onto the other. + * + * Lifted from `/medications/new/page.tsx` (v1.5.3) so the modal + * wizard's dialog can plug the same overlay in without re-deriving + * the mapping logic. + */ + +import { encodeCadence } from "@/components/medications/scheduling/CadencePicker"; +import { isoStringToDate } from "@/components/medications/scheduling/CourseWindowRow"; +import { + DEFAULT_SUB_CONTROLS, + type CadenceSubControls, + type WeekdayToken, +} from "@/components/medications/scheduling/types"; +import type { WizardPayload as ExtractorPayload } from "@/components/medications/scheduling/NaturalLanguageExtractor"; + +import type { WizardPayload } from "./wizard-payload"; + +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 }; +} + +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; +} + +/** + * Map the extractor's partial payload to a `WizardPayload` partial. + * One-shot wins over a cadence kind; on a one-shot prefill the + * adapter clears the `timesOfDay` array so Step 7 (or Step 4 on the + * one-shot path) forces the user to pick the dose time explicitly + * rather than inheriting the wizard's 08:00 default. + */ +export 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; + } + + if (result.oneShot === true) { + out.mode = "oneShot"; + out.subControls = subControlsFromExtractor(result); + out.cadence = encodeCadence("oneShot", out.subControls); + out.timesOfDay = []; + } else if (result.cadenceKind) { + out.mode = "recurring"; + out.subControls = subControlsFromExtractor(result); + out.cadence = encodeCadence(result.cadenceKind, out.subControls); + } + + 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); + } + if (result.oneShot === true && out.startsOn) { + out.endsOn = out.startsOn; + } + + return out; +} diff --git a/src/components/medications/wizard/steps/Step1Name.tsx b/src/components/medications/wizard/steps/Step1Name.tsx new file mode 100644 index 000000000..8900c2da1 --- /dev/null +++ b/src/components/medications/wizard/steps/Step1Name.tsx @@ -0,0 +1,12 @@ +"use client"; + +import type { WizardPayload } from "../wizard-payload"; + +export interface StepProps { + payload: WizardPayload; + applyPartial: (partial: Partial) => void; +} + +export function Step1Name(_props: StepProps) { + return null; +} diff --git a/src/components/medications/wizard/steps/Step2Class.tsx b/src/components/medications/wizard/steps/Step2Class.tsx new file mode 100644 index 000000000..154bbd3bc --- /dev/null +++ b/src/components/medications/wizard/steps/Step2Class.tsx @@ -0,0 +1,7 @@ +"use client"; + +import type { StepProps } from "./Step1Name"; + +export function Step2Class(_props: StepProps) { + return null; +} diff --git a/src/components/medications/wizard/steps/Step3Dose.tsx b/src/components/medications/wizard/steps/Step3Dose.tsx new file mode 100644 index 000000000..4a315ef5d --- /dev/null +++ b/src/components/medications/wizard/steps/Step3Dose.tsx @@ -0,0 +1,7 @@ +"use client"; + +import type { StepProps } from "./Step1Name"; + +export function Step3Dose(_props: StepProps) { + return null; +} diff --git a/src/components/medications/wizard/steps/Step4Window.tsx b/src/components/medications/wizard/steps/Step4Window.tsx new file mode 100644 index 000000000..b3d7aaaf2 --- /dev/null +++ b/src/components/medications/wizard/steps/Step4Window.tsx @@ -0,0 +1,7 @@ +"use client"; + +import type { StepProps } from "./Step1Name"; + +export function Step4Window(_props: StepProps) { + return null; +} diff --git a/src/components/medications/wizard/steps/Step5Cadence.tsx b/src/components/medications/wizard/steps/Step5Cadence.tsx new file mode 100644 index 000000000..88ed62b97 --- /dev/null +++ b/src/components/medications/wizard/steps/Step5Cadence.tsx @@ -0,0 +1,7 @@ +"use client"; + +import type { StepProps } from "./Step1Name"; + +export function Step5Cadence(_props: StepProps) { + return null; +} diff --git a/src/components/medications/wizard/steps/Step6SubCadence.tsx b/src/components/medications/wizard/steps/Step6SubCadence.tsx new file mode 100644 index 000000000..8546fe062 --- /dev/null +++ b/src/components/medications/wizard/steps/Step6SubCadence.tsx @@ -0,0 +1,7 @@ +"use client"; + +import type { StepProps } from "./Step1Name"; + +export function Step6SubCadence(_props: StepProps) { + return null; +} diff --git a/src/components/medications/wizard/steps/Step7Times.tsx b/src/components/medications/wizard/steps/Step7Times.tsx new file mode 100644 index 000000000..2d8916f05 --- /dev/null +++ b/src/components/medications/wizard/steps/Step7Times.tsx @@ -0,0 +1,7 @@ +"use client"; + +import type { StepProps } from "./Step1Name"; + +export function Step7Times(_props: StepProps) { + return null; +} diff --git a/src/components/medications/wizard/steps/Step8Summary.tsx b/src/components/medications/wizard/steps/Step8Summary.tsx new file mode 100644 index 000000000..e750f5bab --- /dev/null +++ b/src/components/medications/wizard/steps/Step8Summary.tsx @@ -0,0 +1,18 @@ +"use client"; + +import type { + MedicationPayload, + WizardPayload, +} from "../wizard-payload"; + +export interface Step8SummaryProps { + payload: WizardPayload; + applyPartial: (partial: Partial) => void; + mode: "create" | "edit"; + initial?: MedicationPayload; + submitError: string | null; +} + +export function Step8Summary(_props: Step8SummaryProps) { + return null; +} diff --git a/src/components/medications/wizard/wizard-payload.ts b/src/components/medications/wizard/wizard-payload.ts new file mode 100644 index 000000000..b2727d1e7 --- /dev/null +++ b/src/components/medications/wizard/wizard-payload.ts @@ -0,0 +1,685 @@ +/** + * v1.5.4 — Medication wizard payload + pure helpers. + * + * The wizard is a popup that drives the same eight-step decision in + * Marc's "one question per breath" cadence. The pure helpers here + * (state machine, validation gate, request body builder, summary + * formatter) carry the test surface; the dialog and step components + * compose them. + * + * Step encoding: + * 1. Name + * 2. Treatment class / clinical category + * 3. Dose + * 4. Course window + * 5. Cadence + * 6. Sub-cadence detail (conditional) + * 7. Times of day + * 8. Reminders + summary + * + * The visible step counter follows the path the user actually walks: + * + * ONE_SHOT_PATH = [1, 2, 3, 4, 8] — 5 steps + * DAILY_PATH = [1, 2, 3, 4, 5, 7, 8] — 7 steps + * RECURRING_PATH = [1, 2, 3, 4, 5, 6, 7, 8] — 8 steps + * + * The mode is set in Step 5: "Einmalig" picks `oneShot`; every other + * radio picks `recurring`. The cadence kind drives the path between + * daily (skips Step 6) and the calendar-anchored / rolling shapes. + * + * Each row in Step 2 maps to a `(treatmentClass, category)` pair that + * the request body carries. GLP-1-Injektion is the only row that picks + * `treatmentClass = "GLP1"`; every other row inherits `GENERIC` and + * differs only on the clinical-category bucket the analytics layer + * reads. The mapping table is the single source of truth. + */ + +import { encodeCadence } from "@/components/medications/scheduling/CadencePicker"; +import { + dateToIsoString, + isoStringToDate, +} from "@/components/medications/scheduling/CourseWindowRow"; +import { + type CadenceKind, + type CadenceSubControls, + type CadenceValue, + DEFAULT_SUB_CONTROLS, +} from "@/components/medications/scheduling/types"; +import { + inferCadenceFromLegacy, + legacyPairFromCadence, +} from "@/components/medications/scheduling/legacy-bridge"; +import { + type MedicationTreatmentClass, + type MedicationCategoryValue, +} from "@/lib/validations/medication"; + +// ──────────────────────────────────────────────────────────────────── +// Step 2 — treatment-class taxonomy +// ──────────────────────────────────────────────────────────────────── + +/** + * Step 2 row identifiers. Distinct from `MedicationCategoryValue` so a + * single picker row can map onto the orthogonal `(treatmentClass, + * category)` pair the schema persists. + */ +export type WizardTreatmentRow = + | "bloodPressure" + | "diabetes" + | "hormone" + | "glp1" + | "painRelief" + | "allergy" + | "vitamin" + | "supplement" + | "antibiotic" + | "other"; + +/** + * The ten taxonomy rows in their visible order. Marc-confirmed (D-1 + * §3 Step 2): Blutdruck · Diabetes · Hormone · GLP-1-Injektion · + * Schmerz · Allergie · Vitamine · Nahrungsergänzung · Antibiotikum · + * Sonstiges. + */ +export const WIZARD_TREATMENT_ROWS: readonly WizardTreatmentRow[] = [ + "bloodPressure", + "diabetes", + "hormone", + "glp1", + "painRelief", + "allergy", + "vitamin", + "supplement", + "antibiotic", + "other", +]; + +/** + * Mapping table: Step 2 row → `(treatmentClass, category)` pair the + * request body carries. GLP-1 is the only row that bends the Prisma + * treatment-class enum; every other row inherits `GENERIC`. + */ +export const WIZARD_TREATMENT_MAPPING: Record< + WizardTreatmentRow, + { treatmentClass: MedicationTreatmentClass; category: MedicationCategoryValue } +> = { + bloodPressure: { treatmentClass: "GENERIC", category: "BLOOD_PRESSURE" }, + diabetes: { treatmentClass: "GENERIC", category: "DIABETES" }, + hormone: { treatmentClass: "GENERIC", category: "HORMONE" }, + glp1: { treatmentClass: "GLP1", category: "OTHER" }, + painRelief: { treatmentClass: "GENERIC", category: "PAIN_RELIEF" }, + allergy: { treatmentClass: "GENERIC", category: "ALLERGY" }, + vitamin: { treatmentClass: "GENERIC", category: "VITAMIN" }, + supplement: { treatmentClass: "GENERIC", category: "SUPPLEMENT" }, + antibiotic: { treatmentClass: "GENERIC", category: "ANTIBIOTIC" }, + other: { treatmentClass: "GENERIC", category: "OTHER" }, +}; + +/** + * Reverse-map a `(treatmentClass, category)` pair onto the Step 2 row + * the wizard pre-selects on edit. GLP-1 takes priority (a GLP-1 + * medication is a GLP-1 row regardless of the side-table value). + * Unknown categories fall back to "other". + */ +export function rowFromTreatment( + treatmentClass: string | undefined, + category: string | undefined, +): WizardTreatmentRow { + if (treatmentClass === "GLP1") return "glp1"; + switch (category) { + case "BLOOD_PRESSURE": + return "bloodPressure"; + case "DIABETES": + return "diabetes"; + case "HORMONE": + return "hormone"; + case "PAIN_RELIEF": + return "painRelief"; + case "ALLERGY": + return "allergy"; + case "VITAMIN": + return "vitamin"; + case "SUPPLEMENT": + return "supplement"; + case "ANTIBIOTIC": + return "antibiotic"; + default: + return "other"; + } +} + +// ──────────────────────────────────────────────────────────────────── +// Wizard payload +// ──────────────────────────────────────────────────────────────────── + +/** + * The wizard's working state. The cadence + sub-controls mirror the + * picker primitives; the treatment row collapses Step 2's + * `(treatmentClass, category)` decision into a single value the + * mapping table re-expands at submit time. + */ +export interface WizardPayload { + name: string; + doseAmount: string; + doseUnit: string; + treatmentRow: WizardTreatmentRow | null; + /** Set in Step 5. */ + mode: "oneShot" | "recurring" | null; + cadence: CadenceValue; + subControls: CadenceSubControls; + timesOfDay: string[]; + startsOn: Date | null; + endsOn: Date | null; + notificationsEnabled: boolean; +} + +const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/; + +function todayUtc(): Date { + const now = new Date(); + return new Date( + Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0), + ); +} + +/** Sensible defaults for a fresh wizard. */ +export function emptyWizardPayload(): WizardPayload { + return { + name: "", + doseAmount: "", + doseUnit: "mg", + treatmentRow: null, + mode: null, + cadence: encodeCadence("daily", DEFAULT_SUB_CONTROLS), + subControls: { ...DEFAULT_SUB_CONTROLS }, + timesOfDay: ["08:00"], + startsOn: todayUtc(), + endsOn: null, + notificationsEnabled: true, + }; +} + +// ──────────────────────────────────────────────────────────────────── +// Path table — visible step counter +// ──────────────────────────────────────────────────────────────────── + +const ONE_SHOT_PATH: readonly number[] = [1, 2, 3, 4, 8]; +const DAILY_PATH: readonly number[] = [1, 2, 3, 4, 5, 7, 8]; +const RECURRING_PATH: readonly number[] = [1, 2, 3, 4, 5, 6, 7, 8]; + +/** + * The ordered list of raw step numbers for the active mode + cadence. + * Mirrors v1.5.3's `progressIndices` shape so the visible counter + * tracks the path the user actually walks. + * + * The mode is set in Step 5. Before that, the wizard renders the + * recurring path so the displayed counter stays monotone. + */ +export function progressIndices( + mode: WizardPayload["mode"], + cadenceKind: CadenceKind, +): readonly number[] { + if (mode === "oneShot") return ONE_SHOT_PATH; + if (mode === "recurring" && cadenceKind === "daily") return DAILY_PATH; + return RECURRING_PATH; +} + +// ──────────────────────────────────────────────────────────────────── +// validateStep — pure per-step gate +// ──────────────────────────────────────────────────────────────────── + +/** + * Returns true when the step's required inputs are populated. + * + * Matrix: + * - Step 1: name populated. + * - Step 2: treatment row picked. + * - Step 3: doseAmount populated. + * - Step 4: startsOn present + (endsOn null OR endsOn >= startsOn). + * - Step 5: mode picked. + * - Step 6: cadence-specific (weekdays >= 1; interval >= 1; monthly + * day 1..31; rolling 1..365). + * - Step 7: >= 1 valid HH:mm time. + * - Step 8: mirrors Step 4 (the reminders toggle has no failure mode). + */ +export function validateStep(payload: WizardPayload, step: number): boolean { + switch (step) { + case 1: + return payload.name.trim().length > 0; + case 2: + return payload.treatmentRow !== null; + case 3: + return payload.doseAmount.trim().length > 0; + case 4: + if (payload.startsOn === null) return false; + if (payload.endsOn === null) return true; + return payload.endsOn.getTime() >= payload.startsOn.getTime(); + case 5: + return payload.mode !== null; + case 6: { + if (payload.mode !== "recurring") return true; + switch (payload.cadence.kind) { + case "weekdays": + return payload.subControls.weekdays.length >= 1; + case "everyNWeeks": + return ( + payload.subControls.weekdays.length >= 1 && + payload.subControls.intervalWeeks >= 1 + ); + case "monthly": + return ( + payload.subControls.dayOfMonth >= 1 && + payload.subControls.dayOfMonth <= 31 + ); + case "rolling": + return ( + payload.subControls.rollingDays >= 1 && + payload.subControls.rollingDays <= 365 + ); + default: + return true; + } + } + case 7: { + const times = payload.timesOfDay.filter((t) => TIME_RE.test(t)); + return times.length >= 1; + } + case 8: + if (payload.startsOn === null) return false; + if (payload.endsOn === null) return true; + return payload.endsOn.getTime() >= payload.startsOn.getTime(); + default: + return false; + } +} + +// ──────────────────────────────────────────────────────────────────── +// buildCreateBody — POST /api/medications request body +// ──────────────────────────────────────────────────────────────────── + +export interface CreateMedicationBody { + name: string; + dose: string; + category: MedicationCategoryValue; + treatmentClass: MedicationTreatmentClass; + notificationsEnabled: boolean; + startsOn?: string; + endsOn?: string; + oneShot: boolean; + schedules: Array<{ + windowStart: string; + windowEnd: string; + timesOfDay: string[]; + rrule?: string; + rollingIntervalDays?: number; + daysOfWeek?: number[]; + intervalWeeks?: number; + }>; +} + +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}`; +} + +/** + * Map a wizard payload onto the `POST /api/medications` body. The + * route still requires the legacy `windowStart` / `windowEnd` pair for + * the v1.5.x dual-write window; we derive them from the times. + * + * The treatment row drives both `treatmentClass` and `category` through + * the mapping table. The route writes `category` to the side-table. + */ +export function buildCreateBody(payload: WizardPayload): CreateMedicationBody { + const dose = composeDose(payload.doseAmount, payload.doseUnit); + const times = sortTimes( + payload.mode === "oneShot" + ? payload.timesOfDay.slice(0, 1) + : payload.timesOfDay, + ); + const [windowStart, windowEnd] = deriveWindow(times); + + const isOneShot = payload.mode === "oneShot"; + const mapping = + payload.treatmentRow !== null + ? WIZARD_TREATMENT_MAPPING[payload.treatmentRow] + : WIZARD_TREATMENT_MAPPING.other; + + // Legacy dual-write pair so the route's `serializeScheduleRecurrence` + // sees a consistent shape — mirrors the edit-form bridge. + const legacyPair = legacyPairFromCadence(payload.cadence, payload.subControls); + + 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; + } + if (legacyPair.daysOfWeek.length > 0) { + schedule.daysOfWeek = legacyPair.daysOfWeek; + } + schedule.intervalWeeks = legacyPair.intervalWeeks; + } + + const body: CreateMedicationBody = { + name: payload.name.trim(), + dose, + category: mapping.category, + treatmentClass: mapping.treatmentClass, + notificationsEnabled: payload.notificationsEnabled, + 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; +} + +// ──────────────────────────────────────────────────────────────────── +// summariseCadence — plain-language Step 8 line +// ──────────────────────────────────────────────────────────────────── + +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"; + } +} + +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("medications.wizard.summary.weekdaysDetail", { + days: names.join(", "), + }); +} + +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("medications.wizard.summary.dayOfMonthDetail", { + day: payload.subControls.dayOfMonth, + }); + default: + return ""; + } +} + +/** + * Plain-language summary for Step 8. Mirrors v1.5.3's helper shape so + * the test surface stays familiar. The `t` callback is passed in so + * the helper stays pure (no React hook coupling). + */ +export function summariseCadence( + payload: WizardPayload, + t: (key: string, params?: Record) => string, +): string { + const cadenceKey = summaryKeyForCadence(payload); + const cadencePhrase = t( + `medications.wizard.summary.cadence.${cadenceKey}`, + { + n: + payload.cadence.kind === "everyNWeeks" + ? payload.subControls.intervalWeeks + : payload.cadence.kind === "rolling" + ? (payload.cadence.rollingIntervalDays ?? 0) + : 0, + }, + ); + const cadenceDetail = cadenceDetailPhrase(payload, t); + const cadenceLine = cadencePhrase + cadenceDetail; + const timesPhrase = + payload.mode !== "oneShot" && payload.timesOfDay.length > 0 + ? t("medications.wizard.summary.times", { + times: sortTimes(payload.timesOfDay).join(", "), + }) + : ""; + const startPhrase = payload.startsOn + ? t("medications.wizard.summary.startsOn", { + date: dateToIsoString(payload.startsOn), + }) + : ""; + const endPhrase = + payload.mode === "oneShot" + ? "" + : payload.endsOn + ? t("medications.wizard.summary.endsOn", { + date: dateToIsoString(payload.endsOn), + }) + : t("medications.wizard.summary.noEndDate"); + return [cadenceLine, timesPhrase, startPhrase, endPhrase] + .filter(Boolean) + .join(" · "); +} + +// ──────────────────────────────────────────────────────────────────── +// Hydration — edit-path payload from an existing medication +// ──────────────────────────────────────────────────────────────────── + +/** + * Snapshot of a medication's persisted state, as the list page passes + * it to ``. Schedules carry both + * the v1.5 cadence fields and the legacy `(daysOfWeek, intervalWeeks)` + * pair so the bridge can hydrate older medications cleanly. + */ +export interface MedicationPayload { + id: string; + name: string; + dose: string; + category: string; + treatmentClass?: string; + notificationsEnabled: boolean; + startsOn: Date | null; + endsOn: Date | null; + oneShot: boolean; + schedules: Array<{ + windowStart: string; + windowEnd: string; + label?: string | null; + dose?: string | null; + daysOfWeek?: number[]; + intervalWeeks?: number; + timesOfDay?: string[]; + rrule?: string | null; + rollingIntervalDays?: number | null; + }>; +} + +const DOSE_EXPR_RE = /^\s*(\d+(?:[.,]\d+)?)\s*(.*)$/; + +/** Try to split "5 mg" into ["5", "mg"]; falls back to ["", input]. */ +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 }; +} + +/** + * Hydrate a `WizardPayload` from a medication snapshot. The legacy + * bridge (`inferCadenceFromLegacy`) infers a cadence from the + * `(daysOfWeek, intervalWeeks)` pair when the v1.5 `rrule` / + * `rollingIntervalDays` fields are absent. The first schedule wins + * (multi-schedule edit support lands in v1.5.5 — until then the + * dialog surfaces a "Mehrere Zeitpläne" link that routes back to the + * legacy flat form). + */ +export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload { + const base = emptyWizardPayload(); + const parsedDose = parseDoseExpression(initial.dose ?? ""); + const firstSchedule = initial.schedules[0]; + + const cadence = firstSchedule + ? deriveCadenceFromSchedule(firstSchedule, initial.oneShot) + : { value: base.cadence, subControls: base.subControls }; + + const times = + firstSchedule?.timesOfDay && firstSchedule.timesOfDay.length > 0 + ? [...firstSchedule.timesOfDay] + : firstSchedule?.windowStart + ? [firstSchedule.windowStart] + : base.timesOfDay; + + return { + ...base, + name: initial.name ?? "", + doseAmount: parsedDose.amount, + doseUnit: parsedDose.unit || base.doseUnit, + treatmentRow: rowFromTreatment(initial.treatmentClass, initial.category), + mode: initial.oneShot ? "oneShot" : "recurring", + cadence: cadence.value, + subControls: cadence.subControls, + timesOfDay: times, + startsOn: initial.startsOn ?? base.startsOn, + endsOn: initial.endsOn, + notificationsEnabled: initial.notificationsEnabled ?? true, + }; +} + +function deriveCadenceFromSchedule( + schedule: MedicationPayload["schedules"][number], + oneShot: boolean, +): { value: CadenceValue; subControls: CadenceSubControls } { + if (oneShot) { + const sub = { ...DEFAULT_SUB_CONTROLS }; + return { value: encodeCadence("oneShot", sub), subControls: sub }; + } + if (typeof schedule.rollingIntervalDays === "number") { + const sub: CadenceSubControls = { + ...DEFAULT_SUB_CONTROLS, + rollingDays: schedule.rollingIntervalDays, + }; + return { value: encodeCadence("rolling", sub), subControls: sub }; + } + if (typeof schedule.rrule === "string" && schedule.rrule.length > 0) { + const sub = subControlsFromRrule(schedule.rrule); + const kind = kindFromRrule(schedule.rrule); + return { value: encodeCadence(kind, sub), subControls: sub }; + } + // Legacy fallback — infer from daysOfWeek + intervalWeeks. + return inferCadenceFromLegacy({ + daysOfWeek: schedule.daysOfWeek ?? [], + intervalWeeks: schedule.intervalWeeks ?? 1, + }); +} + +function kindFromRrule(rrule: string): CadenceKind { + // Lightweight inspection — the picker re-encodes the RRULE on every + // change so the wizard never relies on a perfect parse here. + if (rrule.includes("FREQ=DAILY")) return "daily"; + if (rrule.includes("FREQ=WEEKLY")) { + return /INTERVAL=([2-9]|[1-9]\d)/.test(rrule) ? "everyNWeeks" : "weekdays"; + } + if (rrule.includes("FREQ=MONTHLY")) { + return /INTERVAL=([2-9]|[1-9]\d)/.test(rrule) ? "everyNMonths" : "monthly"; + } + if (rrule.includes("FREQ=YEARLY")) return "yearly"; + return "daily"; +} + +function subControlsFromRrule(rrule: string): CadenceSubControls { + const sub: CadenceSubControls = { ...DEFAULT_SUB_CONTROLS }; + const byday = /BYDAY=([A-Z,]+)/.exec(rrule); + if (byday) { + const tokens = byday[1].split(",").filter((t) => + ["MO", "TU", "WE", "TH", "FR", "SA", "SU"].includes(t), + ); + if (tokens.length > 0) { + sub.weekdays = tokens as CadenceSubControls["weekdays"]; + } + } + const interval = /INTERVAL=(\d{1,3})/.exec(rrule); + if (interval) { + const n = Number(interval[1]); + if (rrule.includes("FREQ=WEEKLY")) sub.intervalWeeks = n; + else if (rrule.includes("FREQ=MONTHLY")) sub.intervalMonths = n; + } + const bymonthday = /BYMONTHDAY=(-?\d{1,2})/.exec(rrule); + if (bymonthday) { + const n = Number(bymonthday[1]); + if (n >= 1 && n <= 31) sub.dayOfMonth = n; + } + const bymonth = /BYMONTH=(\d{1,2})/.exec(rrule); + if (bymonth && bymonthday) { + const month = Number(bymonth[1]); + const day = Number(bymonthday[1]); + if (month >= 1 && month <= 12 && day >= 1 && day <= 31) { + sub.yearlyDate = `${new Date().getUTCFullYear()}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + } + } + return sub; +} + +// ──────────────────────────────────────────────────────────────────── +// Re-exports — convenience for the dialog and tests +// ──────────────────────────────────────────────────────────────────── + +export { dateToIsoString, isoStringToDate };