Components

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

Repeats
Every
1
week
On
At

Next 5 runs

  1. Thu, Aug 6, 20269:00 AMGMT+0
  2. Tue, Aug 11, 20269:00 AMGMT+0
  3. Thu, Aug 13, 20269:00 AMGMT+0
  4. Tue, Aug 18, 20269:00 AMGMT+0
  5. Thu, Aug 20, 20269:00 AMGMT+0

Rule:

Install

npx moumenlab add schedule-builder

For AI

Open .md

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>
  );
}

Story

  1. A design-system requirement

    Another one from the design system, this time for an educational platform: instructors needed to schedule lectures and courses with students. The challenge was never the feature list. It was making a scheduler that is easy to understand, easy to deal with, and clear, without any unnecessary UI tweaks or animation for its own sake.

  2. What I looked at before starting

    I surveyed the usual suspects first: MUI X Scheduler, FullCalendar and shadcn's calendar. All solid, and all calendars: big event grids and date pickers. None of them answers the instructor's actual question, which is "when does this lecture repeat?" The missing piece was a rule builder, not another calendar.

  3. A sentence, not a grid

    The clarity answer: the rule assembles as a live English sentence. Every week on Tuesday and Thursday at 9:00 AM. That is how an instructor already says it out loud, so that is what the component shows. Editing a control changes one word, and the surviving words slide to their new places, so the sentence reads as one object being edited, not a string being replaced.

  4. Proof under the promise

    A sentence can lie; dates can't. Under the rule the component lists the next real occurrences, computed from the actual calendar. If the rule is wrong, the instructor sees it in the list before the students do.

  5. The calendar traps

    Recurrence looks like dropdowns but is full of traps, and this build refuses to fake them. Every month on day 31 cannot run in September: the list shows a ghost row saying so, or the clamp policy moves it to the 30th. Across a daylight-saving jump 9:00 AM stays 9:00 AM, with the GMT offset printed on every row. And the 2nd Tuesday is computed from the month's first day, never found by scanning.

  6. Restraint as a feature

    The requirement said no unnecessary tweaks, and that shaped the motion budget: most animation candidates got deleted before they shipped. What stayed is the word glide, the one movement that carries information. Entrances wait for the first paint, reduced motion is honoured, and everything else just updates. Clear beats clever.