Schedule Builder
A recurrence rule assembled as a live English sentence, over the next five real occurrences - computed, never added. The calendar traps are the point: pick day 31 and months without one appear as ghost skipped rows (RRULE semantics - tick Clamp month ends for the other real policy, where day 31 becomes the 30th). Runs are built from local wall-clock parts, so 9:00 AM stays 9:00 AM across a daylight-saving jump - every row prints its UTC offset and flags the ones that differ. “The 2nd Tuesday” and “the last Friday” come from the month's first and last day, no scanning. And the sentence morphs word by word: words are keyed by text + occurrence and glide via layout, so switching “week” to “month” slides “at 9:00 AM” into place instead of retyping it - the sentence reads as one object being edited, not a string being replaced.
Every week on Tuesday and Thursday at 9:00 AM
Next 5 runs
- Tue, Jul 28, 20269:00 AMGMT+0
- Thu, Jul 30, 20269:00 AMGMT+0
- Tue, Aug 4, 20269:00 AMGMT+0
- Thu, Aug 6, 20269:00 AMGMT+0
- Tue, Aug 11, 20269:00 AMGMT+0
Rule: …
Install
$ npx moumenlab add schedule-builderFor AI
Usage
"use client";
import ScheduleBuilder from "./schedule-builder";
// The rule is the component's value — read it out through onRuleChange and
// serialize it however your backend wants (it maps 1:1 onto RRULE).
export default function ScheduleBuilderExample() {
return (
<div className="flex flex-wrap items-start gap-10">
{/* Default: RRULE semantics — "day 31" SKIPS months without one (ghost rows). */}
<ScheduleBuilder
defaultRule={{ freq: "weekly", weekdays: [1, 3, 5], hour: 8 }}
onRuleChange={(rule) => console.log("rule", rule)}
/>
{/* clamp: the other real-world month-end policy — day 31 becomes the 30th.
occurrences controls how many upcoming runs prove the rule;
morph={false} swaps the word glide for instant text. */}
<ScheduleBuilder
defaultRule={{ freq: "monthly", monthDay: 31 }}
clamp
occurrences={3}
/>
</div>
);
}
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { MotionConfig, motion } from "motion/react";
// Schedule builder — a recurrence rule assembled as a live English sentence,
// with the next N REAL occurrences underneath.
//
// The domain is the hard part: recurrence looks like dropdowns but is full of
// calendar traps, and this build refuses to fake any of them:
//
// · Month boundaries. "Every month on day 31" cannot run in September.
// RRULE semantics SKIP the month (and the list shows a ghost row saying
// so); the `clamp` prop switches to the other real-world policy, where
// day 31 becomes Sep 30.
// · DST. Runs are built from local wall-clock components (year, month,
// day, hour), so "9:00 AM" stays 9:00 AM across a daylight-saving jump —
// the UTC offset is printed on every row and flagged when it differs
// from the first run's.
// · Nth-weekday math. "The 2nd Tuesday" and "the last Friday" are computed
// from the month's first/last day, never by scanning.
//
// The sentence morphs word-by-word: words are keyed by text + occurrence and
// carry motion's `layout` — surviving words glide to their new positions while
// new words blur in. Change "week" to "month" and "at 9:00 AM" slides left
// instead of re-rendering; the sentence reads as one object being edited, not
// a string being replaced.
//
// Read the rule out through `onRuleChange` (it is the component's value).
// Animation via motion/react; honours prefers-reduced-motion. Requires the
// lab-theme tokens. Fully Tailwind, no CSS files.
const EASE = [0.22, 1, 0.36, 1] as const;
const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const ORDINALS: { value: number | "last"; label: string }[] = [
{ value: 1, label: "1st" },
{ value: 2, label: "2nd" },
{ value: 3, label: "3rd" },
{ value: 4, label: "4th" },
{ value: "last", label: "last" },
];
export interface ScheduleRule {
freq: "daily" | "weekly" | "monthly";
interval: number;
weekdays: number[];
monthMode: "date" | "nth";
monthDay: number;
ordinal: number | "last";
weekday: number;
hour: number;
minute: number;
}
export interface ScheduleBuilderState {
sentence: string;
next: string | null;
skips: number;
dstChanges: number;
freq: ScheduleRule["freq"];
}
type RunEntry =
| { kind: "run"; date: Date; clamped?: boolean }
| { kind: "skip"; key: string; label: string; month: number };
const DEFAULT_RULE: ScheduleRule = {
freq: "weekly",
interval: 1,
weekdays: [2, 4], // Tue + Thu
monthMode: "date",
monthDay: 31, // deliberately the gnarly default — skips appear immediately
ordinal: 2,
weekday: 2,
hour: 9,
minute: 0,
};
const monthName = (date: Date) => date.toLocaleString("en-US", { month: "long" });
function offsetLabel(date: Date) {
const mins = -date.getTimezoneOffset();
const sign = mins >= 0 ? "+" : "-";
const abs = Math.abs(mins);
const h = Math.floor(abs / 60);
const m = abs % 60;
return `GMT${sign}${h}${m ? `:${String(m).padStart(2, "0")}` : ""}`;
}
function timeLabel(hour: number, minute: number) {
const h12 = hour % 12 === 0 ? 12 : hour % 12;
return `${h12}:${String(minute).padStart(2, "0")}`;
}
// "2nd Tuesday of month m" — computed from the month's first day; "last"
// walks back from the month's last day. A 1st-4th always exists.
function nthWeekdayOf(year: number, month: number, ordinal: number | "last", weekday: number, hour: number, minute: number) {
if (ordinal === "last") {
const last = new Date(year, month + 1, 0);
const back = (last.getDay() - weekday + 7) % 7;
return new Date(year, month, last.getDate() - back, hour, minute);
}
const first = new Date(year, month, 1);
const forward = (weekday - first.getDay() + 7) % 7;
return new Date(year, month, 1 + forward + (ordinal - 1) * 7, hour, minute);
}
const startOfWeek = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate() - d.getDay());
const WEEK_MS = 7 * 86400000;
// The next `count` occurrences, plus ghost entries for months a day-of-month
// rule has to skip. Everything is built from LOCAL date components, so DST is
// handled by the platform, not re-derived.
function computeRuns(rule: ScheduleRule, now: Date, count: number, clamp: boolean): RunEntry[] {
const { freq, interval, weekdays, monthMode, monthDay, ordinal, weekday, hour, minute } = rule;
const out: RunEntry[] = [];
let runs = 0;
if (freq === "daily") {
const c = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, minute);
if (c <= now) c.setDate(c.getDate() + interval);
while (runs < count) {
out.push({ kind: "run", date: new Date(c) });
runs += 1;
c.setDate(c.getDate() + interval); // setDate keeps local wall-clock across DST
}
} else if (freq === "weekly") {
const anchor = startOfWeek(now); // this week starts the interval cycle
for (let i = 0; i < 800 && runs < count; i += 1) {
const cand = new Date(now.getFullYear(), now.getMonth(), now.getDate() + i, hour, minute);
if (!weekdays.includes(cand.getDay())) continue;
const weeksAway = Math.round((startOfWeek(cand).getTime() - anchor.getTime()) / WEEK_MS); // round() absorbs the DST hour
if (weeksAway % interval !== 0) continue;
if (cand <= now) continue;
out.push({ kind: "run", date: cand });
runs += 1;
}
} else {
for (let i = 0; i < 36 && runs < count; i += 1) {
const year = now.getFullYear();
const month = now.getMonth() + i;
if (monthMode === "date") {
const daysInMonth = new Date(year, month + 1, 0).getDate();
if (monthDay > daysInMonth) {
if (clamp) {
const cand = new Date(year, month, daysInMonth, hour, minute);
if (cand > now) {
out.push({ kind: "run", date: cand, clamped: true });
runs += 1;
}
} else if (new Date(year, month + 1, 0, 23, 59) > now) {
// RRULE semantics: the month simply has no day 31 — say so.
out.push({
kind: "skip",
key: `skip-${year}-${month}`,
label: `${monthName(new Date(year, month, 1))} has only ${daysInMonth} days`,
month,
});
}
continue;
}
const cand = new Date(year, month, monthDay, hour, minute);
if (cand > now) {
out.push({ kind: "run", date: cand });
runs += 1;
}
} else {
const cand = nthWeekdayOf(year, month, ordinal, weekday, hour, minute);
if (cand > now) {
out.push({ kind: "run", date: cand });
runs += 1;
}
}
}
}
return out;
}
// The sentence as a word list. Punctuation rides on its word — keys are
// text + occurrence, so a surviving "at" keeps its identity and glides
// instead of re-entering.
function sentenceWords(rule: ScheduleRule) {
const { freq, interval, weekdays, monthMode, monthDay, ordinal, weekday, hour, minute } = rule;
const words = ["Every"];
if (freq === "daily") {
if (interval === 1) words.push("day");
else words.push(String(interval), "days");
} else if (freq === "weekly") {
if (interval === 1) words.push("week");
else words.push(String(interval), "weeks");
words.push("on");
const names = [...weekdays].sort((a, b) => a - b).map((d) => WEEKDAYS[d]);
names.forEach((name, index) => {
if (index === names.length - 1 && names.length > 1) words.push("and");
words.push(index < names.length - 2 ? `${name},` : name);
});
} else {
words.push("month", "on");
if (monthMode === "date") words.push("day", String(monthDay));
else words.push("the", ORDINALS.find((o) => o.value === ordinal)!.label, WEEKDAYS[weekday]);
}
words.push("at", timeLabel(hour, minute), hour < 12 ? "AM" : "PM");
const seen: Record<string, number> = {};
return words.map((word) => ({ word, key: `${word}·${(seen[word] = (seen[word] ?? 0) + 1)}` }));
}
const SEG_BTN =
"h-7 px-2.5 rounded-md bg-transparent text-gray-500 text-xs font-medium cursor-pointer transition-[background-color,color,scale] duration-150 aria-pressed:bg-[#111] aria-pressed:text-white active:scale-[0.96] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#111]";
const STEP_BTN =
"w-7 h-7 rounded-lg bg-transparent text-gray-500 text-sm cursor-pointer transition-[background-color,color,scale] duration-150 hover:enabled:bg-[#f4f4f5] hover:enabled:text-[#111] active:enabled:scale-[0.96] disabled:opacity-35 disabled:cursor-default focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#111]";
const SELECT =
"h-7 px-1.5 rounded-lg bg-white shadow-border text-[#111] text-xs font-medium cursor-pointer outline-none transition-shadow duration-150 hover:shadow-border-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#111]";
const ROW_LABEL = "w-14 flex-none text-[0.6875rem] font-semibold tracking-[0.06em] uppercase text-gray-400";
export default function ScheduleBuilder({
defaultRule,
onRuleChange,
occurrences = 5,
morph = true,
clamp = false, // month-end policy: false = RRULE skip · true = clamp to last day
inspect = false,
onStateChange,
}: {
/** Seed the builder with a partial rule; the rest falls back to the default. */
defaultRule?: Partial<ScheduleRule>;
/** The component's value: fires with the full rule on every edit. */
onRuleChange?: (rule: ScheduleRule) => void;
/** How many upcoming runs to prove the rule with. */
occurrences?: number;
morph?: boolean;
clamp?: boolean;
inspect?: boolean;
onStateChange?: (state: ScheduleBuilderState) => void;
}) {
const [rule, setRule] = useState<ScheduleRule>({ ...DEFAULT_RULE, ...defaultRule });
// Entrances are gated behind the first paint — on mount the sentence just is.
const mountedRef = useRef(false);
useEffect(() => {
mountedRef.current = true;
}, []);
const words = useMemo(() => sentenceWords(rule), [rule]);
const sentenceText = words.map((w) => w.word).join(" ");
// `now` is pinned per rule-change so the list doesn't jitter between renders.
const runs = useMemo(() => computeRuns(rule, new Date(), occurrences, clamp), [rule, clamp, occurrences]);
const firstRun = runs.find((entry): entry is Extract<RunEntry, { kind: "run" }> => entry.kind === "run");
const baseOffset = firstRun ? offsetLabel(firstRun.date) : null;
const dstChanges = runs.filter((entry) => entry.kind === "run" && offsetLabel(entry.date) !== baseOffset).length;
const skips = runs.filter((entry) => entry.kind === "skip").length;
const set = (patch: Partial<ScheduleRule>) => setRule((prev) => ({ ...prev, ...patch }));
function toggleWeekday(day: number) {
setRule((prev) => {
if (prev.weekdays.includes(day)) {
if (prev.weekdays.length === 1) return prev; // a weekly rule needs a day
return { ...prev, weekdays: prev.weekdays.filter((d) => d !== day) };
}
return { ...prev, weekdays: [...prev.weekdays, day] };
});
}
useEffect(() => {
onRuleChange?.(rule);
}, [rule, onRuleChange]);
useEffect(() => {
onStateChange?.({
sentence: sentenceText,
next: firstRun
? `${firstRun.date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })} · ${timeLabel(firstRun.date.getHours(), firstRun.date.getMinutes())} ${firstRun.date.getHours() < 12 ? "AM" : "PM"}`
: null,
skips,
dstChanges,
freq: rule.freq,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sentenceText, firstRun?.date?.getTime(), skips, dstChanges, rule.freq, onStateChange]);
const runFmt = (date: Date) =>
date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" });
return (
<MotionConfig reducedMotion="user">
<div className="relative w-full max-w-[22rem] flex flex-col gap-3.5">
{/* The sentence — words are the animated unit, not the string. Real
spaces between the spans, so selection, copy and screen readers get
"Every week…", not "Everyweek…". Surviving keys glide via layout;
new keys blur in. */}
<p
className="m-0 min-h-[3.2em] text-lg leading-[1.5] font-medium tracking-[-0.01em] text-[#111]"
aria-live="polite"
>
{words.map(({ word, key }, index) => (
<span key={key}>
{index > 0 && " "}
<motion.span
layout={morph ? "position" : false}
initial={mountedRef.current && morph ? { opacity: 0, filter: "blur(2px)", y: "0.3em" } : false}
animate={{ opacity: 1, filter: "blur(0px)", y: 0 }}
transition={{ layout: { duration: 0.3, ease: EASE }, duration: 0.24, ease: EASE }}
className={`inline-block${inspect ? " outline-1 outline-dashed outline-[#93c5fd] outline-offset-1 rounded-[2px]" : ""}`}
>
{word}
</motion.span>
</span>
))}
</p>
{/* ── Controls ── */}
<div className="flex flex-col gap-2">
<div className="flex items-center flex-wrap gap-2 min-h-8">
<span className={ROW_LABEL}>Repeats</span>
<div className="inline-flex gap-[2px] p-[2px] rounded-lg bg-[#f4f4f5]" role="group" aria-label="Frequency">
{(["daily", "weekly", "monthly"] as const).map((freq) => (
<button key={freq} type="button" className={SEG_BTN} aria-pressed={rule.freq === freq} onClick={() => set({ freq })}>
{freq[0].toUpperCase() + freq.slice(1)}
</button>
))}
</div>
</div>
{rule.freq !== "monthly" && (
<div className="flex items-center flex-wrap gap-2 min-h-8">
<span className={ROW_LABEL}>Every</span>
<div className="inline-flex items-center rounded-lg bg-white shadow-border" role="group" aria-label="Interval">
<button type="button" className={STEP_BTN} aria-label="Less often" disabled={rule.interval <= 1} onClick={() => set({ interval: rule.interval - 1 })}>
−
</button>
<span className="min-w-6 text-center text-[0.8125rem] font-medium text-[#111] tabular-nums">{rule.interval}</span>
<button type="button" className={STEP_BTN} aria-label="More often" disabled={rule.interval >= 6} onClick={() => set({ interval: rule.interval + 1 })}>
+
</button>
</div>
<span className="text-xs font-medium text-gray-400">
{rule.freq === "daily" ? (rule.interval === 1 ? "day" : "days") : rule.interval === 1 ? "week" : "weeks"}
</span>
</div>
)}
{rule.freq === "weekly" && (
<div className="flex items-center flex-wrap gap-2 min-h-8">
<span className={ROW_LABEL}>On</span>
<div className="inline-flex gap-1" role="group" aria-label="Weekdays">
{WEEKDAYS.map((name, day) => (
<button
key={name}
type="button"
className="w-7 h-7 rounded-full bg-[#f4f4f5] text-gray-500 text-[0.6875rem] font-semibold cursor-pointer transition-[background-color,color,scale] duration-150 aria-pressed:bg-[#111] aria-pressed:text-white active:scale-[0.96] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#111]"
aria-pressed={rule.weekdays.includes(day)}
aria-label={name}
onClick={() => toggleWeekday(day)}
>
{name[0]}
</button>
))}
</div>
</div>
)}
{rule.freq === "monthly" && (
<div className="flex items-center flex-wrap gap-2 min-h-8">
<span className={ROW_LABEL}>On</span>
<div className="inline-flex gap-[2px] p-[2px] rounded-lg bg-[#f4f4f5]" role="group" aria-label="Monthly mode">
<button type="button" className={SEG_BTN} aria-pressed={rule.monthMode === "date"} onClick={() => set({ monthMode: "date" })}>
A date
</button>
<button type="button" className={SEG_BTN} aria-pressed={rule.monthMode === "nth"} onClick={() => set({ monthMode: "nth" })}>
A weekday
</button>
</div>
{rule.monthMode === "date" ? (
<div className="inline-flex items-center rounded-lg bg-white shadow-border" role="group" aria-label="Day of month">
<button type="button" className={STEP_BTN} aria-label="Earlier day" disabled={rule.monthDay <= 1} onClick={() => set({ monthDay: rule.monthDay - 1 })}>
−
</button>
<span className="min-w-6 text-center text-[0.8125rem] font-medium text-[#111] tabular-nums">{rule.monthDay}</span>
<button type="button" className={STEP_BTN} aria-label="Later day" disabled={rule.monthDay >= 31} onClick={() => set({ monthDay: rule.monthDay + 1 })}>
+
</button>
</div>
) : (
<>
<select
className={SELECT}
value={String(rule.ordinal)}
aria-label="Which one"
onChange={(e) => set({ ordinal: e.target.value === "last" ? "last" : Number(e.target.value) })}
>
{ORDINALS.map((o) => (
<option key={o.label} value={String(o.value)}>
{o.label}
</option>
))}
</select>
<select className={SELECT} value={rule.weekday} aria-label="Weekday" onChange={(e) => set({ weekday: Number(e.target.value) })}>
{WEEKDAYS.map((name, day) => (
<option key={name} value={day}>
{name}
</option>
))}
</select>
</>
)}
</div>
)}
<div className="flex items-center flex-wrap gap-2 min-h-8">
<span className={ROW_LABEL}>At</span>
<select
className={SELECT}
value={rule.hour % 12 === 0 ? 12 : rule.hour % 12}
aria-label="Hour"
onChange={(e) => {
const h12 = Number(e.target.value) % 12;
set({ hour: rule.hour < 12 ? h12 : h12 + 12 });
}}
>
{Array.from({ length: 12 }, (_, i) => i + 1).map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</select>
<select className={SELECT} value={rule.minute} aria-label="Minutes" onChange={(e) => set({ minute: Number(e.target.value) })}>
{[0, 15, 30, 45].map((m) => (
<option key={m} value={m}>
:{String(m).padStart(2, "0")}
</option>
))}
</select>
<div className="inline-flex gap-[2px] p-[2px] rounded-lg bg-[#f4f4f5]" role="group" aria-label="AM or PM">
<button type="button" className={SEG_BTN} aria-pressed={rule.hour < 12} onClick={() => set({ hour: rule.hour % 12 })}>
AM
</button>
<button type="button" className={SEG_BTN} aria-pressed={rule.hour >= 12} onClick={() => set({ hour: (rule.hour % 12) + 12 })}>
PM
</button>
</div>
</div>
</div>
{/* ── The proof: real occurrences, ghosts for skipped months ── */}
<div className="border-t border-gray-100 pt-2.5">
<p className="m-0 mb-1.5 text-[0.625rem] font-semibold tracking-[0.06em] uppercase text-gray-400">
Next {runs.filter((r) => r.kind === "run").length} runs
</p>
<ol className="flex flex-col gap-0.5 m-0 p-0 list-none">
{runs.map((entry, index) =>
entry.kind === "skip" ? (
<motion.li
key={entry.key}
initial={{ opacity: 0, y: "0.25rem", filter: "blur(1px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{ duration: 0.24, ease: EASE, delay: index * 0.03 }}
className="flex items-center justify-between gap-3 px-1.5 py-[0.3125rem] rounded-lg text-xs text-gray-400 border border-dashed border-gray-200"
>
<span>{entry.label}</span>
<span className="text-[0.625rem] font-semibold tracking-[0.04em] uppercase">skipped</span>
</motion.li>
) : (
<motion.li
key={entry.date.getTime()}
initial={{ opacity: 0, y: "0.25rem", filter: "blur(1px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{ duration: 0.24, ease: EASE, delay: index * 0.03 }}
className="flex items-center justify-between gap-3 px-1.5 py-[0.3125rem] rounded-lg text-[0.8125rem] text-[#111]"
>
<span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
{runFmt(entry.date)}
{entry.clamped && (
<span className="ml-1.5 text-[0.625rem] font-semibold tracking-[0.04em] uppercase text-[#d97706]">clamped</span>
)}
</span>
<span className="inline-flex items-baseline gap-1.5 flex-none text-gray-500 tabular-nums">
{timeLabel(entry.date.getHours(), entry.date.getMinutes())} {entry.date.getHours() < 12 ? "AM" : "PM"}
<span
className={
offsetLabel(entry.date) !== baseOffset
? "text-[0.6875rem] text-[#d97706] font-semibold"
: "text-[0.6875rem] text-gray-400"
}
>
{offsetLabel(entry.date)}
</span>
</span>
</motion.li>
),
)}
</ol>
</div>
{/* Blueprint annotations (blue = the word glide, red = the calendar math). */}
{inspect && (
<>
<span className="absolute bottom-[calc(100%+0.4rem)] left-0 z-[6] whitespace-nowrap rounded-[0.25rem] border border-[#bfdbfe] bg-white px-[0.3125rem] py-[0.0625rem] text-[0.625rem] font-medium leading-normal tracking-[0.01em] text-[#2563eb] shadow-[0_1px_2px_rgba(0,0,0,0.08)] pointer-events-none tabular-nums">
words keyed text+occurrence · layout glide 300ms
</span>
<span className="absolute top-[calc(100%+0.4rem)] left-0 z-[6] whitespace-nowrap rounded-[0.25rem] border border-[#fecaca] bg-white px-[0.3125rem] py-[0.0625rem] text-[0.625rem] font-medium leading-normal tracking-[0.01em] text-[#dc2626] shadow-[0_1px_2px_rgba(0,0,0,0.08)] pointer-events-none tabular-nums">
occurrences computed, never added · offsets straight from Date
</span>
</>
)}
</div>
</MotionConfig>
);
}