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