Calendar
A full-bleed events calendar built on FullCalendar v6's standard (MIT) plugins — month / week / day / list views, drag-to-reschedule, click-to-create, recurring events, and .ics import/export — themed entirely through design tokens.
Overview
The Calendar app (/apps/calendar) is a real events calendar, not a static demo. Features:
- Views — Month, Week, Day, and List, switched from the page's own toolbar.
- Interaction — drag-to-reschedule + resize (
editable), click-drag-to-create (selectable), click-to-edit / quick-view (eventClick), and drag-a-template-chip onto the grid to create. - Recurring events — daily / weekly / monthly rules via the
@fullcalendar/rruleplugin. .icsexport / import — export all events or a single event, import from a file.- A sidebar — a mini-calendar navigator, category filters, drag-to-create templates, and an Upcoming list (occurrence-expanded so recurrences show).
- Token theming — no hardcoded colors; the five event categories map to the five semantic tones.
The route is full-bleed and code-split, so the FullCalendar chunk stays out of the main bundle. On small screens it opens in List view.
Info
Licensing — MIT plugins only
The calendar uses only the standard, MIT-licensed FullCalendar v6 plugins:
@fullcalendar/react, daygrid, timegrid, list, interaction, and rrule (the rrule peer is
BSD-3). Never add @fullcalendar/scheduler or any resource-* plugin — those are premium/paid
and would break the ThemeForest licensing story (the same rule as AG Grid Community). This is a hard
constraint, not a preference.
Architecture & files
src/pages/apps/CalendarPage.tsx # the full-bleed screen (toolbar + sidebar + calendar + modals)
src/components/calendar/
useCalendar.ts # ALL state + behavior (the view-model hook)
CalendarView.tsx # thin FullCalendar wrapper (plugin set, shared options)
CalendarToolbar.tsx # own toolbar (view control, prev/today/next, title, search, add, ics)
CalendarSidebar.tsx # mini-calendar + category filters + templates + upcoming
MiniCalendar.tsx # native-Date month navigator
EventModal.tsx # create / edit event (on the Modal primitive)
EventDetail.tsx # read-only quick view (Edit / Duplicate / Delete / Export .ics)
src/data/calendar.ts # types, seed, persistence, recurrence + display helpers
src/lib/ics.ts # pure-JS .ics reader/writer (eventsToIcs / downloadIcs / parseIcs)
src/styles/_calendar.scss # maps --fc-* → raw --* tokens; per-event fc-ev-<tone> classes
CalendarPage is thin — it renders the toolbar, sidebar, CalendarView, and the two modals, wiring
them all to a single useCalendar() hook that owns every piece of state and behavior. It's a
full-bleed route, so it renders no PageHeader — it calls useDocumentTitle directly and sizes
itself with calc(100dvh - var(--app-header-height) - <footer>).
CalendarView — the FullCalendar wrapper
A thin component that holds the standard plugin set and shared options. The FullCalendar header
toolbar is disabled (headerToolbar={false}) — the page renders its own token-styled toolbar and
drives the calendar imperatively via calendarRef.current.getApi().
// src/components/calendar/CalendarView.tsx (shape)
<FullCalendar
ref={calendarRef}
plugins={[dayGridPlugin, timeGridPlugin, listPlugin, interactionPlugin, rrulePlugin]}
initialView={initialView}
headerToolbar={false}
height="100%"
editable selectable selectMirror droppable nowIndicator expandRows navLinks
navLinkDayClick="timeGridDay"
events={events}
select={onSelect} eventClick={onEventClick}
eventDrop={onEventDrop} eventResize={onEventResize}
eventReceive={onEventReceive} datesSet={onDatesSet}
/>CalendarViewType = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay' | 'listWeek'.
Data & persistence (src/data/calendar.ts)
State persists to localStorage under STORAGE_KEY = 'calendar-state-v2' via loadCalendar() /
saveCalendar() / clearCalendar(). useCalendar saves on every change.
CalendarData={events: Record<string, CalendarEvent>; categories: EventCategory[]; members: CalendarMember[]}.CalendarEvent—id,title,description?,start/end?(local ISO strings —YYYY-MM-DDall-day,YYYY-MM-DDTHH:mm:sstimed),allDay,categoryId,location?,attendeeIds, andrecurrence?.Recurrence—{freq: 'daily'|'weekly'|'monthly'; interval; byweekday?: WeekdayCode[]; until?}.- Categories (5) map 1:1 to the semantic tones: meeting→
primary, personal→success, deadline→danger, holiday→warning, reminder→info.toneFor(categories, id)resolves the tone. - Seed dates are relative to today (
day(offset)/at(offset,h,m)), so the demo never looks stale. - Helpers:
maxEventSeq,localIso,dayKey,toneFor,formatTime/formatDayLabel/formatEventWhen,eventDurationStr, andoccurrencesInRange(events, from, to)which expands recurring rules (the sidebar mini-dots + Upcoming use it so recurrences show there too).
Info
calendar-state-v2 is registered in src/lib/appStorage.ts so "Reset to defaults" wipes it.
Theming (src/styles/_calendar.scss)
FullCalendar v6 exposes --fc-* CSS variables; _calendar.scss maps them onto the app's raw --*
tokens (e.g. --fc-border-color: var(--border), --fc-today-bg-color: color-mix(… var(--primary) …)).
Each event carries a fc-ev-<tone> marker class (set in useCalendar's fcEvents) that colors it via
color-mix() on the semantic token — so light / dark / every skin adapt with no hex and no
--color-* aliases. This mirrors the AG Grid theming precedent.
Recurring events
CalendarEvent.recurrence is emitted to FullCalendar in rrule form (rrule + duration from
eventDurationStr). Recurring events are marked editable: false on the grid — they're edited at the
series level via EventModal's Repeat section, not per instance. The sidebar independently expands
occurrences with occurrencesInRange (using the rrule lib with a UTC-safe dtstart round-trip).
Usage
- Navigate to
/apps/calendar(sidebar: Pages → Calendar, or the apps waffle menu). - Switch views, page through dates, search, and add events from the toolbar.
- Click-drag on the grid to create; click an event for the quick view; drag it to reschedule.
- Use the toolbar overflow menu to Export all or Import an
.icsfile; export a single event from its detail view.
API / Props
useCalendar() — the view-model hook
Returns everything the page wires up:
| Group | Members |
|---|---|
| Data / derived | categories, members, visibleEvents, fcEvents (FullCalendar EventInput[]), eventDays (Set<string> of YYYY-MM-DD with events), upcoming (Occurrence[], next ~60 days, capped 6). |
| View / navigation | calendarRef, view, title, currentDate, query, setQuery, changeView, goPrev, goNext, goToday, gotoDate, onDatesSet. |
| Filtering | activeCategories (Set<string>), toggleCategory(id). |
| Modals | editing ({event, isNew} | null), setEditing, viewing (CalendarEvent | null), setViewing. |
| Handlers | onSelect, onEventClick, onEventDrop, onEventResize, onEventReceive, openDetailById, onAddClick, saveEvent, deleteEvent, duplicateEvent, editFromDetail. |
| ICS | fileRef, exportAll, exportOne(event), onImportFile(changeEvent). |
Components
| Component | Key props |
|---|---|
CalendarView | {calendarRef, events, firstDay?, initialView?, onSelect, onEventClick, onEventDrop, onEventResize, onEventReceive, onDatesSet}. |
CalendarToolbar | {title, view, onChangeView, onPrev, onNext, onToday, query, onQueryChange, onAdd, onExport, fileRef, onImportFile}. |
CalendarSidebar | {selectedDate, categories, activeCategories, onToggleCategory, events, upcoming, eventDays, onGoToDate, onOpenEvent, locale?}. |
MiniCalendar | native-Date month navigator (onGoToDate). |
EventModal | {open, isNew, event, categories, members, onClose, onSave, onDelete}. |
EventDetail | {open, event, categories, members, onClose, onEdit, onDuplicate, onDelete, onExport}. |
.ics library (src/lib/ics.ts)
Pure JavaScript, RFC-5545 folding + escaping + RRULE:
eventsToIcs(events): stringdownloadIcs(filename, text): voidparseIcs(text): Partial<CalendarEvent>[]
Configuration & customization
- Categories & tones — edit the
categoriesarray insrc/data/calendar.ts(label is an i18n key in thecalendarnamespace;toneis one of the five semantic tones). - Seed events / members — edit
seedEvents/members; dates use the relativeday()/at()helpers so they cluster around today. - Colors — adjust
_calendar.scsstoken mappings; never introduce a hex or a--color-*alias (Tailwind doesn't emit those as real CSS vars, so they'd resolve to transparent in plain CSS). - First day of week — pass
firstDaytoCalendarView(0 = Sunday). - Default view on mobile —
defaultView()inuseCalendarseedslistWeekundermax-width:767px. - Upcoming window —
UPCOMING_WINDOW_DAYS(60) inuseCalendar.
Examples
Read the calendar store and render your own upcoming widget:
import {loadCalendar, occurrencesInRange, formatEventWhen} from '@/data/calendar'
const cal = loadCalendar()
const now = new Date()
const soon = new Date(now.getTime() + 7 * 86_400_000)
const next7 = occurrencesInRange(Object.values(cal.events), now, soon)
next7.map((o) => `${o.event.title} — ${formatEventWhen(o.event)}`)Embed the calendar view with your own state (the page pattern, condensed):
import {useCalendar} from '@/components/calendar/useCalendar'
import {CalendarView} from '@/components/calendar/CalendarView'
import {CalendarToolbar} from '@/components/calendar/CalendarToolbar'
function MyCalendar() {
const cal = useCalendar()
return (
<div className="flex h-full flex-col">
<CalendarToolbar
title={cal.title} view={cal.view} onChangeView={cal.changeView}
onPrev={cal.goPrev} onNext={cal.goNext} onToday={cal.goToday}
query={cal.query} onQueryChange={cal.setQuery} onAdd={cal.onAddClick}
onExport={cal.exportAll} fileRef={cal.fileRef} onImportFile={cal.onImportFile}
/>
<CalendarView
calendarRef={cal.calendarRef} events={cal.fcEvents} initialView={cal.view}
onSelect={cal.onSelect} onEventClick={cal.onEventClick}
onEventDrop={cal.onEventDrop} onEventResize={cal.onEventResize}
onEventReceive={cal.onEventReceive} onDatesSet={cal.onDatesSet}
/>
</div>
)
}Export events to an .ics file:
import {eventsToIcs, downloadIcs} from '@/lib/ics'
downloadIcs('calendar.ics', eventsToIcs(Object.values(cal.events)))Best practices
- Keep all state in
useCalendar; keep the page render-only (compose toolbar + sidebar + view + modals). Follow this if you fork the calendar. - Drive FullCalendar through
calendarRef.current.getApi()— the built-in header toolbar stays off. - Store event dates as local ISO (no timezone suffix) via
localIsoso FullCalendar reads them in local time. - Color events only through
fc-ev-<tone>marker classes + token mappings — never per-event hex. - Treat state as the single source of truth:
onEventReceiveremoves the temporary FC event and adds a real one to state. - Never add
@fullcalendar/schedulerorresource-*plugins (premium/paid).
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| Events off by a day / hour | An event start/end carried a timezone suffix. Use localIso (no Z/offset). |
| Recurring event won't drag | Intentional — recurring events are editable:false; edit the series in EventModal. |
| Sidebar dots miss a recurrence | The sidebar expands via occurrencesInRange; check the rule's until/byweekday. |
| Event colors don't follow theme/skin | You set a hex. Use a category tone + the fc-ev-<tone> mapping in _calendar.scss. |
| Import found no events | The .ics had no DTSTART/SUMMARY the parser recognized (parseIcs filters events without a start). |
| Calendar has a scroll gap | Full-bleed sizing — subtract the footer only when config.fixedFooter (via useFixedFooterHeight). |
FAQ
Is it wired to a backend? No — events live in localStorage (calendar-state-v2). Replace the
setData calls / loadCalendar in useCalendar with API calls to go live.
Can I use the scheduler / resource views? No — those are premium FullCalendar plugins and are
prohibited by the template's MIT-only licensing rule. Only daygrid/timegrid/list/interaction/
rrule are allowed.
Why is the header toolbar disabled? So the page can render a token-styled toolbar that matches the rest of the template; FullCalendar is driven imperatively via its API.
Do recurring events edit per-instance? No — they edit at the series level (Repeat section of
EventModal).
Notes for designers & content editors
- Category labels and all calendar chrome text live in the
calendari18n namespace — editsrc/locales/en/calendar.jsonto reword. - Event titles, locations, and member names in the seed are literal demo content.
- The five event categories are locked to the five semantic tones — recoloring an event means picking its category, and recoloring a tone means editing the token (which re-skins the whole app in sync).
- Avatars for attendees are self-hosted in
public/avatars/.
Related
Was this page helpful?
