Recurring events
Give an event a recurrence and the scheduler draws one event for each occurrence in the visible range. You keep one event in your data, not hundreds.
The rule
Section titled “The rule”const standup = { id: "standup", title: "Standup", startDate: new Date(2026, 9, 5, 9), // the first occurrence: Monday 9:00 endDate: new Date(2026, 9, 5, 9, 30), resourceId: "ann", recurrence: { freq: "weekly", // "hourly" | "daily" | "weekly" | "monthly" | "yearly" byWeekday: [1, 2, 3, 4, 5], // weekly, monthly, yearly; 0 = Sunday. Weekly default: the weekday of startDate interval: 1, // every n hours, days, weeks, months or years // monthly and yearly also take byMonthDay: [1, -1] and bySetPos: [-1], see "Rarer rules" until: new Date(2026, 11, 18), // last day it may start on, inclusive count: 40, // or: how many in total, counting the first exceptions: [new Date(2026, 9, 12)], // days to skip },};- The event’s own
startDateandendDateare the first occurrence. Its time of day and length repeat, including multi-day events. until,countandexceptionsare all optional. With neitheruntilnorcountit repeats for ever.- Monthly and yearly keep the day of the month. A month without that day is skipped, as in iCalendar (31 January repeats in March, not on 28 February). The same goes for 29 February.
- Times follow the wall clock, so 9:00 stays 9:00 across a daylight saving change. With the
timeZoneprop that is the wall clock of that zone, whatever zone the viewer’s machine is in (see Time zones). - Weeks start on Monday for
intervalandbyWeekday, as in iCalendar. exceptionsare matched by calendar day and do not changecount. Forhourlythey are matched by the exact start of the occurrence.
Rarer rules
Section titled “Rarer rules”// The last Friday of every month (-1 counts from the end; 1 is the first, 2 the second...){ freq: "monthly", byWeekday: [5], bySetPos: [-1] }
// The second Tuesday, every 2 months{ freq: "monthly", interval: 2, byWeekday: [2], bySetPos: [2] }
// The last weekday of the month (a payroll run){ freq: "monthly", byWeekday: [1, 2, 3, 4, 5], bySetPos: [-1] }
// The 1st and the last day of the month (-1 is the 28th, 29th, 30th or 31st){ freq: "monthly", byMonthDay: [1, -1] }
// Friday the 13th: byWeekday and byMonthDay must both match{ freq: "monthly", byWeekday: [5], byMonthDay: [13] }
// Every 4 hours, for a long job or a rolling watch{ freq: "hourly", interval: 4, count: 12 }byMonthDay(1 to 31, or negative from the end of the month) andbyWeekdaynarrow the days of each month;bySetPosthen picks from what is left ([-1]the last,[1, -1]the first and the last). A month that has no such day (the 31st, a fifth Friday) is skipped, andcountonly counts occurrences that exist.- Yearly does the same in the month the series starts in:
{ freq: "yearly", byWeekday: [5], bySetPos: [-1] }from 5 October is the last Friday of every October. - The series starts at the first matching day on or after
startDate. IfstartDateitself does not match (a Monday start with “last Friday”), it is not an occurrence, as for weeklybyWeekday. byMonthDayandbySetPosare ignored byhourly,dailyandweekly;byWeekdayis ignored byhourlyanddaily.- Hourly steps the clock, like every other frequency: every 4 hours from 08:00 is 08:00, 12:00, 16:00 … on every day, also on a day the clocks change (so it is not always 4 real hours apart, and a day with 25 hours has the same occurrences as any other). A reading the clock skips, 02:30 when the clocks go forward, is not an occurrence and is not counted, as in iCalendar. The ids carry the time,
"<id>::2026-10-05T09:00".untilis the last moment it may start at,exceptionsand the date you pass tosplitRecurrenceandeditOccurrenceare the exact start of an occurrence, and a split ends the first series 1 ms before the split point.
Recurring events work wherever events do: in resource.events, or in the flat events prop.
What the handlers receive
Section titled “What the handlers receive”The scheduler hands your handlers an occurrence: a copy of the event with id "<id>::<yyyy-MM-dd>", its own startDate and endDate, seriesId set to the original id, and no recurrence. Your data is never changed for you.
<ResourceScheduler resources={people} events={events} onEventDrop={(occurrence, fromId, toId, start, end) => { if (!occurrence.seriesId) return setEvents(moveOneOff(occurrence, toId, start, end)); // It is part of a series. Decide, or ask the user, what to change. }}/>The two usual choices, both shown in the demo above:
- This event only. Add the occurrence’s day to the series’
exceptions, and add a normal one-off event at the new time. - The whole series. Change the series itself: its
resourceId, or the time of day ofstartDateandendDate.
Two helpers do the bookkeeping for you. Both are pure: they return new events and change nothing.
import { editOccurrence, splitRecurrence } from "resource-scheduler";
// "This and following": the old series ends the day before, a new one starts at the occurrence.const { before, after } = splitRecurrence(standup, occurrenceStart, { title: "Planning", startDate: newStart, endDate: newEnd,});// before: standup with recurrence.until = the day before (its count is dropped)// after: a new series, id "<id>::from-<yyyy-MM-dd>", with the edits applied
// "This occurrence only": an exception on the series plus a one-off event.const { series, changed } = editOccurrence(standup, occurrenceStart, { startDate: newStart, endDate: newEnd });occurrenceis any time on that day, and it must be an occurrence of the event; otherwise both functions throw aRangeError.afterkeeps acountas what is left (countminus the occurrences before the split, exceptions included), keepsuntil, and carries over the exceptions from the split on. It keeps the time of day and length unless the edits give new dates. Putrecurrencein the edits to change the rule, oridto choose the id.beforeisnullwhen the occurrence is the first one: nothing is left of the old series, so remove it.changedgets the id<id>::edit-<yyyy-MM-dd>andseriesId, and norecurrenceunless the edits set one.
Conflict rules (eventOverlap, isValidDrop, blockUnavailable) see the occurrences, so a new booking cannot land on top of one.
Without the component
Section titled “Without the component”The same expansion is exported, for a server, an export or your own list:
import { expandRecurrence, expandEvents } from "resource-scheduler";
expandRecurrence(standup, from, to); // the occurrences touching from..to (to exclusive)expandEvents(events, from, to); // expands the recurring ones in a list, passes the rest throughRRULE strings
Section titled “RRULE strings”parseRRule and toRRule turn an iCalendar RRULE into this rule and back, for storage or an API that speaks iCalendar: parseRRule("FREQ=MONTHLY;BYDAY=-1FR") is { freq: "monthly", byWeekday: [5], bySetPos: [-1] }. icsToEvents and eventsToICS use them.
Not supported yet
Section titled “Not supported yet”- Rules beyond the fields above:
BYMONTHother than the month the series starts in,BYYEARDAY,BYWEEKNO,WKSTother than Monday, minutely and secondly frequencies.parseRRulethrows aRangeErrorthat names the part. - Time zones in the rule. A series repeats at the same wall-clock time; with the
timeZoneprop that is the time in the zone (see Time zones).