mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
e686083bae
commit
105fcee242
6 files changed
+70
-3499
No files matched your search
@@ -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");
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user