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