Recurring events

Add the Recurrence plugin and give an event a recurrence rule: RFC 5545 text such as FREQ=WEEKLY;BYDAY=MO,WE or the equivalent object. The calendar expands the rule into occurrences for the visible range. Drag an occurrence and choose which occurrences the change applies to.

Recurrence.tsx
Recurrence.tsx
import {
  Calendar,
  DayGrid,
  Interaction,
  Recurrence,
  TimeGrid,
  editRecurringEvent,
  type CalEvent,
  type EventInput,
  type RecurrenceScope,
} from 'z-cal/react'
import { useEffect, useRef, useState, type JSX } from 'react'
import { useScheme } from '../DemoFrame'
import { today } from '../shared/dates'

// Recurrence expands rules into occurrences; Interaction lets you drag them.
const PLUGINS = [DayGrid, TimeGrid, Interaction, Recurrence]

const INITIAL_EVENTS: EventInput[] = [
  {
    id: 'standup',
    title: 'Standup',
    start: today(-7, 9, 30),
    end: today(-7, 9, 45),
    color: '#2563eb',
    // RFC 5545 text, as stored by most backends.
    recurrence: { rrule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR' },
  },
  {
    id: 'retro',
    title: 'Retro (2nd Tuesday)',
    start: today(-28, 15),
    end: today(-28, 16),
    color: '#9333ea',
    // The same rule as an object: monthly on the second Tuesday.
    recurrence: { freq: 'monthly', weekday: 2, nth: 2 },
  },
  { id: 'lunch', title: 'Lunch with Sam', start: today(1, 12), end: today(1, 13) },
]

interface Pending {
  event: CalEvent
  revert: () => void
}

let counter = 0

export function RecurrenceDemo(): JSX.Element {
  // The app owns the series inputs; a drop asks which occurrences it should change.
  const [events, setEvents] = useState(INITIAL_EVENTS)
  const [pending, setPending] = useState<Pending | null>(null)
  const [log, setLog] = useState<{ id: number; line: string }[]>([])
  const dialog = useRef<HTMLDialogElement>(null)

  useEffect(() => {
    if (pending) dialog.current?.showModal()
    else dialog.current?.close()
  }, [pending])

  const record = (line: string): void =>
    setLog((prev) => [{ id: ++counter, line }, ...prev].slice(0, 5))

  const apply = (scope: RecurrenceScope): void => {
    if (!pending) return
    const { event } = pending
    setEvents((prev) => {
      const series = prev.find((input) => String(input.id) === event.id)
      if (!series) return prev
      const { update, add } = editRecurringEvent(series, event, scope, {
        newId: `${event.id}-${++counter}`,
      })
      const next = update
        ? prev.map((input) => (input === series ? update : input))
        : prev.filter((input) => input !== series)
      return add ? [...next, add] : next
    })
    record(`${scope}: "${event.title}" → ${event.startStr}`)
    setPending(null)
  }

  const cancel = (): void => {
    pending?.revert()
    record(`kept "${pending?.event.title}" in place`)
    setPending(null)
  }

  const movePlain = (event: CalEvent): void =>
    setEvents((prev) =>
      prev.map((input) =>
        String(input.id) === event.id
          ? { ...input, start: event.start, end: event.end, allDay: event.allDay }
          : input,
      ),
    )

  return (
    <>
      <div className="card">
        <Calendar
          plugins={PLUGINS}
          view="timeGridWeek"
          colorScheme={useScheme()}
          headerToolbar={{
            start: 'title',
            center: '',
            end: 'today prev,next dayGridMonth,timeGridWeek',
          }}
          events={events}
          editable
          scrollTime="08:00"
          height="600px"
          eventDrop={(info) => {
            // The calendar already recorded an override for this occurrence and can revert it.
            if (info.event.occurrence) setPending({ event: info.event, revert: info.revert })
            else movePlain(info.event)
          }}
          eventResize={(info) => {
            if (info.event.occurrence) setPending({ event: info.event, revert: info.revert })
            else movePlain(info.event)
          }}
          eventClick={(info) => {
            const { occurrence } = info.event
            record(
              occurrence
                ? `clicked occurrence ${occurrence.key} of "${info.event.title}" (#${occurrence.index})`
                : `clicked "${info.event.title}"`,
            )
          }}
        />
      </div>
      <dialog ref={dialog} className="event-create-dialog" onCancel={cancel}>
        <p>
          Change <b>{pending ? String(pending.event.title) : ''}</b> for…
        </p>
        <div className="actions">
          <button type="button" onClick={() => apply('this')}>
            This occurrence
          </button>
          <button type="button" onClick={() => apply('following')}>
            This and following
          </button>
          <button type="button" onClick={() => apply('all')}>
            All occurrences
          </button>
          <button type="button" onClick={cancel}>
            Cancel
          </button>
        </div>
      </dialog>
      <ul className="log" aria-live="polite">
        {log.map((entry) => (
          <li key={entry.id}>{entry.line}</li>
        ))}
      </ul>
    </>
  )
}

What to look at

  • The app owns two series inputs and one plain event. Nothing is pre-expanded: the calendar computes occurrences from the rule, so a series with no end costs only what is on screen.
  • Every occurrence is a CalEvent whose id is the series id and whose occurrence names the original start (key) and position (index). Click one to see it in the log.
  • A drop has already recorded an override on the series, so the dialog can offer "this occurrence", "this and following", or "all occurrences" and cancel with revert(). editRecurringEvent returns the series inputs to write back for the chosen scope. See Recurring events.