) => void;
+ t: (key: string) => string;
+}
+
+function CadenceOption({
+ kind,
+ selected,
+ groupName,
+ disabled,
+ i18nPrefix,
+ sub,
+ onPick,
+ onSubChange,
+ t,
+}: CadenceOptionProps) {
+ const inputId = useId();
+ const label = t(`${i18nPrefix}.kind.${kind}`);
+ return (
+
+
+
+
+ {label}
+
+
+
+ {selected && kind === "weekdays" && (
+
+ onSubChange({ weekdays })}
+ t={t}
+ />
+
+ )}
+
+ {selected && kind === "everyNWeeks" && (
+
+ onSubChange({ intervalWeeks })}
+ />
+ onSubChange({ weekdays })}
+ t={t}
+ />
+
+ )}
+
+ {selected && kind === "monthly" && (
+
+ onSubChange({ dayOfMonth })}
+ />
+
+ )}
+
+ {selected && kind === "everyNMonths" && (
+
+ onSubChange({ intervalMonths })}
+ />
+
+ {t(`${i18nPrefix}.intervalMonths.dayOnLabel`)}
+
+ onSubChange({ dayOfMonth })}
+ />
+
+ )}
+
+ {selected && kind === "yearly" && (
+
+ onSubChange({ yearlyDate })}
+ t={t}
+ />
+
+ )}
+
+ {selected && kind === "rolling" && (
+
+
onSubChange({ rollingDays })}
+ />
+
+ {t(`${i18nPrefix}.kind.rolling.explainer`)}
+
+
+ )}
+
+ {/* oneShot has no sub-controls — the dose date lives in CourseWindowRow. */}
+
+ );
+}
+
+// ────────────────────────────────────────────────────────────────────
+// Sub-controls
+// ────────────────────────────────────────────────────────────────────
+
+interface WeekdayChipsProps {
+ selected: WeekdayToken[];
+ disabled: boolean;
+ i18nPrefix: string;
+ onChange: (next: WeekdayToken[]) => void;
+ t: (key: string) => string;
+}
+
+function WeekdayChips({
+ selected,
+ disabled,
+ i18nPrefix,
+ onChange,
+ t,
+}: WeekdayChipsProps) {
+ const set = useMemo(() => new Set(selected), [selected]);
+ const toggle = (tok: WeekdayToken) => {
+ const next = new Set(set);
+ if (next.has(tok)) next.delete(tok);
+ else next.add(tok);
+ onChange(WEEKDAY_TOKENS.filter((w) => next.has(w)));
+ };
+
+ return (
+
+ {WEEKDAY_TOKENS.map((tok) => {
+ const isOn = set.has(tok);
+ const short = t(`${i18nPrefix}.weekdays.short.${tok.toLowerCase()}`);
+ const long = t(`${i18nPrefix}.weekdays.long.${tok.toLowerCase()}`);
+ return (
+ toggle(tok)}
+ className={[
+ "focus-visible:ring-ring inline-flex h-11 min-w-11 items-center justify-center rounded-md border px-2 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:opacity-50",
+ isOn
+ ? "bg-primary text-primary-foreground border-primary"
+ : "border-border bg-background text-foreground hover:bg-muted",
+ ].join(" ")}
+ >
+ {short}
+
+ );
+ })}
+
+ );
+}
+
+interface NumberWithSuffixProps {
+ value: number;
+ min: number;
+ max: number;
+ suffix: string;
+ disabled: boolean;
+ onChange: (next: number) => void;
+}
+
+function NumberWithSuffix({
+ value,
+ min,
+ max,
+ suffix,
+ disabled,
+ onChange,
+}: NumberWithSuffixProps) {
+ const id = useId();
+ return (
+
+
+ {suffix}
+
+ onChange(Number(e.target.value))}
+ className="h-11 w-20"
+ inputMode="numeric"
+ data-slot="cadence-number-input"
+ />
+ {suffix}
+
+ );
+}
+
+interface NumberWithLabelProps {
+ label: string;
+ value: number;
+ min: number;
+ max: number;
+ disabled: boolean;
+ onChange: (next: number) => void;
+}
+
+function NumberWithLabel({
+ label,
+ value,
+ min,
+ max,
+ disabled,
+ onChange,
+}: NumberWithLabelProps) {
+ const id = useId();
+ return (
+
+ {label ? (
+
+ {label}
+
+ ) : null}
+ onChange(Number(e.target.value))}
+ className="h-11 w-20"
+ inputMode="numeric"
+ aria-label={label || undefined}
+ data-slot="cadence-number-input"
+ />
+
+ );
+}
+
+interface YearlyDateProps {
+ value: string;
+ disabled: boolean;
+ i18nPrefix: string;
+ onChange: (next: string) => void;
+ t: (key: string) => string;
+}
+
+function YearlyDate({ value, disabled, i18nPrefix, onChange, t }: YearlyDateProps) {
+ const id = useId();
+ return (
+
+
+ {t(`${i18nPrefix}.yearly.date.label`)}
+
+ onChange(e.target.value)}
+ className="h-11"
+ data-slot="cadence-yearly-date"
+ />
+
+ );
+}
diff --git a/src/components/medications/scheduling/CourseWindowRow.tsx b/src/components/medications/scheduling/CourseWindowRow.tsx
new file mode 100644
index 000000000..ea62fe000
--- /dev/null
+++ b/src/components/medications/scheduling/CourseWindowRow.tsx
@@ -0,0 +1,211 @@
+"use client";
+
+/**
+ * v1.5.0 — CourseWindowRow component.
+ *
+ * Two-field row: `startsOn` (date picker, defaults to today on the
+ * parent side) + `endsOn` (date picker with a "No end date" Switch).
+ *
+ * When `lockEndsToStart` is true (one-shot context), both pickers
+ * show the same date and the second is read-only with a "(one-time
+ * dose)" caption.
+ *
+ * Validation: `endsOn >= startsOn`. When violated the component
+ * renders an error caption below the row; the form layer enforces
+ * submission.
+ *
+ * i18n keys consumed (namespace `medications.scheduling.courseWindow.*`):
+ *
+ * .startsOn.label — "Starts on"
+ * .endsOn.label — "Ends on"
+ * .noEndDate — "No end date"
+ * .oneShotCaption — "(one-time dose)"
+ * .invalidRange — "End date must be on or after start"
+ */
+
+import { useCallback, useId, useMemo } from "react";
+
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { useTranslations } from "@/lib/i18n/context";
+
+export interface CourseWindowRowProps {
+ startsOn: Date | null;
+ endsOn: Date | null;
+ onChange: (next: { startsOn: Date | null; endsOn: Date | null }) => void;
+ /** When true, endsOn is forced to equal startsOn (one-shot case). */
+ lockEndsToStart?: boolean;
+ disabled?: boolean;
+ /** Translation namespace prefix. */
+ i18nPrefix?: string;
+}
+
+// ────────────────────────────────────────────────────────────────────
+// Pure helpers — exported for unit tests
+// ────────────────────────────────────────────────────────────────────
+
+const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
+
+/** Convert a Date to an ISO YYYY-MM-DD wall-clock string (UTC day). */
+export function dateToIsoString(d: Date | null): string {
+ if (!d) return "";
+ const y = d.getUTCFullYear();
+ const m = String(d.getUTCMonth() + 1).padStart(2, "0");
+ const day = String(d.getUTCDate()).padStart(2, "0");
+ return `${y}-${m}-${day}`;
+}
+
+/** Parse YYYY-MM-DD → UTC midnight Date, or null. */
+export function isoStringToDate(s: string): Date | null {
+ if (!ISO_DATE_RE.test(s)) return null;
+ const [y, m, d] = s.split("-").map(Number);
+ return new Date(Date.UTC(y, m - 1, d, 0, 0, 0, 0));
+}
+
+/** Validate that endsOn ≥ startsOn (both Date or null). */
+export function isRangeValid(
+ startsOn: Date | null,
+ endsOn: Date | null,
+): boolean {
+ if (!startsOn || !endsOn) return true;
+ return endsOn.getTime() >= startsOn.getTime();
+}
+
+// ────────────────────────────────────────────────────────────────────
+// Component
+// ────────────────────────────────────────────────────────────────────
+
+export function CourseWindowRow({
+ startsOn,
+ endsOn,
+ onChange,
+ lockEndsToStart = false,
+ disabled = false,
+ i18nPrefix = "medications.scheduling.courseWindow",
+}: CourseWindowRowProps) {
+ const { t } = useTranslations();
+ const startId = useId();
+ const endId = useId();
+
+ const startsIso = useMemo(() => dateToIsoString(startsOn), [startsOn]);
+ const endsIso = useMemo(() => dateToIsoString(endsOn), [endsOn]);
+ const noEndDate = endsOn === null && !lockEndsToStart;
+ const valid = useMemo(
+ () => isRangeValid(startsOn, endsOn),
+ [startsOn, endsOn],
+ );
+
+ const onStartsChange = useCallback(
+ (iso: string) => {
+ const next = isoStringToDate(iso);
+ if (lockEndsToStart) {
+ onChange({ startsOn: next, endsOn: next });
+ return;
+ }
+ onChange({ startsOn: next, endsOn });
+ },
+ [endsOn, lockEndsToStart, onChange],
+ );
+
+ const onEndsChange = useCallback(
+ (iso: string) => {
+ const next = isoStringToDate(iso);
+ onChange({ startsOn, endsOn: next });
+ },
+ [onChange, startsOn],
+ );
+
+ const onNoEndToggle = useCallback(
+ (checked: boolean) => {
+ // Switch "on" = no end date (endsOn = null). "Off" = require a
+ // value; default to startsOn so the date input is editable but
+ // doesn't render empty.
+ if (checked) onChange({ startsOn, endsOn: null });
+ else onChange({ startsOn, endsOn: startsOn });
+ },
+ [onChange, startsOn],
+ );
+
+ return (
+
+
+ {/* startsOn */}
+
+
+ {t(`${i18nPrefix}.startsOn.label`)}
+
+ onStartsChange(e.target.value)}
+ className="h-11 w-full"
+ aria-label={t(`${i18nPrefix}.startsOn.label`)}
+ data-slot="course-window-starts"
+ />
+
+
+ {/* endsOn */}
+
+
+ {t(`${i18nPrefix}.endsOn.label`)}
+
+
onEndsChange(e.target.value)}
+ className="h-11 w-full"
+ aria-label={t(`${i18nPrefix}.endsOn.label`)}
+ aria-invalid={!valid || undefined}
+ data-slot="course-window-ends"
+ />
+ {lockEndsToStart && (
+
+ {t(`${i18nPrefix}.oneShotCaption`)}
+
+ )}
+
+
+ {/* No end date switch — hidden when locked to start. */}
+ {!lockEndsToStart && (
+
+
+
+ {t(`${i18nPrefix}.noEndDate`)}
+
+
+ )}
+
+
+ {!valid && (
+
+ {t(`${i18nPrefix}.invalidRange`)}
+
+ )}
+
+ );
+}
diff --git a/src/components/medications/scheduling/TimesOfDayChips.tsx b/src/components/medications/scheduling/TimesOfDayChips.tsx
new file mode 100644
index 000000000..3b01e87b3
--- /dev/null
+++ b/src/components/medications/scheduling/TimesOfDayChips.tsx
@@ -0,0 +1,278 @@
+"use client";
+
+/**
+ * v1.5.0 — TimesOfDayChips component.
+ *
+ * Multi-chip HH:mm input. Each chip is removable; adding a chip opens
+ * a small native ` `. A preset row above the chips
+ * offers Morning / Noon / Evening / Night — tapping a preset adds the
+ * matching chip if absent, removes it if present.
+ *
+ * When the parent passes `maxChips=1` (one-shot context), the
+ * component renders as a single ` ` (no chip list)
+ * since there is nothing meaningful to add or remove beyond the one
+ * value.
+ *
+ * Chips are sorted ascending HH:mm before display + emit. Duplicate
+ * adds are no-ops.
+ *
+ * i18n keys consumed (namespace `medications.scheduling.timesOfDay.*`):
+ *
+ * .label — input aria-label "Times of day"
+ * .empty.cta — "Add the first time"
+ * .add — button label "Add time"
+ * .remove — chip remove aria-label
+ * .presets.morning — chip "Morning 08:00"
+ * .presets.noon — chip "Noon 12:00"
+ * .presets.evening — chip "Evening 18:00"
+ * .presets.night — chip "Night 22:00"
+ * .max.reached — "Maximum reached" copy
+ */
+
+import { useCallback, useId, useMemo, useState } from "react";
+import { Plus, X } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { useTranslations } from "@/lib/i18n/context";
+
+export interface TimesOfDayChipsProps {
+ value: string[];
+ onChange: (next: string[]) => void;
+ /** Max chips. Default 8 (matches Zod schema cap). */
+ maxChips?: number;
+ /** Disables the input. */
+ disabled?: boolean;
+ /** Translation namespace prefix. */
+ i18nPrefix?: string;
+}
+
+/** Preset table — wall-clock HH:mm and i18n suffix key. */
+const PRESETS: ReadonlyArray<{ key: string; value: string }> = [
+ { key: "morning", value: "08:00" },
+ { key: "noon", value: "12:00" },
+ { key: "evening", value: "18:00" },
+ { key: "night", value: "22:00" },
+];
+
+const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
+
+// ────────────────────────────────────────────────────────────────────
+// Pure helpers — exported for unit tests
+// ────────────────────────────────────────────────────────────────────
+
+/** Sort HH:mm strings ascending; ignore malformed entries. */
+export function sortTimes(times: string[]): string[] {
+ return [...times]
+ .filter((t) => TIME_RE.test(t))
+ .sort((a, b) => a.localeCompare(b));
+}
+
+/** Add a HH:mm to the list; sorted, deduped, capped at maxChips. */
+export function addTime(
+ current: string[],
+ next: string,
+ maxChips: number,
+): string[] {
+ if (!TIME_RE.test(next)) return current;
+ if (current.includes(next)) return current;
+ if (current.length >= maxChips) return current;
+ return sortTimes([...current, next]);
+}
+
+/** Remove a HH:mm; returns sorted remainder. */
+export function removeTime(current: string[], target: string): string[] {
+ return sortTimes(current.filter((t) => t !== target));
+}
+
+/** Toggle a preset — present-then-remove, else add (if under cap). */
+export function togglePreset(
+ current: string[],
+ preset: string,
+ maxChips: number,
+): string[] {
+ if (current.includes(preset)) return removeTime(current, preset);
+ return addTime(current, preset, maxChips);
+}
+
+// ────────────────────────────────────────────────────────────────────
+// Component
+// ────────────────────────────────────────────────────────────────────
+
+export function TimesOfDayChips({
+ value,
+ onChange,
+ maxChips = 8,
+ disabled = false,
+ i18nPrefix = "medications.scheduling.timesOfDay",
+}: TimesOfDayChipsProps) {
+ const { t } = useTranslations();
+ const inputId = useId();
+ const sortedValue = useMemo(() => sortTimes(value), [value]);
+ const [draft, setDraft] = useState("");
+
+ const onAddDraft = useCallback(() => {
+ if (!draft) return;
+ const next = addTime(sortedValue, draft, maxChips);
+ onChange(next);
+ setDraft("");
+ }, [draft, maxChips, onChange, sortedValue]);
+
+ const onRemove = (target: string) => {
+ onChange(removeTime(sortedValue, target));
+ };
+
+ const onPresetToggle = (preset: string) => {
+ onChange(togglePreset(sortedValue, preset, maxChips));
+ };
+
+ const atCap = sortedValue.length >= maxChips;
+
+ // One-shot context — render a single time picker, no chip list.
+ if (maxChips === 1) {
+ const single = sortedValue[0] ?? "";
+ return (
+
+
+ {t(`${i18nPrefix}.label`)}
+
+ {
+ const v = e.target.value;
+ if (!v) {
+ onChange([]);
+ return;
+ }
+ if (!TIME_RE.test(v)) return;
+ onChange([v]);
+ }}
+ className="h-11 w-32"
+ aria-label={t(`${i18nPrefix}.label`)}
+ />
+
+ );
+ }
+
+ return (
+
+ {/* Preset row */}
+
+ {PRESETS.map(({ key, value: preset }) => {
+ const isOn = sortedValue.includes(preset);
+ const label = `${t(`${i18nPrefix}.presets.${key}`)} ${preset}`;
+ return (
+ onPresetToggle(preset)}
+ className={[
+ "focus-visible:ring-ring inline-flex h-11 items-center gap-1 rounded-md border px-3 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:opacity-50",
+ isOn
+ ? "bg-primary text-primary-foreground border-primary"
+ : "border-border bg-background text-foreground hover:bg-muted",
+ ].join(" ")}
+ >
+ {label}
+
+ );
+ })}
+
+
+ {/* Chip list */}
+ {sortedValue.length === 0 ? (
+
+ {t(`${i18nPrefix}.empty.cta`)}
+
+ ) : (
+
+ {sortedValue.map((time) => (
+
+ {time}
+ onRemove(time)}
+ className="focus-visible:ring-ring inline-flex h-7 w-7 items-center justify-center rounded-sm hover:bg-background focus-visible:outline-none focus-visible:ring-2 disabled:opacity-50"
+ data-slot="times-of-day-chip-remove"
+ >
+
+
+
+ ))}
+
+ )}
+
+ {/* Add-new row */}
+
+
+ {t(`${i18nPrefix}.add`)}
+
+
setDraft(e.target.value)}
+ className="h-11 w-32"
+ aria-label={t(`${i18nPrefix}.add`)}
+ data-slot="times-of-day-draft-input"
+ />
+
+
+ {t(`${i18nPrefix}.add`)}
+
+
+
+ {atCap && (
+
+ {t(`${i18nPrefix}.max.reached`)}
+
+ )}
+
+ );
+}
diff --git a/src/components/medications/scheduling/__tests__/CadencePicker.test.tsx b/src/components/medications/scheduling/__tests__/CadencePicker.test.tsx
new file mode 100644
index 000000000..874aca164
--- /dev/null
+++ b/src/components/medications/scheduling/__tests__/CadencePicker.test.tsx
@@ -0,0 +1,234 @@
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+
+import {
+ CadencePicker,
+ encodeCadence,
+} from "@/components/medications/scheduling/CadencePicker";
+import {
+ type CadenceKind,
+ type CadenceSubControls,
+ type CadenceValue,
+ DEFAULT_SUB_CONTROLS,
+} from "@/components/medications/scheduling/types";
+
+/**
+ * v1.5.0 — CadencePicker tests.
+ *
+ * Encoder mapping pinned per design synthesis (cadence-picker
+ * section). SSR smoke pins the conditional-sub-control hide-not-grey
+ * behaviour for each selected `kind`.
+ *
+ * Project convention is SSR-only (no `@testing-library/react`); the
+ * encoder is exported so the onChange-emission contract can be pinned
+ * without `userEvent`. The wizard-level e2e (Playwright) covers the
+ * click→emit path end-to-end.
+ */
+
+function render(node: React.ReactNode) {
+ return renderToStaticMarkup({node} );
+}
+
+function makeValue(kind: CadenceKind): CadenceValue {
+ return encodeCadence(kind, DEFAULT_SUB_CONTROLS);
+}
+
+const NOOP = () => undefined;
+
+describe("encodeCadence", () => {
+ const baseSub: CadenceSubControls = {
+ weekdays: ["MO", "WE", "FR"],
+ intervalWeeks: 2,
+ dayOfMonth: 15,
+ intervalMonths: 3,
+ yearlyDate: "2026-01-01",
+ rollingDays: 7,
+ };
+
+ it("daily → FREQ=DAILY", () => {
+ const v = encodeCadence("daily", baseSub);
+ expect(v).toEqual({
+ kind: "daily",
+ rrule: "FREQ=DAILY",
+ rollingIntervalDays: null,
+ oneShot: false,
+ });
+ });
+
+ it("weekdays (Mo, We, Fr) → FREQ=WEEKLY;BYDAY=MO,WE,FR", () => {
+ const v = encodeCadence("weekdays", baseSub);
+ expect(v.rrule).toBe("FREQ=WEEKLY;BYDAY=MO,WE,FR");
+ expect(v.kind).toBe("weekdays");
+ expect(v.rollingIntervalDays).toBeNull();
+ expect(v.oneShot).toBe(false);
+ });
+
+ it("weekdays with empty set defaults to [MO]", () => {
+ const v = encodeCadence("weekdays", { ...baseSub, weekdays: [] });
+ expect(v.rrule).toBe("FREQ=WEEKLY;BYDAY=MO");
+ });
+
+ it("everyNWeeks (n=2, We) → FREQ=WEEKLY;INTERVAL=2;BYDAY=WE", () => {
+ const v = encodeCadence("everyNWeeks", {
+ ...baseSub,
+ weekdays: ["WE"],
+ intervalWeeks: 2,
+ });
+ expect(v.rrule).toBe("FREQ=WEEKLY;INTERVAL=2;BYDAY=WE");
+ });
+
+ it("everyNWeeks with empty weekdays defaults to MO", () => {
+ const v = encodeCadence("everyNWeeks", {
+ ...baseSub,
+ weekdays: [],
+ intervalWeeks: 4,
+ });
+ expect(v.rrule).toBe("FREQ=WEEKLY;INTERVAL=4;BYDAY=MO");
+ });
+
+ it("monthly (day=1) → FREQ=MONTHLY;BYMONTHDAY=1", () => {
+ const v = encodeCadence("monthly", { ...baseSub, dayOfMonth: 1 });
+ expect(v.rrule).toBe("FREQ=MONTHLY;BYMONTHDAY=1");
+ });
+
+ it("everyNMonths (n=3, day=15) → FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15", () => {
+ const v = encodeCadence("everyNMonths", baseSub);
+ expect(v.rrule).toBe("FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15");
+ });
+
+ it("yearly (Jan 1) → FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1", () => {
+ const v = encodeCadence("yearly", { ...baseSub, yearlyDate: "2026-01-01" });
+ expect(v.rrule).toBe("FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1");
+ });
+
+ it("yearly with missing date defaults to Jan 1", () => {
+ const v = encodeCadence("yearly", { ...baseSub, yearlyDate: "" });
+ expect(v.rrule).toBe("FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1");
+ });
+
+ it("rolling → rollingIntervalDays only", () => {
+ const v = encodeCadence("rolling", { ...baseSub, rollingDays: 7 });
+ expect(v).toEqual({
+ kind: "rolling",
+ rrule: null,
+ rollingIntervalDays: 7,
+ oneShot: false,
+ });
+ });
+
+ it("oneShot → empty rrule + rolling, oneShot=true", () => {
+ const v = encodeCadence("oneShot", baseSub);
+ expect(v).toEqual({
+ kind: "oneShot",
+ rrule: null,
+ rollingIntervalDays: null,
+ oneShot: true,
+ });
+ });
+
+ it("clamps intervalWeeks to [1, 52]", () => {
+ expect(
+ encodeCadence("everyNWeeks", { ...baseSub, intervalWeeks: 0 }).rrule,
+ ).toBe("FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,FR");
+ expect(
+ encodeCadence("everyNWeeks", { ...baseSub, intervalWeeks: 999 }).rrule,
+ ).toBe("FREQ=WEEKLY;INTERVAL=52;BYDAY=MO,WE,FR");
+ });
+
+ it("clamps dayOfMonth to [1, 31]", () => {
+ expect(
+ encodeCadence("monthly", { ...baseSub, dayOfMonth: 99 }).rrule,
+ ).toBe("FREQ=MONTHLY;BYMONTHDAY=31");
+ expect(
+ encodeCadence("monthly", { ...baseSub, dayOfMonth: 0 }).rrule,
+ ).toBe("FREQ=MONTHLY;BYMONTHDAY=1");
+ });
+
+ it("clamps rollingDays to [1, 365]", () => {
+ expect(
+ encodeCadence("rolling", { ...baseSub, rollingDays: 0 })
+ .rollingIntervalDays,
+ ).toBe(1);
+ expect(
+ encodeCadence("rolling", { ...baseSub, rollingDays: 9999 })
+ .rollingIntervalDays,
+ ).toBe(365);
+ });
+});
+
+describe(" — SSR render", () => {
+ it("renders all 8 cadence options as radio inputs", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-slot="cadence-picker"');
+ expect(html.match(/data-slot="cadence-option"/g)?.length).toBe(8);
+ expect(html).toContain('data-kind="daily"');
+ expect(html).toContain('data-kind="weekdays"');
+ expect(html).toContain('data-kind="everyNWeeks"');
+ expect(html).toContain('data-kind="monthly"');
+ expect(html).toContain('data-kind="everyNMonths"');
+ expect(html).toContain('data-kind="yearly"');
+ expect(html).toContain('data-kind="rolling"');
+ expect(html).toContain('data-kind="oneShot"');
+ });
+
+ it("only shows weekday chips when 'weekdays' is selected", () => {
+ const dailyHtml = render(
+ ,
+ );
+ expect(dailyHtml).not.toContain('data-slot="cadence-weekday-chips"');
+
+ const weekdaysHtml = render(
+ ,
+ );
+ expect(weekdaysHtml).toContain('data-slot="cadence-weekday-chips"');
+ expect(
+ weekdaysHtml.match(/data-slot="cadence-weekday-chip"/g)?.length,
+ ).toBe(7);
+ });
+
+ it("shows the rolling explainer copy when rolling is selected", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-slot="cadence-rolling-explainer"');
+ });
+
+ it("hides all sub-controls when oneShot is selected", () => {
+ const html = render(
+ ,
+ );
+ expect(html).not.toContain('data-slot="cadence-weekday-chips"');
+ expect(html).not.toContain('data-slot="cadence-number-input"');
+ expect(html).not.toContain('data-slot="cadence-yearly-date"');
+ expect(html).not.toContain('data-slot="cadence-rolling-explainer"');
+ });
+
+ it("renders disabled state on the fieldset", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toMatch(/]*disabled/);
+ });
+
+ it("shows everyNMonths sub-controls (interval + day-of-month)", () => {
+ const html = render(
+ ,
+ );
+ expect(html.match(/data-slot="cadence-number-input"/g)?.length).toBe(2);
+ });
+
+ it("shows yearly date picker only when yearly is selected", () => {
+ const dailyHtml = render(
+ ,
+ );
+ expect(dailyHtml).not.toContain('data-slot="cadence-yearly-date"');
+ const yearlyHtml = render(
+ ,
+ );
+ expect(yearlyHtml).toContain('data-slot="cadence-yearly-date"');
+ });
+});
diff --git a/src/components/medications/scheduling/__tests__/CourseWindowRow.test.tsx b/src/components/medications/scheduling/__tests__/CourseWindowRow.test.tsx
new file mode 100644
index 000000000..3095d117d
--- /dev/null
+++ b/src/components/medications/scheduling/__tests__/CourseWindowRow.test.tsx
@@ -0,0 +1,142 @@
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+
+import {
+ CourseWindowRow,
+ dateToIsoString,
+ isRangeValid,
+ isoStringToDate,
+} from "@/components/medications/scheduling/CourseWindowRow";
+
+/**
+ * v1.5.0 — CourseWindowRow tests.
+ *
+ * Pure date conversion + validation helpers exercised directly; SSR
+ * smoke pins the lock-to-start / no-end-date / invalid-range render
+ * branches.
+ */
+
+function render(node: React.ReactNode) {
+ return renderToStaticMarkup({node} );
+}
+
+const NOOP = () => undefined;
+
+describe("dateToIsoString / isoStringToDate", () => {
+ it("round-trips a UTC date", () => {
+ const d = new Date(Date.UTC(2026, 4, 28, 0, 0, 0, 0));
+ expect(dateToIsoString(d)).toBe("2026-05-28");
+ const back = isoStringToDate("2026-05-28");
+ expect(back?.toISOString()).toBe("2026-05-28T00:00:00.000Z");
+ });
+
+ it("emits an empty string for null", () => {
+ expect(dateToIsoString(null)).toBe("");
+ });
+
+ it("rejects malformed ISO inputs", () => {
+ expect(isoStringToDate("2026/05/28")).toBeNull();
+ expect(isoStringToDate("not-a-date")).toBeNull();
+ expect(isoStringToDate("")).toBeNull();
+ });
+});
+
+describe("isRangeValid", () => {
+ const a = new Date(Date.UTC(2026, 4, 1));
+ const b = new Date(Date.UTC(2026, 4, 30));
+
+ it("accepts identical start + end", () => {
+ expect(isRangeValid(a, a)).toBe(true);
+ });
+
+ it("accepts end after start", () => {
+ expect(isRangeValid(a, b)).toBe(true);
+ });
+
+ it("rejects end before start", () => {
+ expect(isRangeValid(b, a)).toBe(false);
+ });
+
+ it("accepts a null on either side", () => {
+ expect(isRangeValid(null, b)).toBe(true);
+ expect(isRangeValid(a, null)).toBe(true);
+ expect(isRangeValid(null, null)).toBe(true);
+ });
+});
+
+describe(" — SSR render", () => {
+ it("renders both date inputs + the no-end Switch", () => {
+ const start = new Date(Date.UTC(2026, 4, 28));
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-slot="course-window-row"');
+ expect(html).toContain('data-slot="course-window-starts"');
+ expect(html).toContain('data-slot="course-window-ends"');
+ expect(html).toContain('data-slot="course-window-no-end"');
+ expect(html).toMatch(/value="2026-05-28"/);
+ });
+
+ it("disables the endsOn input when no-end-date is on (endsOn=null)", () => {
+ const start = new Date(Date.UTC(2026, 4, 28));
+ const html = render(
+ ,
+ );
+ expect(html).toMatch(/data-slot="course-window-ends"[^>]*disabled/);
+ });
+
+ it("hides the no-end switch + renders one-shot caption when lockEndsToStart", () => {
+ const start = new Date(Date.UTC(2026, 9, 15));
+ const html = render(
+ ,
+ );
+ expect(html).not.toContain('data-slot="course-window-no-end"');
+ expect(html).toContain('data-slot="course-window-oneshot-caption"');
+ // Both date inputs should mirror the same value.
+ expect(
+ html.match(/value="2026-10-15"/g)?.length ?? 0,
+ ).toBeGreaterThanOrEqual(2);
+ });
+
+ it("renders the invalid-range error when endsOn < startsOn", () => {
+ const start = new Date(Date.UTC(2026, 4, 28));
+ const end = new Date(Date.UTC(2026, 4, 1));
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-slot="course-window-error"');
+ expect(html).toMatch(/aria-invalid="true"/);
+ });
+
+ it("renders disabled state on inputs", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toMatch(/data-slot="course-window-starts"[^>]*disabled/);
+ expect(html).toMatch(/data-slot="course-window-ends"[^>]*disabled/);
+ });
+});
diff --git a/src/components/medications/scheduling/__tests__/TimesOfDayChips.test.tsx b/src/components/medications/scheduling/__tests__/TimesOfDayChips.test.tsx
new file mode 100644
index 000000000..3e21c8193
--- /dev/null
+++ b/src/components/medications/scheduling/__tests__/TimesOfDayChips.test.tsx
@@ -0,0 +1,146 @@
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+
+import {
+ TimesOfDayChips,
+ addTime,
+ removeTime,
+ sortTimes,
+ togglePreset,
+} from "@/components/medications/scheduling/TimesOfDayChips";
+
+/**
+ * v1.5.0 — TimesOfDayChips tests.
+ *
+ * The chip-list semantic (sort / dedupe / cap / preset toggle) is
+ * extracted into pure helpers so we can pin the behaviour without
+ * `@testing-library/react`. SSR smoke pins the render contract for
+ * the empty / chip / max-reached / one-shot variants.
+ */
+
+function render(node: React.ReactNode) {
+ return renderToStaticMarkup({node} );
+}
+
+const NOOP = () => undefined;
+
+describe("sortTimes", () => {
+ it("sorts ascending HH:mm", () => {
+ expect(sortTimes(["18:00", "08:00", "12:00"])).toEqual([
+ "08:00",
+ "12:00",
+ "18:00",
+ ]);
+ });
+
+ it("drops malformed entries", () => {
+ expect(sortTimes(["08:00", "nope", "25:99", "18:00"])).toEqual([
+ "08:00",
+ "18:00",
+ ]);
+ });
+});
+
+describe("addTime", () => {
+ it("adds valid HH:mm and keeps sort order", () => {
+ expect(addTime(["08:00"], "18:00", 8)).toEqual(["08:00", "18:00"]);
+ expect(addTime(["18:00"], "08:00", 8)).toEqual(["08:00", "18:00"]);
+ });
+
+ it("is a no-op when the time already exists (dedupe)", () => {
+ const r = addTime(["08:00", "12:00"], "08:00", 8);
+ expect(r).toEqual(["08:00", "12:00"]);
+ });
+
+ it("rejects malformed input", () => {
+ expect(addTime(["08:00"], "nope", 8)).toEqual(["08:00"]);
+ expect(addTime(["08:00"], "25:00", 8)).toEqual(["08:00"]);
+ });
+
+ it("refuses to add beyond maxChips", () => {
+ expect(addTime(["08:00", "12:00"], "18:00", 2)).toEqual([
+ "08:00",
+ "12:00",
+ ]);
+ });
+});
+
+describe("removeTime", () => {
+ it("removes the matching time and re-sorts", () => {
+ expect(removeTime(["08:00", "12:00", "18:00"], "12:00")).toEqual([
+ "08:00",
+ "18:00",
+ ]);
+ });
+
+ it("is a no-op for an absent time", () => {
+ expect(removeTime(["08:00"], "22:00")).toEqual(["08:00"]);
+ });
+});
+
+describe("togglePreset", () => {
+ it("adds the preset when absent", () => {
+ expect(togglePreset(["08:00"], "12:00", 8)).toEqual(["08:00", "12:00"]);
+ });
+
+ it("removes the preset when present", () => {
+ expect(togglePreset(["08:00", "12:00"], "12:00", 8)).toEqual(["08:00"]);
+ });
+
+ it("respects maxChips on add", () => {
+ expect(togglePreset(["08:00", "12:00"], "18:00", 2)).toEqual([
+ "08:00",
+ "12:00",
+ ]);
+ });
+});
+
+describe(" — SSR render", () => {
+ it("renders the empty CTA when value is empty", () => {
+ const html = render( );
+ expect(html).toContain('data-slot="times-of-day-empty"');
+ expect(html).toContain('data-slot="times-of-day-presets"');
+ });
+
+ it("renders one chip per value, sorted", () => {
+ const html = render(
+ ,
+ );
+ expect(html.match(/data-slot="times-of-day-chip"/g)?.length).toBe(2);
+ const idx08 = html.indexOf('data-time="08:00"');
+ const idx18 = html.indexOf('data-time="18:00"');
+ expect(idx08).toBeGreaterThan(-1);
+ expect(idx18).toBeGreaterThan(idx08);
+ });
+
+ it("marks a preset chip active when its time is in value", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toMatch(/data-preset="morning"[^>]*data-active="true"/);
+ expect(html).toMatch(/data-preset="evening"[^>]*data-active="false"/);
+ });
+
+ it("renders the max-reached caption when at cap", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-slot="times-of-day-max"');
+ });
+
+ it("renders a single when maxChips=1 (one-shot)", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-slot="times-of-day-single"');
+ expect(html).not.toContain('data-slot="times-of-day-list"');
+ expect(html).not.toContain('data-slot="times-of-day-presets"');
+ expect(html).toMatch(/value="09:30"/);
+ });
+});
diff --git a/src/components/medications/scheduling/types.ts b/src/components/medications/scheduling/types.ts
new file mode 100644
index 000000000..823e42a3f
--- /dev/null
+++ b/src/components/medications/scheduling/types.ts
@@ -0,0 +1,94 @@
+/**
+ * v1.5.0 — shared types for the medication-scheduling wizard
+ * components (`CadencePicker`, `TimesOfDayChips`, `CourseWindowRow`).
+ *
+ * The wizard layer composes these three primitives + a few non-
+ * scheduling steps (name/dose, reminders, summary) and maps the
+ * resulting `CadenceValue` + `timesOfDay[]` + `{ startsOn, endsOn }`
+ * into the create-medication request body shape declared in
+ * `src/lib/validations/medication.ts`:
+ *
+ * - `oneShot` lives at the medication level
+ * - `rrule` + `rollingIntervalDays` live on the schedule
+ * - `timesOfDay` lives on the schedule
+ * - `startsOn` + `endsOn` live at the medication level
+ *
+ * The components themselves are layer-agnostic. They emit / receive
+ * structured values; the wizard / form maps them to the wire format.
+ */
+
+/**
+ * The eight cadence kinds the wizard exposes. Maps to the engine's
+ * dispatch tiers in `src/lib/medications/scheduling/recurrence.ts`:
+ *
+ * - `daily` / `weekdays` / `everyNWeeks` / `monthly` / `everyNMonths`
+ * / `yearly` → RRULE branch
+ * - `rolling` → `rollingIntervalDays` branch
+ * - `oneShot` → one-shot branch
+ */
+export type CadenceKind =
+ | "daily"
+ | "weekdays"
+ | "everyNWeeks"
+ | "monthly"
+ | "everyNMonths"
+ | "yearly"
+ | "rolling"
+ | "oneShot";
+
+export interface CadenceValue {
+ kind: CadenceKind;
+ /**
+ * RRULE string when the kind is calendar-anchored (daily / weekdays
+ * / everyNWeeks / monthly / everyNMonths / yearly). NULL for rolling
+ * and oneShot.
+ */
+ rrule: string | null;
+ /** Rolling interval in days when the kind is rolling. NULL otherwise. */
+ rollingIntervalDays: number | null;
+ /** Marks the medication as a single-administration course. */
+ oneShot: boolean;
+}
+
+/**
+ * Internal sub-controls state. Kept on the picker as a single object
+ * so the parent only deals with the encoded `CadenceValue` while the
+ * picker remembers what the user typed if they toggle radios back +
+ * forth. Exported for the unit tests that pin the encoder mapping.
+ */
+export interface CadenceSubControls {
+ /** Selected weekdays (RFC 5545 BYDAY tokens). */
+ weekdays: WeekdayToken[];
+ /** Multi-week interval (1..52). */
+ intervalWeeks: number;
+ /** Day-of-month (1..31) for monthly + everyNMonths. */
+ dayOfMonth: number;
+ /** Multi-month interval (1..12) for everyNMonths. */
+ intervalMonths: number;
+ /** ISO YYYY-MM-DD for the yearly anchor (month + day extracted). */
+ yearlyDate: string;
+ /** Rolling cadence interval in days (1..365). */
+ rollingDays: number;
+}
+
+/** RFC 5545 BYDAY tokens — the order the chip row renders. */
+export const WEEKDAY_TOKENS = [
+ "MO",
+ "TU",
+ "WE",
+ "TH",
+ "FR",
+ "SA",
+ "SU",
+] as const;
+export type WeekdayToken = (typeof WEEKDAY_TOKENS)[number];
+
+/** Sensible defaults so a brand-new picker has valid sub-controls. */
+export const DEFAULT_SUB_CONTROLS: CadenceSubControls = {
+ weekdays: ["MO"],
+ intervalWeeks: 2,
+ dayOfMonth: 1,
+ intervalMonths: 3,
+ yearlyDate: "",
+ rollingDays: 7,
+};