Реалізація календаря подій на вебсайті
Календар подій складніший, ніж здається. Повторювані eventos, часові пояси, перекриваючі слоти, мобільний UX, інтеграція з Google Calendar — кожен пункт додає тиждень розробки. Розберемо по частинах.
Вибір бібліотеки
FullCalendar — промислов стандарт. Підтримує місяць/тиждень/день/список, drag-and-drop, повторювані события, iCalendar, Google Calendar API. Пакети для React, Vue, Angular доступні. Безплатна версія закриває 90% задач, Premium додає timeline та resource views.
React Big Calendar — легша, простіша для налаштування, менше функцій. Хороша для простих розписаних.
TOAST UI Calendar — корейська розробка, хороші можливості, активно розвивається.
Для статичного відображення (тільки вивід подій без інтерактивності) — можна обійтися без бібліотек.
FullCalendar: Базова React інтеграція
npm install @fullcalendar/react @fullcalendar/core @fullcalendar/daygrid \
@fullcalendar/timegrid @fullcalendar/list @fullcalendar/interaction
import FullCalendar from '@fullcalendar/react'
import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
import listPlugin from '@fullcalendar/list'
import interactionPlugin from '@fullcalendar/interaction'
import { EventInput, DateSelectArg, EventClickArg } from '@fullcalendar/core'
import ukLocale from '@fullcalendar/core/locales/uk'
export function EventCalendar() {
const [events, setEvents] = useState<EventInput[]>([])
const [selectedEvent, setSelectedEvent] = useState<EventClickArg | null>(null)
useEffect(() => {
fetch('/api/events')
.then(r => r.json())
.then(data => setEvents(data.map(mapToFullCalendarEvent)))
}, [])
function handleEventClick(info: EventClickArg) {
info.jsEvent.preventDefault()
setSelectedEvent(info)
}
function handleDateSelect(info: DateSelectArg) {
// Відкриваємо форму створення события
openCreateEventModal({ start: info.start, end: info.end, allDay: info.allDay })
}
return (
<>
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, listPlugin, interactionPlugin]}
initialView="dayGridMonth"
locale={ukLocale}
headerToolbar={{
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek',
}}
buttonText={{
today: 'Сьогодні',
month: 'Місяць',
week: 'Тиждень',
day: 'День',
list: 'Список',
}}
events={events}
selectable={true}
selectMirror={true}
dayMaxEvents={3} // "+N ще" замість переповнення
weekends={true}
eventClick={handleEventClick}
select={handleDateSelect}
eventTimeFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
firstDay={1} // Тиждень з понеділка
height="auto"
eventDisplay="block"
eventContent={renderEventContent}
/>
{selectedEvent && <EventModal event={selectedEvent} onClose={() => setSelectedEvent(null)} />}
</>
)
}
function renderEventContent(eventInfo: any) {
return (
<div className="fc-event-custom">
<span className="fc-event-dot" style={{ background: eventInfo.event.backgroundColor }} />
<span className="fc-event-title">{eventInfo.event.title}</span>
{!eventInfo.event.allDay && (
<span className="fc-event-time">{eventInfo.timeText}</span>
)}
</div>
)
}
API: структура подій
// app/Http/Controllers/EventController.php
class EventController extends Controller
{
public function index(Request $request): JsonResponse
{
$events = Event::query()
->when($request->start, fn($q) => $q->where('end_at', '>=', $request->start))
->when($request->end, fn($q) => $q->where('start_at', '<=', $request->end))
->with(['category', 'location'])
->get()
->map(fn(Event $e) => [
'id' => $e->id,
'title' => $e->title,
'start' => $e->start_at->toIso8601String(),
'end' => $e->end_at->toIso8601String(),
'allDay' => $e->all_day,
'backgroundColor' => $e->category->color,
'borderColor' => $e->category->color,
'url' => route('events.show', $e),
'extendedProps' => [
'description' => $e->description,
'location' => $e->location?->name,
'category' => $e->category->name,
],
]);
return response()->json($events);
}
}
Повторювані события через rrule
npm install @fullcalendar/rrule rrule
import rrulePlugin from '@fullcalendar/rrule'
// У массиву events:
const recurringEvent: EventInput = {
id: 'weekly-standup',
title: 'Щотижневий стендап',
rrule: {
freq: 'weekly',
byweekday: ['mo', 'we', 'fr'],
dtstart: '2025-01-01T09:00:00',
until: '2025-12-31',
},
duration: '00:30',
backgroundColor: '#0ea5e9',
}
// Виключення (скасовані дати)
const recurringWithExceptions: EventInput = {
...recurringEvent,
exdate: [
'2025-05-01T09:00:00', // Свято — пропускаємо
'2025-06-12T09:00:00',
],
}
Часові пояси
// Зберігаємо в UTC, відображаємо в часовому поясі користувача
<FullCalendar
timeZone="local" // або конкретний: 'Europe/Kyiv'
// ...
/>
// Якщо события в різних TZ — конвертуємо при завантаженні
import { formatInTimeZone } from 'date-fns-tz'
function mapToFullCalendarEvent(event: ApiEvent): EventInput {
return {
...event,
start: formatInTimeZone(event.start_at, event.timezone, "yyyy-MM-dd'T'HH:mm:ss"),
end: formatInTimeZone(event.end_at, event.timezone, "yyyy-MM-dd'T'HH:mm:ss"),
}
}
Інтеграція з Google Calendar
npm install @fullcalendar/google-calendar
import googleCalendarPlugin from '@fullcalendar/google-calendar'
<FullCalendar
plugins={[dayGridPlugin, googleCalendarPlugin]}
googleCalendarApiKey="AIza..."
eventSources={[
{
googleCalendarId: '[email protected]',
backgroundColor: '#4285f4',
borderColor: '#4285f4',
},
{
googleCalendarId: '[email protected]',
backgroundColor: '#0f9d58',
display: 'background', // фонова підсвітка для свят
},
]}
/>
Експорт в iCalendar (.ics)
// Генерація .ics файлу на Laravel
// composer require eluceo/ical
use Eluceo\iCal\Domain\Entity\Calendar;
use Eluceo\iCal\Domain\Entity\Event;
use Eluceo\iCal\Presentation\Factory\CalendarFactory;
class ICalExportController extends Controller
{
public function export(Request $request): Response
{
$events = Event::whereHas('attendees', fn($q) => $q->where('user_id', auth()->id()))->get();
$calendar = new Calendar(array_map(function (Event $e) {
return (new \Eluceo\iCal\Domain\Entity\Event(
new \Eluceo\iCal\Domain\ValueObject\UniqueIdentifier($e->uid)
))
->setSummary($e->title)
->setDescription($e->description ?? '')
->setOccurrence(
new \Eluceo\iCal\Domain\ValueObject\TimeSpan(
new \Eluceo\iCal\Domain\ValueObject\DateTime(
\DateTimeImmutable::createFromMutable($e->start_at), true
),
new \Eluceo\iCal\Domain\ValueObject\DateTime(
\DateTimeImmutable::createFromMutable($e->end_at), true
)
)
);
}, $events->all()));
return response(
(new CalendarFactory())->createCalendar($calendar),
200,
[
'Content-Type' => 'text/calendar; charset=UTF-8',
'Content-Disposition' => 'attachment; filename="events.ics"',
]
);
}
}
Мобільний UX: свайп для зміни місяця
// FullCalendar не підтримує swipe нативно — додаємо через touch events
function addSwipeNavigation(calendarRef: React.RefObject<FullCalendar>) {
let startX = 0
const el = document.querySelector('.fc')!
el.addEventListener('touchstart', (e: TouchEvent) => {
startX = e.touches[0].clientX
}, { passive: true })
el.addEventListener('touchend', (e: TouchEvent) => {
const diff = startX - e.changedTouches[0].clientX
const api = calendarRef.current!.getApi()
if (Math.abs(diff) > 50) {
diff > 0 ? api.next() : api.prev()
}
}, { passive: true })
}
Терміни
Статичний календар з вивідом подій з API — 1 день. FullCalendar з місяцем/тижнем/списком, модальним переглядом события та фільтром по категоріях — 3–4 дні. З редагуванням drag-and-drop, повторюваними подіями, iCal-експортом та синхронізацією Google Calendar — 2 тижні.







