mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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 };
|
||||
Reference in new issue
Block a user