feat(meds): reusable cadence-picker, times-of-day, and course-window components

Three components plus a shared `types.ts` module under
`src/components/medications/scheduling/`. The wizard + the form
refactor consume them; the picker emits a `CadenceValue`
discriminated union that the form maps onto the API request body.

### CadencePicker

A grouped flat radio list with 8 cadence kinds:

- Every day → `FREQ=DAILY`
- Certain weekdays (chip-toggle row) → `FREQ=WEEKLY;BYDAY=...`
- Every N weeks on certain days → `FREQ=WEEKLY;INTERVAL=N;BYDAY=...`
- Monthly on day → `FREQ=MONTHLY;BYMONTHDAY=N`
- Every N months on day → `FREQ=MONTHLY;INTERVAL=N;BYMONTHDAY=M`
- Yearly on date → `FREQ=YEARLY;BYMONTH=M;BYMONTHDAY=D`
- Rolling N days from last intake → `rollingIntervalDays: N`
- One-time dose → `oneShot: true`

The "Every N days flexible" option carries an explainer line so
the GLP-1 patient understands the rolling semantic. Defaults are
chosen so the encoded RRULE is always parseable (weekday set
defaults to Monday, yearly clamps to Jan 1) — the wizard / form
gates "continue" until the user picks the real day.

Sub-controls state is owned by the parent (the picker accepts
`subControls` + emits via the second `onChange` arg) so a wizard
that re-enters step 3 sees the user's previous picks.

### TimesOfDayChips

Multi-chip HH:mm input with morning / noon / evening / night
presets. Dedupes + sorts. `maxChips` prop honours the Zod cap of
8; when the parent passes `maxChips: 1` (one-shot context) the
component renders a single time picker instead of a chip row.

### CourseWindowRow

Two date pickers (`startsOn` + `endsOn`) with a "No end date"
toggle. `lockEndsToStart` prop forces the one-shot case where
both pickers share a date. Validation message rendered below the
row when `endsOn < startsOn`.

### Quality

- "use client" on every component file.
- shadcn/ui-only — no new deps, no shadcn Calendar primitive in
  this repo so date pickers fall back to `<Input type="date">`.
- WCAG 2.5.5 tap targets (44 px) across every interactive
  element; focus rings, aria-labels, aria-pressed on toggles,
  role="group" on chip rows.
- 49 tests across the three components (encoder mapping, sort /
  dedupe / preset toggle, range validation, SSR-only render
  smoke tests). Pure helpers exported so the contract is pinned
  at the unit-test level; user-event paths land in Playwright
  e2e per the synthesis quality bar.

i18n keys listed at the top of each component file under
`medications.scheduling.{cadence,timesOfDay,courseWindow}.*`. The
follow-up i18n agent populates DE + EN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 08:22:33 +02:00
1 parent 925138eccb
commit 57ff8776f5
7 files changed
+1691

No files matched your search

@@ -0,0 +1,586 @@
"use client";
/**
* v1.5.0 — CadencePicker component.
*
* Single picker exposing all 8 cadence kinds as a flat-grouped radio
* list (per design-synthesis R-1 + R-4). One native radio drives the
* `kind`; conditional sub-controls render only when their parent
* radio is selected (hide-not-grey-out).
*
* Emits a `CadenceValue` (`{ kind, rrule, rollingIntervalDays,
* oneShot }`) via `onChange`. The wizard / form layer maps that to
* the wire shape — `oneShot` to the medication level, `rrule` +
* `rollingIntervalDays` to the schedule.
*
* No new dependencies. Uses native `<input type="radio">` for the
* grouped-flat-list semantic (no `RadioGroup` primitive ships in
* `src/components/ui/`); shadcn `<Label>`, `<Input>` for sub-controls.
*
* i18n keys consumed (namespace `medications.scheduling.cadence.*`):
*
* .label — picker group ariaLabel
* .kind.daily — "Every day"
* .kind.weekdays — "Certain days of the week"
* .kind.everyNWeeks — "Every N weeks on certain days"
* .kind.monthly — "Monthly on day"
* .kind.everyNMonths — "Every N months on day"
* .kind.yearly — "Yearly on date"
* .kind.rolling — "Every N days from when I last took it (flexible)"
* .kind.rolling.explainer — "Counts from your last logged intake — pauses if you skip"
* .kind.oneShot — "One-time dose"
* .weekdays.label — chip-row aria-label "Days of week"
* .weekdays.short.mo|tu|we|th|fr|sa|su — chip abbreviations
* .weekdays.long.mo|tu|we|th|fr|sa|su — chip aria-labels (full name)
* .intervalWeeks.suffix — "weeks"
* .dayOfMonth.label — "Day"
* .intervalMonths.suffix — "months"
* .intervalMonths.dayOnLabel — "on day"
* .yearly.date.label — "Date"
* .rollingDays.suffix — "days"
*/
import { useCallback, useId, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import {
type CadenceKind,
type CadenceSubControls,
type CadenceValue,
DEFAULT_SUB_CONTROLS,
type WeekdayToken,
WEEKDAY_TOKENS,
} from "./types";
export interface CadencePickerProps {
value: CadenceValue;
/** Internal sub-controls; the parent owns them so the picker stays
* stateless. Optional — if omitted, `DEFAULT_SUB_CONTROLS` is used
* and the picker simply re-emits with the chosen defaults. */
subControls?: CadenceSubControls;
onChange: (next: CadenceValue, subControls: CadenceSubControls) => void;
/** Disables the entire picker (edit-locked-while-active medications). */
disabled?: boolean;
/** Translation namespace prefix. */
i18nPrefix?: string;
}
const ALL_KINDS: CadenceKind[] = [
"daily",
"weekdays",
"everyNWeeks",
"monthly",
"everyNMonths",
"yearly",
"rolling",
"oneShot",
];
// ────────────────────────────────────────────────────────────────────
// Encoder — pure helper (exported for unit tests)
// ────────────────────────────────────────────────────────────────────
/**
* Map a (kind, subControls) pair to a `CadenceValue`. Pure function;
* the picker calls this on every interaction to produce the next
* value emitted to `onChange`.
*
* Defaults:
* - `weekdays` / `everyNWeeks` with no days selected → default to
* `[MO]` so the encoded RRULE is always valid.
* - `yearly` with no date set → uses Jan 1 placeholder
* (`FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1`); the wizard should still
* gate the "continue" button until the user picks a real date.
*/
export function encodeCadence(
kind: CadenceKind,
sub: CadenceSubControls,
): CadenceValue {
switch (kind) {
case "daily":
return {
kind,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
oneShot: false,
};
case "weekdays": {
const days = sub.weekdays.length > 0 ? sub.weekdays : ["MO"];
return {
kind,
rrule: `FREQ=WEEKLY;BYDAY=${days.join(",")}`,
rollingIntervalDays: null,
oneShot: false,
};
}
case "everyNWeeks": {
const days = sub.weekdays.length > 0 ? sub.weekdays : ["MO"];
const n = clamp(sub.intervalWeeks, 1, 52);
return {
kind,
rrule: `FREQ=WEEKLY;INTERVAL=${n};BYDAY=${days.join(",")}`,
rollingIntervalDays: null,
oneShot: false,
};
}
case "monthly": {
const d = clamp(sub.dayOfMonth, 1, 31);
return {
kind,
rrule: `FREQ=MONTHLY;BYMONTHDAY=${d}`,
rollingIntervalDays: null,
oneShot: false,
};
}
case "everyNMonths": {
const n = clamp(sub.intervalMonths, 1, 12);
const d = clamp(sub.dayOfMonth, 1, 31);
return {
kind,
rrule: `FREQ=MONTHLY;INTERVAL=${n};BYMONTHDAY=${d}`,
rollingIntervalDays: null,
oneShot: false,
};
}
case "yearly": {
const parsed = parseIsoDate(sub.yearlyDate);
const month = parsed?.month ?? 1;
const day = parsed?.day ?? 1;
return {
kind,
rrule: `FREQ=YEARLY;BYMONTH=${month};BYMONTHDAY=${day}`,
rollingIntervalDays: null,
oneShot: false,
};
}
case "rolling": {
const n = clamp(sub.rollingDays, 1, 365);
return {
kind,
rrule: null,
rollingIntervalDays: n,
oneShot: false,
};
}
case "oneShot":
return {
kind,
rrule: null,
rollingIntervalDays: null,
oneShot: true,
};
}
}
function clamp(n: number, min: number, max: number): number {
if (!Number.isFinite(n)) return min;
return Math.max(min, Math.min(max, Math.trunc(n)));
}
function parseIsoDate(s: string): { year: number; month: number; day: number } | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
const [y, m, d] = s.split("-").map(Number);
if (!y || !m || !d) return null;
return { year: y, month: m, day: d };
}
// ────────────────────────────────────────────────────────────────────
// Component
// ────────────────────────────────────────────────────────────────────
export function CadencePicker({
value,
subControls,
onChange,
disabled = false,
i18nPrefix = "medications.scheduling.cadence",
}: CadencePickerProps) {
const { t } = useTranslations();
const groupName = useId();
const sub = subControls ?? DEFAULT_SUB_CONTROLS;
const emit = useCallback(
(kind: CadenceKind, nextSub: CadenceSubControls) => {
onChange(encodeCadence(kind, nextSub), nextSub);
},
[onChange],
);
const onPickKind = useCallback(
(kind: CadenceKind) => {
emit(kind, sub);
},
[emit, sub],
);
const onSubChange = useCallback(
(patch: Partial<CadenceSubControls>) => {
emit(value.kind, { ...sub, ...patch });
},
[emit, sub, value.kind],
);
return (
<fieldset
disabled={disabled}
className="space-y-2"
aria-label={t(`${i18nPrefix}.label`)}
data-slot="cadence-picker"
>
{ALL_KINDS.map((kind) => {
const selected = value.kind === kind;
return (
<CadenceOption
key={kind}
kind={kind}
selected={selected}
groupName={groupName}
disabled={disabled}
i18nPrefix={i18nPrefix}
sub={sub}
onPick={() => onPickKind(kind)}
onSubChange={onSubChange}
t={t}
/>
);
})}
</fieldset>
);
}
// ────────────────────────────────────────────────────────────────────
// CadenceOption — one row (radio + label + conditional sub-controls)
// ────────────────────────────────────────────────────────────────────
interface CadenceOptionProps {
kind: CadenceKind;
selected: boolean;
groupName: string;
disabled: boolean;
i18nPrefix: string;
sub: CadenceSubControls;
onPick: () => void;
onSubChange: (patch: Partial<CadenceSubControls>) => 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 (
<div
className="border-border/60 hover:bg-muted/30 has-checked:bg-muted/40 rounded-md border p-3 transition-colors"
data-slot="cadence-option"
data-kind={kind}
>
<div className="flex min-h-11 items-center gap-3">
<input
id={inputId}
type="radio"
name={groupName}
checked={selected}
disabled={disabled}
onChange={onPick}
aria-label={label}
className="text-primary focus-visible:ring-ring h-4 w-4 cursor-pointer focus-visible:outline-none focus-visible:ring-2"
data-slot="cadence-option-radio"
/>
<Label
htmlFor={inputId}
className="cursor-pointer select-none text-sm font-medium"
>
{label}
</Label>
</div>
{selected && kind === "weekdays" && (
<div className="mt-3">
<WeekdayChips
selected={sub.weekdays}
disabled={disabled}
i18nPrefix={i18nPrefix}
onChange={(weekdays) => onSubChange({ weekdays })}
t={t}
/>
</div>
)}
{selected && kind === "everyNWeeks" && (
<div className="mt-3 space-y-3">
<NumberWithSuffix
value={sub.intervalWeeks}
min={1}
max={52}
suffix={t(`${i18nPrefix}.intervalWeeks.suffix`)}
disabled={disabled}
onChange={(intervalWeeks) => onSubChange({ intervalWeeks })}
/>
<WeekdayChips
selected={sub.weekdays}
disabled={disabled}
i18nPrefix={i18nPrefix}
onChange={(weekdays) => onSubChange({ weekdays })}
t={t}
/>
</div>
)}
{selected && kind === "monthly" && (
<div className="mt-3">
<NumberWithLabel
label={t(`${i18nPrefix}.dayOfMonth.label`)}
value={sub.dayOfMonth}
min={1}
max={31}
disabled={disabled}
onChange={(dayOfMonth) => onSubChange({ dayOfMonth })}
/>
</div>
)}
{selected && kind === "everyNMonths" && (
<div className="mt-3 flex flex-wrap items-end gap-3">
<NumberWithSuffix
value={sub.intervalMonths}
min={1}
max={12}
suffix={t(`${i18nPrefix}.intervalMonths.suffix`)}
disabled={disabled}
onChange={(intervalMonths) => onSubChange({ intervalMonths })}
/>
<span className="text-muted-foreground pb-2 text-sm">
{t(`${i18nPrefix}.intervalMonths.dayOnLabel`)}
</span>
<NumberWithLabel
label=""
value={sub.dayOfMonth}
min={1}
max={31}
disabled={disabled}
onChange={(dayOfMonth) => onSubChange({ dayOfMonth })}
/>
</div>
)}
{selected && kind === "yearly" && (
<div className="mt-3">
<YearlyDate
value={sub.yearlyDate}
disabled={disabled}
i18nPrefix={i18nPrefix}
onChange={(yearlyDate) => onSubChange({ yearlyDate })}
t={t}
/>
</div>
)}
{selected && kind === "rolling" && (
<div className="mt-3 space-y-2">
<NumberWithSuffix
value={sub.rollingDays}
min={1}
max={365}
suffix={t(`${i18nPrefix}.rollingDays.suffix`)}
disabled={disabled}
onChange={(rollingDays) => onSubChange({ rollingDays })}
/>
<p
className="text-muted-foreground text-xs"
data-slot="cadence-rolling-explainer"
>
{t(`${i18nPrefix}.kind.rolling.explainer`)}
</p>
</div>
)}
{/* oneShot has no sub-controls — the dose date lives in CourseWindowRow. */}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// 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 (
<div
role="group"
aria-label={t(`${i18nPrefix}.weekdays.label`)}
className="flex flex-wrap gap-1.5"
data-slot="cadence-weekday-chips"
>
{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 (
<button
key={tok}
type="button"
disabled={disabled}
aria-pressed={isOn}
aria-label={long}
data-slot="cadence-weekday-chip"
data-token={tok}
data-active={isOn ? "true" : "false"}
onClick={() => 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}
</button>
);
})}
</div>
);
}
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 (
<div className="flex items-center gap-2">
<Label htmlFor={id} className="sr-only">
{suffix}
</Label>
<Input
id={id}
type="number"
min={min}
max={max}
value={value}
disabled={disabled}
onChange={(e) => onChange(Number(e.target.value))}
className="h-11 w-20"
inputMode="numeric"
data-slot="cadence-number-input"
/>
<span className="text-sm">{suffix}</span>
</div>
);
}
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 (
<div className="flex items-center gap-2">
{label ? (
<Label htmlFor={id} className="text-sm">
{label}
</Label>
) : null}
<Input
id={id}
type="number"
min={min}
max={max}
value={value}
disabled={disabled}
onChange={(e) => onChange(Number(e.target.value))}
className="h-11 w-20"
inputMode="numeric"
aria-label={label || undefined}
data-slot="cadence-number-input"
/>
</div>
);
}
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 (
<div className="flex items-center gap-2">
<Label htmlFor={id} className="text-sm">
{t(`${i18nPrefix}.yearly.date.label`)}
</Label>
<Input
id={id}
type="date"
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
className="h-11"
data-slot="cadence-yearly-date"
/>
</div>
);
}
@@ -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 (
<div className="space-y-3" data-slot="course-window-row">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
{/* startsOn */}
<div className="flex-1 space-y-1.5">
<Label htmlFor={startId} className="text-sm">
{t(`${i18nPrefix}.startsOn.label`)}
</Label>
<Input
id={startId}
type="date"
value={startsIso}
disabled={disabled}
onChange={(e) => onStartsChange(e.target.value)}
className="h-11 w-full"
aria-label={t(`${i18nPrefix}.startsOn.label`)}
data-slot="course-window-starts"
/>
</div>
{/* endsOn */}
<div className="flex-1 space-y-1.5">
<Label htmlFor={endId} className="text-sm">
{t(`${i18nPrefix}.endsOn.label`)}
</Label>
<Input
id={endId}
type="date"
value={endsIso}
disabled={
disabled || noEndDate || lockEndsToStart
}
readOnly={lockEndsToStart}
onChange={(e) => 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 && (
<p
className="text-muted-foreground text-xs"
data-slot="course-window-oneshot-caption"
>
{t(`${i18nPrefix}.oneShotCaption`)}
</p>
)}
</div>
{/* No end date switch — hidden when locked to start. */}
{!lockEndsToStart && (
<div
className="flex min-h-11 items-center gap-2 pb-1"
data-slot="course-window-no-end-wrap"
>
<Switch
id={`${endId}-no-end`}
checked={noEndDate}
disabled={disabled}
onCheckedChange={onNoEndToggle}
aria-label={t(`${i18nPrefix}.noEndDate`)}
data-slot="course-window-no-end"
/>
<Label htmlFor={`${endId}-no-end`} className="text-sm">
{t(`${i18nPrefix}.noEndDate`)}
</Label>
</div>
)}
</div>
{!valid && (
<p
className="text-destructive text-xs"
role="alert"
data-slot="course-window-error"
>
{t(`${i18nPrefix}.invalidRange`)}
</p>
)}
</div>
);
}
@@ -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 `<input type="time">`. 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 `<input type="time">` (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 (
<div data-slot="times-of-day-single" className="flex items-center gap-2">
<Label htmlFor={inputId} className="sr-only">
{t(`${i18nPrefix}.label`)}
</Label>
<Input
id={inputId}
type="time"
value={single}
disabled={disabled}
onChange={(e) => {
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`)}
/>
</div>
);
}
return (
<div
className="space-y-3"
data-slot="times-of-day-chips"
aria-label={t(`${i18nPrefix}.label`)}
>
{/* Preset row */}
<div
role="group"
aria-label={t(`${i18nPrefix}.label`)}
className="flex flex-wrap gap-1.5"
data-slot="times-of-day-presets"
>
{PRESETS.map(({ key, value: preset }) => {
const isOn = sortedValue.includes(preset);
const label = `${t(`${i18nPrefix}.presets.${key}`)} ${preset}`;
return (
<button
key={key}
type="button"
disabled={disabled || (!isOn && atCap)}
aria-pressed={isOn}
aria-label={label}
data-slot="times-of-day-preset"
data-preset={key}
data-active={isOn ? "true" : "false"}
onClick={() => 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}
</button>
);
})}
</div>
{/* Chip list */}
{sortedValue.length === 0 ? (
<p
className="text-muted-foreground text-sm"
data-slot="times-of-day-empty"
>
{t(`${i18nPrefix}.empty.cta`)}
</p>
) : (
<ul
className="flex flex-wrap gap-1.5"
data-slot="times-of-day-list"
aria-label={t(`${i18nPrefix}.label`)}
>
{sortedValue.map((time) => (
<li
key={time}
data-slot="times-of-day-chip"
data-time={time}
className="bg-muted text-foreground inline-flex h-11 items-center gap-1.5 rounded-md border px-3 text-sm font-medium"
>
<span>{time}</span>
<button
type="button"
disabled={disabled}
aria-label={`${t(`${i18nPrefix}.remove`)} ${time}`}
onClick={() => 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"
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</li>
))}
</ul>
)}
{/* Add-new row */}
<div className="flex items-center gap-2">
<Label htmlFor={inputId} className="sr-only">
{t(`${i18nPrefix}.add`)}
</Label>
<Input
id={inputId}
type="time"
value={draft}
disabled={disabled || atCap}
onChange={(e) => setDraft(e.target.value)}
className="h-11 w-32"
aria-label={t(`${i18nPrefix}.add`)}
data-slot="times-of-day-draft-input"
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled || !draft || atCap}
onClick={onAddDraft}
className="h-11"
aria-label={t(`${i18nPrefix}.add`)}
data-slot="times-of-day-add"
>
<Plus className="mr-1 h-4 w-4" aria-hidden="true" />
{t(`${i18nPrefix}.add`)}
</Button>
</div>
{atCap && (
<p
className="text-muted-foreground text-xs"
data-slot="times-of-day-max"
>
{t(`${i18nPrefix}.max.reached`)}
</p>
)}
</div>
);
}
@@ -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(<I18nProvider initialLocale="en">{node}</I18nProvider>);
}
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("<CadencePicker> — SSR render", () => {
it("renders all 8 cadence options as radio inputs", () => {
const html = render(
<CadencePicker value={makeValue("daily")} onChange={NOOP} />,
);
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(
<CadencePicker value={makeValue("daily")} onChange={NOOP} />,
);
expect(dailyHtml).not.toContain('data-slot="cadence-weekday-chips"');
const weekdaysHtml = render(
<CadencePicker value={makeValue("weekdays")} onChange={NOOP} />,
);
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(
<CadencePicker value={makeValue("rolling")} onChange={NOOP} />,
);
expect(html).toContain('data-slot="cadence-rolling-explainer"');
});
it("hides all sub-controls when oneShot is selected", () => {
const html = render(
<CadencePicker value={makeValue("oneShot")} onChange={NOOP} />,
);
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(
<CadencePicker value={makeValue("daily")} onChange={NOOP} disabled />,
);
expect(html).toMatch(/<fieldset[^>]*disabled/);
});
it("shows everyNMonths sub-controls (interval + day-of-month)", () => {
const html = render(
<CadencePicker value={makeValue("everyNMonths")} onChange={NOOP} />,
);
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(
<CadencePicker value={makeValue("daily")} onChange={NOOP} />,
);
expect(dailyHtml).not.toContain('data-slot="cadence-yearly-date"');
const yearlyHtml = render(
<CadencePicker value={makeValue("yearly")} onChange={NOOP} />,
);
expect(yearlyHtml).toContain('data-slot="cadence-yearly-date"');
});
});
@@ -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(<I18nProvider initialLocale="en">{node}</I18nProvider>);
}
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("<CourseWindowRow> — SSR render", () => {
it("renders both date inputs + the no-end Switch", () => {
const start = new Date(Date.UTC(2026, 4, 28));
const html = render(
<CourseWindowRow
startsOn={start}
endsOn={null}
onChange={NOOP}
/>,
);
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(
<CourseWindowRow
startsOn={start}
endsOn={null}
onChange={NOOP}
/>,
);
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(
<CourseWindowRow
startsOn={start}
endsOn={start}
onChange={NOOP}
lockEndsToStart
/>,
);
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(
<CourseWindowRow
startsOn={start}
endsOn={end}
onChange={NOOP}
/>,
);
expect(html).toContain('data-slot="course-window-error"');
expect(html).toMatch(/aria-invalid="true"/);
});
it("renders disabled state on inputs", () => {
const html = render(
<CourseWindowRow
startsOn={null}
endsOn={null}
onChange={NOOP}
disabled
/>,
);
expect(html).toMatch(/data-slot="course-window-starts"[^>]*disabled/);
expect(html).toMatch(/data-slot="course-window-ends"[^>]*disabled/);
});
});
@@ -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(<I18nProvider initialLocale="en">{node}</I18nProvider>);
}
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("<TimesOfDayChips> — SSR render", () => {
it("renders the empty CTA when value is empty", () => {
const html = render(<TimesOfDayChips value={[]} onChange={NOOP} />);
expect(html).toContain('data-slot="times-of-day-empty"');
expect(html).toContain('data-slot="times-of-day-presets"');
});
it("renders one <li> chip per value, sorted", () => {
const html = render(
<TimesOfDayChips value={["18:00", "08:00"]} onChange={NOOP} />,
);
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(
<TimesOfDayChips value={["08:00"]} onChange={NOOP} />,
);
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(
<TimesOfDayChips
value={["08:00", "12:00"]}
maxChips={2}
onChange={NOOP}
/>,
);
expect(html).toContain('data-slot="times-of-day-max"');
});
it("renders a single <input type=time> when maxChips=1 (one-shot)", () => {
const html = render(
<TimesOfDayChips value={["09:30"]} maxChips={1} onChange={NOOP} />,
);
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"/);
});
});
@@ -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,
};