feat(meds): modal-wizard skeleton + dialog/sheet shell

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 19:33:11 +02:00
1 parent a868bd1cf2
commit 0a0af4b772
11 files changed
+1319

No files matched your search

@@ -0,0 +1,448 @@
"use client";
/**
* v1.5.4 — Modal medication-creation wizard.
*
* Replaces the v1.5.3 `<CreationWizard>` card with a popup that lives
* inside `<ResponsiveSheet>` (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 <WizardDialogShell key={stateKey} {...props} />;
}
function WizardDialogShell({
open,
onOpenChange,
mode,
initial,
onSuccess,
}: MedicationWizardDialogProps) {
const { t, locale } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
const [step, setStep] = useState<StepNumber>(1);
const [payload, setPayload] = useState<WizardPayload>(() =>
mode === "edit" && initial
? hydrateWizardPayload(initial)
: emptyWizardPayload(),
);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [nlOpen, setNlOpen] = useState(false);
const bodyRef = useRef<HTMLDivElement | null>(null);
const isFirstRenderRef = useRef(true);
const applyPartial = useCallback((partial: Partial<WizardPayload>) => {
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<HTMLElement>(
'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 (
<>
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={headerTitle}
hideHeader
showCloseButton
className="sm:max-w-md"
bodyClassName="gap-0 p-0"
footer={
<div className="flex w-full items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
onClick={goBack}
disabled={isFirstStep || submitting}
data-slot="wizard-back"
className="h-11"
>
{t("medications.wizard.nav.back")}
</Button>
{!isLastStep ? (
<Button
type="button"
onClick={goNext}
disabled={!canContinue}
data-slot="wizard-next"
className="h-11"
>
{primaryCtaLabel}
</Button>
) : (
<Button
type="button"
onClick={() => void onSubmit()}
disabled={!canContinue || submitting}
data-slot="wizard-save"
aria-busy={submitting || undefined}
className="h-11"
>
{submitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{primaryCtaLabel}
</Button>
)}
</div>
}
>
<div
data-slot="medication-wizard-dialog"
data-step={step}
data-display-step={displayStep}
data-total-steps={totalSteps}
data-mode={mode}
aria-busy={submitting || undefined}
className="flex flex-col"
>
{/* Progress + counter */}
<div className="border-border/70 space-y-1.5 border-b p-4">
<Progress
value={progress}
className="h-1"
aria-label={stepOf}
/>
<div className="flex items-center justify-between gap-2">
<p className="text-muted-foreground text-xs">{stepOf}</p>
{step === 1 && mode === "create" && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 gap-1 px-2 text-xs"
onClick={() => setNlOpen(true)}
data-slot="wizard-nl-button"
aria-label={t("medications.wizard.nl.button")}
>
<Sparkles
className="h-3.5 w-3.5"
aria-hidden="true"
/>
{t("medications.wizard.nl.button")}
</Button>
)}
</div>
</div>
{/* Step body */}
<div
ref={bodyRef}
className="space-y-4 p-4"
data-slot="wizard-step-body"
key={step}
>
<div className="flex items-start gap-3">
<div
className="bg-card border-border/60 grid h-10 w-10 shrink-0 place-items-center rounded-xl border"
data-slot="wizard-step-icon"
aria-hidden="true"
>
<Icon className="text-primary h-6 w-6" />
</div>
<div className="min-w-0 space-y-1">
<h2 className="text-foreground text-base font-medium leading-tight">
{stepTitle}
</h2>
<p className="text-muted-foreground text-sm">
{stepSubline}
</p>
</div>
</div>
<div data-slot="wizard-step-field">
{step === 1 && (
<Step1Name payload={payload} applyPartial={applyPartial} />
)}
{step === 2 && (
<Step2Class payload={payload} applyPartial={applyPartial} />
)}
{step === 3 && (
<Step3Dose payload={payload} applyPartial={applyPartial} />
)}
{step === 4 && (
<Step4Window payload={payload} applyPartial={applyPartial} />
)}
{step === 5 && (
<Step5Cadence
payload={payload}
applyPartial={applyPartial}
/>
)}
{step === 6 && (
<Step6SubCadence
payload={payload}
applyPartial={applyPartial}
/>
)}
{step === 7 && (
<Step7Times
payload={payload}
applyPartial={applyPartial}
/>
)}
{step === 8 && (
<Step8Summary
payload={payload}
applyPartial={applyPartial}
mode={mode}
initial={initial}
submitError={submitError}
/>
)}
</div>
</div>
</div>
</ResponsiveSheet>
{/* 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" && (
<NaturalLanguageExtractor
open={nlOpen}
onClose={() => setNlOpen(false)}
onPrefill={(partial) => {
applyPartial(extractorToWizardPartial(partial));
}}
locale={locale}
/>
)}
</>
);
}
@@ -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<ExtractorPayload>,
): 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<ExtractorPayload>,
): Partial<WizardPayload> {
const out: Partial<WizardPayload> = {};
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;
}
@@ -0,0 +1,12 @@
"use client";
import type { WizardPayload } from "../wizard-payload";
export interface StepProps {
payload: WizardPayload;
applyPartial: (partial: Partial<WizardPayload>) => void;
}
export function Step1Name(_props: StepProps) {
return null;
}
@@ -0,0 +1,7 @@
"use client";
import type { StepProps } from "./Step1Name";
export function Step2Class(_props: StepProps) {
return null;
}
@@ -0,0 +1,7 @@
"use client";
import type { StepProps } from "./Step1Name";
export function Step3Dose(_props: StepProps) {
return null;
}
@@ -0,0 +1,7 @@
"use client";
import type { StepProps } from "./Step1Name";
export function Step4Window(_props: StepProps) {
return null;
}
@@ -0,0 +1,7 @@
"use client";
import type { StepProps } from "./Step1Name";
export function Step5Cadence(_props: StepProps) {
return null;
}
@@ -0,0 +1,7 @@
"use client";
import type { StepProps } from "./Step1Name";
export function Step6SubCadence(_props: StepProps) {
return null;
}
@@ -0,0 +1,7 @@
"use client";
import type { StepProps } from "./Step1Name";
export function Step7Times(_props: StepProps) {
return null;
}
@@ -0,0 +1,18 @@
"use client";
import type {
MedicationPayload,
WizardPayload,
} from "../wizard-payload";
export interface Step8SummaryProps {
payload: WizardPayload;
applyPartial: (partial: Partial<WizardPayload>) => void;
mode: "create" | "edit";
initial?: MedicationPayload;
submitError: string | null;
}
export function Step8Summary(_props: Step8SummaryProps) {
return null;
}
@@ -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 | number>) => 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 | number>) => 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 | number>) => 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 `<MedicationWizardDialog mode="edit">`. 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 };