PVR Tech Studio

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.

7 min read
Updated August 13, 2026

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/rrule plugin.
  • .ics export / 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.

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-DD all-day, YYYY-MM-DDTHH:mm:ss timed), allDay, categoryId, location?, attendeeIds, and recurrence?.
  • 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, and occurrencesInRange(events, from, to) which expands recurring rules (the sidebar mini-dots + Upcoming use it so recurrences show there too).

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 .ics file; export a single event from its detail view.

API / Props

useCalendar() — the view-model hook

Returns everything the page wires up:

GroupMembers
Data / derivedcategories, members, visibleEvents, fcEvents (FullCalendar EventInput[]), eventDays (Set<string> of YYYY-MM-DD with events), upcoming (Occurrence[], next ~60 days, capped 6).
View / navigationcalendarRef, view, title, currentDate, query, setQuery, changeView, goPrev, goNext, goToday, gotoDate, onDatesSet.
FilteringactiveCategories (Set<string>), toggleCategory(id).
Modalsediting ({event, isNew} | null), setEditing, viewing (CalendarEvent | null), setViewing.
HandlersonSelect, onEventClick, onEventDrop, onEventResize, onEventReceive, openDetailById, onAddClick, saveEvent, deleteEvent, duplicateEvent, editFromDetail.
ICSfileRef, exportAll, exportOne(event), onImportFile(changeEvent).

Components

ComponentKey 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?}.
MiniCalendarnative-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): string
  • downloadIcs(filename, text): void
  • parseIcs(text): Partial<CalendarEvent>[]

Configuration & customization

  • Categories & tones — edit the categories array in src/data/calendar.ts (label is an i18n key in the calendar namespace; tone is one of the five semantic tones).
  • Seed events / members — edit seedEvents / members; dates use the relative day() / at() helpers so they cluster around today.
  • Colors — adjust _calendar.scss token 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 firstDay to CalendarView (0 = Sunday).
  • Default view on mobile — defaultView() in useCalendar seeds listWeek under max-width:767px.
  • Upcoming window — UPCOMING_WINDOW_DAYS (60) in useCalendar.

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 localIso so 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: onEventReceive removes the temporary FC event and adds a real one to state.
  • Never add @fullcalendar/scheduler or resource-* plugins (premium/paid).

Troubleshooting

SymptomCause / fix
Events off by a day / hourAn event start/end carried a timezone suffix. Use localIso (no Z/offset).
Recurring event won't dragIntentional — recurring events are editable:false; edit the series in EventModal.
Sidebar dots miss a recurrenceThe sidebar expands via occurrencesInRange; check the rule's until/byweekday.
Event colors don't follow theme/skinYou set a hex. Use a category tone + the fc-ev-<tone> mapping in _calendar.scss.
Import found no eventsThe .ics had no DTSTART/SUMMARY the parser recognized (parseIcs filters events without a start).
Calendar has a scroll gapFull-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 calendar i18n namespace — edit src/locales/en/calendar.json to 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/.

Was this page helpful?