refactor(meds): route /medications/new and the edit trigger through the new dialog

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) <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 e686083bae
commit 105fcee242
6 files changed
+70 -3499

No files matched your search

+10 -204
View File
@@ -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` (`<MedicationWizardDialog>` 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<WizardPayload>) => void) | null>(
null,
);
const handleNaturalLanguagePrefill = useCallback(
(apply: (partial: Partial<WizardPayload>) => void) => {
applyRef.current = apply;
setNlOpen(true);
},
[],
);
const handlePrefill = useCallback(
(partial: Partial<ExtractorPayload>) => {
const apply = applyRef.current;
if (!apply) return;
apply(extractorToWizardPartial(partial));
},
[],
);
const handleClose = useCallback(() => {
setNlOpen(false);
}, []);
return (
<div className="mx-auto max-w-2xl space-y-4">
<Link
href="/medications"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-sm"
data-slot="wizard-back-link"
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
{t(`${PAGE_NS}.backToList`)}
</Link>
<CreationWizard onNaturalLanguagePrefill={handleNaturalLanguagePrefill} />
<NaturalLanguageExtractor
open={nlOpen}
onClose={handleClose}
onPrefill={handlePrefill}
locale={locale}
/>
</div>
);
}
/**
* 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<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;
}
// 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<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;
}
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");
}
+60 -80
View File
@@ -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<Medication | null>(null);
const [importMedId, setImportMedId] = useState<string | null>(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<HTMLDivElement | null>(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 `<md`, centred Dialog on `md+`. */}
<ResponsiveSheet
{/* v1.5.4 — modal-wizard mount. The same component drives both
create (no initial) and edit (hydrates from the medication's
payload). The wizard owns its own ResponsiveSheet shell with
the dialog/sheet split and the sticky footer. */}
<MedicationWizardDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
title={
editingMed
? t("medications.editMedication")
: t("medications.newMedication")
}
className="sm:max-w-lg"
footer={<div ref={setFooterEl} className="flex w-full" />}
>
<MedicationForm
footerSlot={footerEl}
initial={
editingMed
? {
id: editingMed.id,
name: editingMed.name,
dose: editingMed.dose,
category: editingMed.category,
// v1.4.25 W4d — pass through the Prisma treatment-class
// discriminator and the optional dosesPerUnit so the
// form preselects the GLP-1 surfaces correctly when
// editing an existing GLP-1 row.
treatmentClass: editingMed.treatmentClass,
dosesPerUnit: editingMed.dosesPerUnit ?? null,
active: editingMed.active,
notificationsEnabled: editingMed.notificationsEnabled,
// v1.5 — medication-level course window + one-shot
// flag. Pass-through so the form can hydrate the
// CourseWindowRow + oneShot Switch with the row's
// current values.
startsOn: editingMed.startsOn
? new Date(editingMed.startsOn)
: null,
endsOn: editingMed.endsOn
? new Date(editingMed.endsOn)
: null,
oneShot: editingMed.oneShot ?? false,
schedules: editingMed.schedules.map((s) => ({
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}
/>
</ResponsiveSheet>
mode={editingMed ? "edit" : "create"}
initial={editingMed ? medicationToPayload(editingMed) : undefined}
onSuccess={closeDialog}
/>
</div>
);
}
/**
* 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,
File diff suppressed because it is too large. Load diff
@@ -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<PhaseConfigData | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(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 (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("medications.phaseConfig")}
description={t("medications.phaseConfigDescription")}
className="sm:max-w-md"
footer={
<div className="flex w-full flex-row justify-between gap-2 sm:justify-between">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => resetMutation.mutate()}
disabled={resetMutation.isPending}
>
<RotateCcw className="mr-2 h-4 w-4" />
{t("medications.phaseResetDefaults")}
</Button>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={() => saveMutation.mutate(form)}
disabled={saveMutation.isPending}
>
{saveMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</div>
</div>
}
>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
) : (
<div className="space-y-3">
{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 `<sm` so
// the input + mode toggle + suffix caption don't
// overflow the 280-300 px dialog inner width on
// Galaxy Fold. The dot + phase label stay on the
// first line; the input / toggle / suffix wrap
// beneath. At `sm:` the original single-line layout
// returns because the wider dialog can absorb the
// five horizontal slots.
<div
key={key}
className="flex flex-wrap items-center gap-2 sm:flex-nowrap"
>
<div
className={`h-3 w-3 rounded-full ${dotClass} shrink-0`}
aria-hidden="true"
/>
<span className="w-14 shrink-0 text-sm font-medium">
{phaseLabel(key)}
</span>
<Input
type="number"
min={0}
max={1440}
value={value}
onChange={(e) =>
updateValue(key, parseInt(e.target.value, 10) || 0)
}
className="h-11 w-20 sm:h-9"
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-11 w-12 px-0 text-xs sm:h-9"
onClick={() => toggleMode(key)}
>
{mode === "MINUTES"
? t("medications.phaseMinutes")
: t("medications.phasePercent")}
</Button>
<span className="text-muted-foreground basis-full text-xs sm:basis-auto sm:shrink-0">
{direction === "before"
? t("medications.phaseBeforeEnd")
: t("medications.phaseAfterEnd")}
</span>
</div>
);
})}
</div>
)}
{statusMessage && (
<div className="text-center text-sm text-green-500">
{statusMessage}
</div>
)}
</ResponsiveSheet>
);
}
File diff suppressed because it is too large. Load diff
@@ -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, string | number>): string => {
if (!params) return key;
return `${key}(${JSON.stringify(params)})`;
};
}
function withCadence(
kind: CadenceKind,
sub: Partial<typeof DEFAULT_SUB_CONTROLS> = {},
): WizardPayload {
const subControls = { ...DEFAULT_SUB_CONTROLS, ...sub };
return {
...emptyWizardPayload(),
mode: kind === "oneShot" ? "oneShot" : "recurring",
name: "Foo",
doseAmount: "5",
doseUnit: "mg",
cadence: encodeCadence(kind, subControls),
subControls,
};
}
describe("validateStep", () => {
it("step 1: rejects empty name + empty dose; accepts both populated", () => {
const base = emptyWizardPayload();
expect(validateStep(base, 1)).toBe(false);
expect(validateStep({ ...base, name: "Foo" }, 1)).toBe(false);
expect(validateStep({ ...base, doseAmount: "5" }, 1)).toBe(false);
expect(validateStep({ ...base, name: "Foo", doseAmount: "5" }, 1)).toBe(
true,
);
});
it("step 1: trims whitespace before deciding the gate", () => {
const base = { ...emptyWizardPayload(), name: " ", doseAmount: " " };
expect(validateStep(base, 1)).toBe(false);
});
it("step 2: requires a picked mode", () => {
const base = { ...emptyWizardPayload(), name: "Foo", doseAmount: "5" };
expect(validateStep(base, 2)).toBe(false);
expect(validateStep({ ...base, mode: "oneShot" }, 2)).toBe(true);
expect(validateStep({ ...base, mode: "recurring" }, 2)).toBe(true);
});
it("step 3: always passes for one-shot (cadence step is skipped)", () => {
const p = withCadence("oneShot");
expect(validateStep(p, 3)).toBe(true);
});
it("step 3: passes for daily / weekdays / monthly / rolling", () => {
expect(validateStep(withCadence("daily"), 3)).toBe(true);
expect(validateStep(withCadence("weekdays"), 3)).toBe(true);
expect(validateStep(withCadence("monthly"), 3)).toBe(true);
expect(validateStep(withCadence("rolling"), 3)).toBe(true);
});
it("step 3: rejects yearly without a real picked date", () => {
expect(
validateStep(withCadence("yearly", { yearlyDate: "" }), 3),
).toBe(false);
expect(
validateStep(withCadence("yearly", { yearlyDate: "2026-01-01" }), 3),
).toBe(true);
});
it("step 4: 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]);
});
});