FullCalendar: календарь записей на JavaScript — 8 сценариев и PHP
Собираем календарь для сервиса: записи, переносы, фильтры, повторы и рабочие часы. Восемь живых сценариев, PHP API с проверками и готовый проект для Windows, Linux и VPS.

Календарь в приложении нужен, когда пользователю важно видеть не только список дел, но и время между ними. Администратор записывает клиента на консультацию, диспетчер распределяет выезды, команда планирует созвоны, редактор следит за датами публикаций. В таблице всё это можно хранить, но занятость дня и пересечения гораздо понятнее на временной сетке.
FullCalendar берёт на себя отображение календаря, переключение видов, выбор интервала, перетаскивание и события интерфейса. В этой статье соберём учебный сервис с восемью сценариями. Его можно менять прямо на странице, смотреть JSON и скачать целиком. В архиве также есть PHP-режим: настоящие HTTP-запросы, серверная проверка данных, CSRF и защита от перезаписи изменений из другой вкладки.
Что такое FullCalendar и где он пригодится
FullCalendar — JavaScript-библиотека для календарных интерфейсов. Она принимает описание событий и показывает их в виде месяца, сетки недели, отдельного дня или списка. Пользователь нажимает запись, выделяет свободный интервал или переносит встречу, а приложение получает обработчик с датами и идентификатором. Дальнейшее действие определяет ваш код: открыть форму, запросить сервер, показать ошибку или сохранить изменение.
В сервисном бизнесе это помогает вести расписание консультаций и выездов. В образовательном проекте — показывать занятия и изменения времени. В CRM — связывать встречи с заявками и ответственными сотрудниками. В редакционной системе — распределять публикации и согласования. Для внутренней команды можно собрать единый календарь созвонов, отпусков и дедлайнов, но правила доступа к этим данным останутся задачей самого сервиса.
Календарь не является готовой CRM, базой данных или системой оплаты. Например, красивый свободный слот не доказывает, что его можно забронировать: другой пользователь мог занять его секунду назад. Поэтому интерфейс показывает удобное представление, а окончательное решение о сохранении принимает сервер. Это разделение особенно важно, если одна ошибка может привести к двум клиентам на одно время.
Подробнее: FullCalendar для обычного JavaScript.
Бесплатные возможности, версия 7 и границы примера
В мастерской используется FullCalendar Standard 7.1.4 — бесплатные компоненты под MIT. Месяц, неделя, день, список, выбор дат, перенос событий и простое повторение не требуют покупки Premium. Ресурсные виды с отдельными колонками сотрудников и горизонтальный resource timeline относятся к другой части продукта. Здесь расписание команды реализовано обычным фильтром, поэтому платный Scheduler в архив не включён.
Для версии 7 нужны соответствующие импорты, тема и CSS. Старый пример с пакетами из шестой версии нельзя механически смешивать с новой сборкой. У нас зафиксированы fullcalendar 7.1.4, temporal-polyfill 1.0.7 и Bootstrap 5.3.8. Транзитивные зависимости фиксирует package-lock.json. Готовые app.js и app.css включены в архив, так что сначала можно просто открыть работающий проект, а затем изучать сборку.
HTML5 задаёт форму и структуру страницы, Bootstrap5 оформляет основные элементы, а FullCalendar отвечает за календарь. React, Vue и Angular для этого примера не нужны. Все используемые библиотеки лежат в локальной сборке; при работе календаря браузеру не требуется обращаться к внешнему CDN. Лицензии зависимостей сохранены в THIRD-PARTY-LICENSES.txt.
Подробнее: лицензии FullCalendar Standard и Premium.
Мини-мастерская: восемь задач для настоящего календаря
В выпадающем списке «Что пробуем» выберите сценарий. У каждого есть собственный набор учебных записей и подсказка с действием. Сначала попробуйте добавить встречу, затем отфильтровать сотрудника, перенести запись и вернуть последнее изменение. После этого переключитесь на ограничения и специально выберите занятое время. Так видна не только удачная операция, но и объяснение отказа.
На компьютере удобно смотреть неделю по часам; на узком экране по умолчанию появляется список. Любую запись можно открыть кнопкой в отдельном списке и изменить в форме. Перетаскивание остаётся дополнительным способом, а не обязательным условием. Счётчики показывают число записей и длительность разовых встреч после фильтрации; целые дни и повторяющиеся серии из подсчёта часов исключены.
Учебная неделя зафиксирована на 9–15 ноября 2026 года. Все часы обозначены как UTC. Это позволяет повторять примеры независимо от даты чтения и настроек компьютера. Кнопка «Учебная неделя» возвращает календарь к данным. Изменения в разных сценариях сохраняются в памяти открытой страницы; обновление страницы очищает их, кроме явно сохранённого черновика.
Мастерская на сайте работает в браузере. Серверный PHP-режим запускается отдельно из архива. Встроенную область можно прокручивать; на телефоне удобнее полноэкранная ссылка.
Первый календарь: подключение и минимальный код
Если вы запускаете архив, используйте npm ci: эта команда восстанавливает зафиксированное дерево зависимостей. Для нового проекта можно установить выбранные пакеты отдельно. После этого сборщик объединяет модули и стили. В HTML остаются контейнер календаря и подключение полученных файлов. Высота и ширина контейнера должны быть доступны в момент render; скрытая вкладка или свёрнутая панель требуют отдельного обновления размера после открытия.
npm install --save-exact fullcalendar@7.1.4 temporal-polyfill@1.0.7
npm install --save-dev --save-exact esbuild@0.28.2
import { Calendar } from 'fullcalendar';
import themePlugin from 'fullcalendar/themes/monarch';
import dayGridPlugin from 'fullcalendar/daygrid';
import ru from 'fullcalendar/locales/ru';
import 'fullcalendar/skeleton.css';
import 'fullcalendar/themes/monarch/theme.css';
import 'fullcalendar/themes/monarch/palettes/green.css';
const calendar = new Calendar(document.querySelector('#calendar'), {
plugins: [themePlugin, dayGridPlugin],
locale: ru,
timeZone: 'UTC',
initialDate: '2026-11-09',
initialView: 'dayGridMonth',
events: [{ id: '1', title: 'Обсуждение проекта', start: '2026-11-09' }]
});
calendar.render();
В минимальном примере есть только месячный вид. Для сетки по часам подключается timegrid, для списка — list, а для выделения и переноса — interaction. Сборщик позволяет взять нужные части, не добавляя все доступные плагины. Полная конфигурация мастерской находится в src/app.js и показывается на вкладке JavaScript вместе с текущими данными.
Как устроена запись: ID, даты и дополнительные поля
У записи есть устойчивый id, название, начало и окончание. ID нужен для редактирования и удаления, поэтому название нельзя использовать вместо идентификатора: два клиента вполне могут записаться на одинаковую услугу. Дополнительные сведения удобно хранить в extendedProps. В примере это сотрудник и статус; в вашем приложении там могут быть тип услуги, номер заявки и признак подтверждения.
const appointment = {
id: 'booking-104',
title: 'Обсуждение проекта',
start: '2026-11-09T09:00:00Z',
end: '2026-11-09T10:00:00Z',
allDay: false,
extendedProps: { staff: 'anna', status: 'confirmed' }
};
Для списка сотрудников используются стабильные ключи anna, boris и vera, а подписи выводятся отдельно. Так переименование сотрудника не ломает существующие записи. Статусы также ограничены списком: запланировано, подтверждено, завершено. Произвольное значение из запроса сервер не принимает. Название выводится как текст, поэтому текстовые данные не превращаются в исполняемый HTML.
FullCalendar возвращает объект события с методами изменения. Наше приложение дополнительно хранит собственный массив данных, чтобы фильтры, форма, экспорт и сервер видели одно состояние. Календарь обновляется из этого массива после принятой операции. Это упрощает отмену и позволяет не читать бизнес-данные обратно из DOM.
Подробнее: модель события FullCalendar.
1. Запись на услугу: создание и редактирование
Представьте администратора, который разговаривает с клиентом и должен быстро закрепить договорённость. Он нажимает «Новая запись», выбирает сотрудника, вводит время и понятное название. После сохранения запись появляется в календаре и списке, а счётчик увеличивается. Нажатие существующей записи заполняет ту же форму, поэтому пользователю не приходится изучать два разных интерфейса для создания и редактирования.
В сетке можно выделить интервал мышью. Даты попадут в форму, но запись ещё не считается сохранённой: сначала нужны название и сотрудник. Такой шаг предотвращает появление пустых событий от случайного выделения. На мобильном экране форма выполняет ту же задачу без сложного жеста. После ошибки введённый текст остаётся на месте, а сообщение объясняет, что исправить.
select(info) {
clearForm(info.startStr, info.endStr, info.allDay);
document.querySelector('#title').focus();
calendar.unselect();
}
// clearForm — функция приложения из src/app.js.
// Она заполняет форму; сохранение выполняет submit-обработчик.
В рабочем сервисе к этому сценарию добавляются контакт клиента, источник записи и права администратора. Их не стоит складывать в публичный JSON календаря без необходимости. Для просмотра занятости обычно достаточно времени и короткой подписи, а подробности карточки загружаются после отдельной проверки доступа.
2. Расписание команды: фильтры и понятные статусы
Когда в календаре несколько сотрудников, одних цветных прямоугольников мало. Нужно быстро ответить на конкретный вопрос: что у Анны подтверждено на этой неделе, где ещё есть запланированные встречи и какие записи уже завершены. В мастерской фильтры сотрудника и статуса применяются совместно. Меняются календарь, список и показатели, поэтому интерфейс не показывает противоречивые итоги.
Цвет отражает статус, но тот же статус написан словами в списке. Это полезно при нарушениях цветового восприятия, на слабом экране и при печати. Фильтр сотрудника не выдаётся за ресурсный вид Premium: здесь стандартный календарь с обычным набором событий. Для небольшого сервиса такого решения часто достаточно, особенно когда диспетчер работает с одним человеком за раз.
const visible = records.filter(event =>
(!staff || event.extendedProps.staff === staff) &&
(!status || event.extendedProps.status === status)
);
calendar.refetchEvents(); // events-функция отдаёт visible
При пустой выборке пользователь видит объяснение и может сбросить фильтр. Не заменяйте это пустым белым блоком: человек должен понимать, что данные отфильтрованы, а не потеряны. В серверном варианте фильтры могут стать параметрами API, но сервер всё равно ограничивает доступный пользователю набор сотрудников.
3. Перенос, изменение длительности и отмена
Перетаскивание помогает быстро перестроить день, но случайная ошибка здесь особенно неприятна. Поэтому после переноса мастерская показывает сообщение и даёт вернуть последнее изменение. Аналогично работают редактирование формы, удаление и сброс сценария. Отмена хранит одну предыдущую версию в памяти; это не бесконечная история и не журнал действий сотрудников.
FullCalendar вызывает eventDrop после переноса и eventResize после изменения длительности. В обработчик приходит новое состояние и функция revert. В нашем варианте визуальная операция сначала возвращается, затем кандидат проходит общие правила и сохранение. После успешного ответа календарь строится заново из принятых данных. Таким образом, при ошибке сервера интерфейс не оставляет посетителю ложную видимость сохранённой записи.
async function onDrop(info) {
const candidate = {
id: info.event.id,
start: info.event.startStr,
end: info.event.endStr
};
info.revert();
// В полном проекте: объединить с записью, проверить правила,
// отправить серверу и отобразить только принятый результат.
await saveCandidate(candidate);
}
Функция saveCandidate здесь обозначает интеграцию с вашим приложением; рабочая реализация commit находится в архиве. Для клиента важен результат операции, поэтому сообщение должно говорить «запись перенесена» или объяснять отказ. Одного исчезнувшего индикатора загрузки для такого действия недостаточно.
Подробнее: eventDrop и возврат изменения.
4. Дедлайны и события на целый день
Дата без времени подходит для дедлайна, отпуска, этапа подготовки или дня публикации. Она не означает полночь конкретного часового пояса. Если событие занимает два дня, 12 и 13 ноября, начало будет 2026-11-12, а конец — 2026-11-14. Конец интервала не включается. Это же правило помогает корректно стыковать соседние периоды без искусственного значения 23:59:59.
{
id: 'report',
title: 'Подготовка отчёта',
start: '2026-11-12',
end: '2026-11-14',
allDay: true
}
В мастерской включение «Весь день» меняет тип полей даты и пояснение возле окончания. Пользователь видит правило непосредственно там, где вводит значение. Это лучше, чем спрятать его только в документации: ошибка на один день иначе будет повторяться при каждой новой записи. При возвращении к времени необходимо проверить часы, потому что целодневный интервал не содержит информации о конкретном начале работы.
Не складывайте целые дни в показатель часов занятости без договорённости о смысле. Один календарный день может означать восемь рабочих часов, круглосуточное резервирование или просто срок сдачи. В учебных показателях такие события намеренно исключены из часов, а их количество остаётся в общем списке.
5. Повторяющиеся встречи: правило и отдельный экземпляр
Планёрка по понедельникам и средам не обязана храниться как десятки одинаковых строк. FullCalendar умеет разворачивать простое недельное правило. В сценарии есть серия с двумя днями недели, временем 08:00–08:30 и ограниченным периодом ноября. При переключении недель библиотека показывает соответствующие экземпляры. В JSON остаётся одно правило, поэтому число записей и число нарисованных встреч не всегда совпадают.
{
id: 'series-1',
title: 'Планёрка команды',
daysOfWeek: [1, 3],
startTime: '08:00', endTime: '08:30',
startRecur: '2026-11-01', endRecur: '2026-12-01',
editable: false
}
Самый важный продуктовый вопрос начинается при изменении: перенести только эту встречу, всю серию или все будущие встречи? Это разные операции. В мастерской серия доступна только для просмотра, чтобы не создавать впечатление готовой системы исключений. В реальном приложении храните правило, исключённые даты и изменённые экземпляры отдельно; пользователь должен явно выбрать масштаб правки.
Для сложных повторений существует подключаемый RRule, но он не устраняет необходимость проектировать исключения и права. Начните с реального расписания клиента: если встречи заранее известны и нерегулярны, обычные отдельные записи могут быть проще и понятнее правила.
Подробнее: простые повторяющиеся события.
6. Рабочие часы и запрет пересечений
В сценарии ограничений доступны только будни с 09:00 до 18:00 UTC, а две активные записи одного сотрудника не могут пересекаться. Проверка учитывает весь интервал, включая минуты окончания. Встреча до 18:01 уже выходит за границу. Соседние интервалы 09:00–10:00 и 10:00–11:00 допускаются: конец первого не включается в занятость.
function overlaps(a, b) {
return new Date(a.start) < new Date(b.end)
&& new Date(a.end) > new Date(b.start);
}
const conflict = records.some(event =>
event.id !== candidate.id &&
event.extendedProps.staff === candidate.extendedProps.staff &&
event.extendedProps.status !== 'done' &&
candidate.extendedProps.status !== 'done' &&
overlaps(event, candidate)
);
Рабочие часы, подсветка и запрет сохранения — отдельные вещи. businessHours помогает визуально показать рабочую область, но бизнес-правило нужно применять при каждом способе изменения: форме, переносе, восстановлении черновика и запросе напрямую к API. Иначе пользователь обойдёт ограничение, просто изменив данные другим путём. В PHP-примере проверка повторяется на сервере.
Мастерская считает завершённую запись неблокирующей. Для вашего сервиса это может быть неверно: историческая занятость часто должна оставаться неизменной. Политика статусов приведена явно, чтобы вы могли заменить её под свою задачу, а не принять учебное условие за универсальное правило расписаний.
Подробнее: программные ограничения переноса.
7. Загрузка JSON, ошибка и повтор
Сценарий загрузки делает настоящий fetch к data/remote.json из проекта. Это статический HTTP-файл с учебными событиями, а не скрытая база или имитация успешного ответа. Над календарём можно явно включить учебный отказ. Тогда старые данные сохраняются, появляется объяснение, и пользователь может выключить отказ и повторить загрузку. Пустой результат и ошибка сети не смешиваются.
const response = await fetch('data/remote.json', { cache: 'no-store' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const events = await response.json();
// Проверить массив и поля до передачи в календарь.
// В проекте это делает sanitizeDraft, лимит — 80 записей.
Для большого сервиса календарь обычно запрашивает только нужный диапазон дат. Нативный JSON feed FullCalendar передаёт границы start и end при переключении периода. Сервер выбирает пересекающие диапазон записи и возвращает разрешённые поля. Наш файл небольшой и загружается целиком, чтобы его можно было изучить без установки базы; в статье важно видеть разницу между демонстрацией транспорта и производственной выборкой.
Ошибка не должна очищать заполненную форму или молча подменяться пустым массивом. Предложите повтор, сохраните контекст и объясните, что последний результат может быть устаревшим. Если сохранялась запись, не повторяйте операцию автоматически без защиты от дубликатов; если читался список, повторный GET обычно значительно проще.
Подробнее: загрузка событий по JSON URL.
8. Черновик, JSON и перенос настроек в свой проект
Черновик нужен, когда человек подготовил расписание, но ещё не готов отправлять его серверу. В мастерской сохранение в localStorage выполняется только явной кнопкой. После обновления страницы появится сообщение о наличии черновика, однако данные не восстанавливаются без действия пользователя. Так случайно сохранённый вчера вариант не заменяет текущее состояние незаметно.
localStorage.setItem('aen-fullcalendar-draft-v1', JSON.stringify({
version: 1,
events: records
}));
// При чтении: ограничить размер, проверить version,
// уникальность ID, даты, сотрудников и статусы.
В JSON включаются все записи выбранного сценария, даже если часть скрыта фильтром. Это написано рядом с интерфейсом: экспорт только видимых строк и экспорт полного состояния решают разные задачи. Кнопка JavaScript показывает рецепт инициализации с текущими настройками; его нужно использовать в проекте с указанными зависимостями, а не ожидать, что отдельный файл сам установит библиотеку.
Хранилище браузера не является защищённой базой и может быть очищено. Для реальных клиентов не сохраняйте лишние персональные данные в учебном локальном черновике. Если нужен перенос между устройствами или работа нескольких сотрудников, используйте серверный черновик с правами, сроком хранения и понятной кнопкой удаления.
Часовые пояса: что хранить и что показывать
В примере выбран UTC, чтобы одинаковая запись показывалась одинаково на любом компьютере. Буква Z в ISO-строке обозначает UTC. Значение datetime-local из формы само по себе не содержит часового пояса, поэтому интерфейс прямо подписывает шкалу UTC, а код добавляет Z в рамках этого учебного соглашения. В бизнес-приложении нельзя автоматически приписывать Z ко времени, которое пользователь вводил как московское.
Если клиент записывается на 10:00 в конкретном городе, приложению нужны и местное время, и зона IANA, например Europe/Moscow. Если встреча представляет уже определённый момент, сервер может хранить UTC и переводить его в зону пользователя при отображении. Для повторяющегося занятия «каждый понедельник в 10:00» местная зона особенно важна в регионах с сезонным переводом часов.
Отдельно договоритесь о целодневных событиях: дата отпуска не должна сдвигаться на предыдущий день при показе в другой стране. На границе формы, API и базы используйте один документированный контракт, а тесты делайте для разных зон и переходов времени. FullCalendar предоставляет способы отображения зон, но не может угадать бизнес-смысл входной строки.
Подробнее: timeZone и интерпретация дат.
PHP-режим: настоящее сохранение и изоляция посетителей
В архиве есть server/api.php. При запуске с параметром backend=php тот же интерфейс читает и сохраняет состояние через HTTP. GET возвращает события, версию и CSRF-токен. POST отправляет изменённый массив и известную клиенту версию. Сервер проверяет тип содержимого, размер запроса, структуру записей, даты, сотрудников и статусы, после чего сохраняет принятый результат.
const response = await fetch(`api.php?scenario=${scenario}`, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf
},
body: JSON.stringify({ version, events: nextRecords })
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
Данные хранятся в изолированной PHP-сессии, вне веб-каталога. После тридцати минут бездействия создаётся новое учебное состояние. Другой браузер получает свой набор записей. Для публичной мастерской это удобно: один посетитель не портит пример другому. Для общей записи клиентов такая модель не подходит — там нужна общая база и отдельные права пользователей.
Пределы учебного API заданы явно: восемь сценариев, до восьмидесяти записей в каждом, тело запроса до ста килобайт и ограничение частоты изменений в пределах сессии. Ограничение частоты не заменяет защиту внешнего сервера от злоупотреблений, поэтому VPS-вариант ниже закрывается входом Caddy. На сайте статьи PHP API не публикуется; оно находится в скачиваемом проекте.
Конфликт двух вкладок и серверная проверка
Представьте две вкладки с версией 5. В первой сотрудник переносит встречу и получает версию 6. Вторая вкладка пытается сохранить свой старый массив. Если принять его без проверки, перенос исчезнет. В примере сервер возвращает HTTP 409 и предлагает перечитать состояние. Пользователь переключает сценарий, получает актуальные данные и решает, какое изменение выполнить заново.
if ($data['version'] !== $state['version']) {
reply(409, ['error' => 'Данные изменились в другой вкладке. Перечитайте состояние.']);
}
// После всех проверок под блокировкой PHP-сессии:
$state = ['version' => $state['version'] + 1, 'events' => $events];
Блокировка сессии делает этот пример последовательным для одного посетителя, но она не защищает общую таблицу бронирования разных пользователей. В рабочей базе проверка свободного интервала и запись должны выполняться согласованно: в транзакции, с выбранной стратегией блокировок или подходящим ограничением базы. Простого SELECT перед INSERT без контроля параллельности недостаточно.
CSRF-токен подтверждает происхождение операции в рамках сессии, а проверка роли отвечает на другой вопрос — разрешено ли пользователю менять эту запись. В учебном сервере нет учётных записей и ролей. При интеграции в CRM проверяйте владельца и сотрудника на сервере, даже если соответствующее поле спрятано или заблокировано в форме.
Доступность и мобильный интерфейс
Календарь легко сделать эффектным и неудобным одновременно. На телефоне семь узких колонок плохо подходят для длинных названий и точного переноса. Поэтому мастерская сначала показывает список недели, а сетку можно выбрать отдельно. Недельный вид прокручивается внутри своей области; форма и основные кнопки не расширяют всю страницу. Дневной вид помогает работать со временем, сохраняя более широкие записи.
У каждого поля есть подпись. Статусы написаны словами, сообщения объявляются через live-region, а кнопки имеют достаточную высоту для касания. Для редактирования существует отдельный список записей: можно пройти по нему клавиатурой, открыть нужную встречу и изменить время обычными полями. Это также помогает, если перетаскивание конфликтует с прокруткой страницы.
В библиотеке включена интерактивность событий, однако доступность приложения не заканчивается одной настройкой. Нужно проверить порядок фокуса, читаемость ошибок, отсутствие ловушек при навигации и поведение после смены вида. Автоматический тест модели не показывает, помещается ли кнопка в экран; визуальная проверка мобильной ширины выполняется отдельно.
Подробнее: доступность FullCalendar.
Windows: запуск мастерской и изменение исходников
Для статического варианта установите Node.js 24 LTS из официального MSI и откройте новый PowerShell. Архив распаковывается в обычный рабочий каталог. Команды выполняются там, где находятся package.json и package-lock.json. Сначала устанавливаются точные зависимости, затем собирается интерфейс и запускаются тесты. После старта откройте адрес в браузере, а не HTML-файл двойным щелчком.
Set-Location C:\projects\fullcalendar-project
node --version
npm.cmd ci
npm.cmd run build
npm.cmd test
npm.cmd start
Адрес по умолчанию — http://127.0.0.1:8111/. Сервер слушает только этот компьютер и останавливается Ctrl+C. Если порт занят, задайте $env:PORT перед запуском. После изменения src/app.js или src/model.js повторяйте сборку. Готовая статика не нуждается в Node.js на обычном хостинге: Node здесь используется как инструмент разработки, а не обязательный сервер приложения.
(Invoke-WebRequest http://127.0.0.1:8111/).StatusCode
(Invoke-WebRequest http://127.0.0.1:8111/app.js).Headers['Content-Type']
Ожидайте HTTP 200 и JavaScript MIME. Если библиотека не загрузилась, сначала проверяйте сеть и сообщения консоли, а не меняйте бизнес-данные. Рядом с кодом есть тест настоящего FullCalendar в JSDOM; он проверяет создание календаря и действия приложения, но не заменяет осмотр верстки.
Windows: PHP-пример через Docker Desktop
Чтобы попробовать серверные запросы без ручной установки Apache и PHP, используйте Docker Desktop с Linux-контейнерами. Готовая сборка интерфейса уже включена в архив. Dockerfile берёт официальный образ PHP 8.5.12 с Apache, копирует только нужные статические файлы и API. Учебные данные для начального состояния лежат вне публичного каталога.
Copy-Item .env.example .env
docker compose config
docker compose up -d --build
docker compose ps
docker compose logs --tail=50 web
Откройте http://127.0.0.1:8112/?backend=php и проверьте надпись «PHP-режим». Создайте встречу и обновите вкладку: в этой сессии запись сохранится. Откройте другой профиль браузера — там будут исходные данные. Так можно наглядно увидеть разницу между серверным хранением в сессии и общей базой всех сотрудников.
python tests/php-http.py http://127.0.0.1:8112
docker compose stop
Python нужен только для проверочного скрипта. Команда stop останавливает контейнер; down пересоздаваемое учебное состояние не сохраняет. В .env можно изменить порт. Локально COOKIE_SECURE=0 нужен для HTTP, а на VPS с HTTPS значение должно быть 1. Если выставить его в 1 на обычном локальном HTTP, браузер не будет передавать cookie сессии и сохранение станет выглядеть сломанным.
Linux: локальный запуск и вариант без Docker
На Linux используется тот же lock-файл и те же исходники. Для Ubuntu/Debian в архиве лежит установщик официального Node24, который проверяет SHA256 и поддерживает x64 и arm64. Установка Node требует sudo, но npm ci и сборка выполняются обычным пользователем. Это сохраняет нормальные права файлов и не создаёт необходимости запускать разработку от root.
cd ~/projects/fullcalendar-project
sudo bash deploy/install-node24.sh
export PATH="/opt/node24/bin:$PATH"
npm ci
npm run build
npm test
npm start
Если PHP уже установлен, учебное API можно запустить встроенным сервером. Нужны PHP 8.2 или новее с обычными JSON и сессиями. router.php направляет запрос api.php в серверный файл, а остальное отдаёт из public. Этот способ предназначен для локальной проверки; на публичном VPS используйте Apache-контейнер и обратный прокси из следующего раздела.
php -S 127.0.0.1:8112 -t public server/router.php
После старта откройте тот же адрес с backend=php. Для проверки HTTP-примера достаточно python3 tests/php-http.py. Если сессии не создаются, проверьте права временного каталога и журнал PHP. Сам исходник server/api.php не должен становиться скачиваемым текстовым файлом на статическом хостинге.
Linux VPS: от архива до работающего сервиса
Для VPS нужны SSH-доступ, sudo, домен и DNS-записи на сервер. TCP 80 и 443 должны быть доступны извне; SSH-доступ сохраняется. В README приведены команды установки Docker из официального репозитория Ubuntu, включая ключ, описание репозитория и Compose plugin. Если Docker уже используется для других проектов, сначала проверьте существующую установку и не удаляйте контейнеры ради нового примера.
mkdir -p ~/fullcalendar-build
cd ~/fullcalendar-build
curl -fL -o fullcalendar-project.zip https://ae-nekrasov.ru/wp-content/plugins/aen-fullcalendar-workshop/fullcalendar-project.zip
unzip fullcalendar-project.zip
cd fullcalendar-project
cp .env.example .env
sed -i 's/^COOKIE_SECURE=.*/COOKIE_SECURE=1/' .env
sudo docker compose -p fullcalendar config
sudo docker compose -p fullcalendar up -d --build
sudo docker compose -p fullcalendar ps
curl --fail -I http://127.0.0.1:8112/
Порт Apache привязан к 127.0.0.1. Это означает, что сайт будет доступен через Caddy, а не через открытый внутренний порт контейнера. Политику перезапуска, предел памяти и ограниченный размер журналов задаёт Compose. Для простого статического календаря PHP-контейнер вообще необязателен: можно опубликовать public обычной файловой выдачей и подключить существующее API сервиса.
Этот VPS-сценарий разворачивает закрытый учебный стенд. Он позволяет проверить путь от домена до PHP и получить основу для интеграции. Превращение его в публичную запись клиентов требует общей базы, авторизации, защиты от одновременного бронирования и правил хранения данных; наличие HTTPS само по себе эти задачи не решает.
Подробнее: официальная установка Docker Engine на Ubuntu.
HTTPS, пароль и проверка доступности
Caddy завершает HTTPS и передаёт запросы локальному Apache. В архиве есть установщик Caddy и пример блока сайта. Перед добавлением конфигурации сохраните текущий Caddyfile. На сервере с несколькими сайтами добавляйте отдельный блок домена, а не заменяйте весь файл. Домен calendar.example.com в примере нужно заменить своим, включая корректную AAAA-запись, если используется IPv6.
calendar.example.com {
import /etc/caddy/fullcalendar-auth.caddy
encode zstd gzip
reverse_proxy 127.0.0.1:8112
}
Файл fullcalendar-auth.caddy содержит basic_auth с хешем пароля, права root:caddy и режим 640. README показывает получение хеша через интерактивный caddy hash-password: открытый пароль не записывается в команду и историю shell. Общий вход защищает демонстрацию целиком, но не заменяет пользователей и роли приложения. Даже с одинаковым паролем PHP-сессии разных браузеров остаются разными.
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl enable --now caddy
sudo systemctl reload caddy
curl -I https://calendar.example.com/
curl --user demo --fail -I https://calendar.example.com/
sudo journalctl -u caddy -n 50 --no-pager
Без пароля ожидается 401, с паролем — 200. Команда curl –user demo спросит пароль отдельно. В браузере открывайте HTTPS-адрес с backend=php. Если сертификат не выдаётся, проверяйте DNS, порты и журнал; отключение проверки сертификата скрывает причину, а не исправляет конфигурацию. Более подробно устройство прокси разобрано в статье о Caddy.
Обновления, откат и сохранность данных
Перед изменением проекта сохраните предыдущий архив, lock-файл и конфигурацию веб-сервера. Новую версию сначала распакуйте в другой каталог, выполните сборку и тесты, затем запустите на отдельном локальном порту. Только после проверки переключайте рабочий стенд. Так у вас остаётся понятная точка возврата, а обновление библиотек не смешивается с неожиданной правкой настроек сервера.
Для этой мастерской временные PHP-сессии не являются бизнес-хранилищем. Пересоздание контейнера может удалить учебные записи, и посетитель получит исходный набор. Если нужен результат эксперимента, скачайте JSON до обновления. В интерфейсе нет произвольного импорта JSON-файлов, поэтому экспорт — прежде всего исходные данные для своего проекта и проверяемый снимок состояния.
sudo docker compose -p fullcalendar logs --tail=100 web
sudo docker compose -p fullcalendar stop
# Перейдите в каталог новой или предыдущей проверенной версии.
sudo docker compose -p fullcalendar up -d --build
sudo docker compose -p fullcalendar ps
Для настоящего расписания резервная копия должна включать базу и быть проверена восстановлением. Не храните единственную копию записей внутри контейнера приложения. Вместе с техническим восстановлением продумайте человеческий сценарий: как сотрудник узнает о переносе, что увидит клиент и кто разберёт конфликт после сбоя.
Частые ошибки и порядок диагностики
Если календарь пустой, сначала нажмите «Учебная неделя» и сбросьте фильтры. Если нет оформления, проверьте app.css и соответствие темы версии библиотеки. Если JSON не загружается, убедитесь, что страница открыта по HTTP и флажок учебного отказа выключен. Эти проверки дешевле, чем немедленно переписывать обработчик событий.
Для PHP смотрите HTTP-статус: 403 означает проблему с CSRF или сессией, 409 — устаревшую версию, 422 — нарушение правил данных, 429 — слишком частые изменения. HTML вместо JSON обычно означает неправильный маршрут, страницу ошибки прокси или запуск статического сервера вместо PHP. Встроенное сообщение не должно раскрывать трассировку и внутренние пути, но журнал сервера должен помогать найти причину.
Если перенос визуально проходит, а после перезагрузки исчезает, проверьте выбранный режим. На странице статьи данные живут в браузере, а настоящий запрос выполняется только в PHP-варианте. Если в PHP каждый запрос получает новую сессию, проверьте cookie и COOKIE_SECURE. При ошибке 502 проверяйте состояние контейнера и адрес reverse_proxy, не открывая внутренний порт всему интернету.
Как превратить мастерскую в часть своего сервиса
Начните с одного сценария, например записи на консультацию, и определите обязательные данные: услуга, сотрудник, начало, длительность и статус. Затем зафиксируйте правила: рабочие часы, буфер между встречами, допустимость пересечений, право переноса и срок отмены. Только после этого выбирайте виды календаря и набор кнопок. Иначе интерфейс будет выглядеть завершённым, а реальные ситуации останутся неописанными.
В API используйте стабильные ID, проверяйте права и сохраняйте версии. Для списка событий отдавайте минимально необходимый набор данных и ограничивайте период запроса. Для создания полезен ключ идемпотентности, чтобы повтор после сетевой ошибки не сделал вторую запись. Для переноса храните историю важного изменения и связывайте её с пользователем; учебная кнопка отмены не заменяет аудит.
Если пользователи работают одновременно, после сохранения обновляйте связанные экраны. Начать можно с повторного чтения календаря, а затем подключить события в реальном времени. На сайте есть отдельный разбор Socket.IO с комнатами, подтверждениями и восстановлением после разрыва. Текст заметки к встрече при необходимости можно редактировать через Tiptap, сохраняя его отдельно от минимального календарного ответа.
Документация, исходники и что проверено
В архиве находятся готовая браузерная сборка, исходники, npm lock-файл, PHP API, Docker Compose, инструкции для Windows/Linux и VPS, лицензии и тесты. Проверки JavaScript используют настоящую библиотеку в JSDOM, а HTTP-скрипт обращается к настоящему PHP: создаёт изменения, проверяет сессии, CSRF, конфликты версий и ограничения. Такие проверки дополняют ручную работу с мастерской, а не доказывают поведение всех браузеров и устройств.
При переносе проекта проверяйте именно свою сборку и окружение. Особого внимания требуют часовые пояса, правила общего бронирования и восстановление данных. Статья даёт работающую основу и объясняет, где заканчивается учебный календарь и начинается ответственность приложения. Благодаря этому пример можно использовать как исходную точку, не скрывая важные условия за эффектным интерфейсом.


