import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import FullCalendar from '@fullcalendar/react' import type { DatesSetArg, EventContentArg, EventInput, EventMountArg } from '@fullcalendar/core' import type { EventClickArg, EventHoveringArg } from '@fullcalendar/core' import dayGridPlugin from '@fullcalendar/daygrid' import interactionPlugin from '@fullcalendar/interaction' import ruLocale from '@fullcalendar/core/locales/ru' import { ChevronLeft, ChevronRight, Lock, Pencil } from 'lucide-react' import type { ConferenceOut, OccurrenceOut } from '@/api/conferences' import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard' /* * Стабильные (модульные, не пересоздаваемые на каждый рендер) значения для * пропсов FullCalendar. `@fullcalendar/react` при любом изменении identity * пропса-опции (массива/объекта/функции) дёргает внутренний `resetOptions`, * который заново вызывает `datesSet` — если тот, в свою очередь, меняет * состояние компонента без проверки «а изменилось ли значение», получается * бесконечный цикл setState → рендер → новые identity пропсов → resetOptions * → datesSet → setState (баг гейта, 2026-07-16: /calendar рендерился пустым * экраном с «Maximum update depth exceeded» в консоли). Поэтому: массивы * `plugins`/`hiddenDays` и функция `renderEventContent` — константы модуля * (не зависят от пропсов/состояния), а `events`/колбэки, зависящие от * пропсов, — обёрнуты в `useMemo`/`useCallback` ниже. */ const CALENDAR_PLUGINS = [dayGridPlugin, interactionPlugin] const HIDDEN_DAYS_BUSINESS_WEEK: number[] = [0, 6] const HIDDEN_DAYS_NONE: number[] = [] /** * Хранилище отписок focusin/focusout по DOM-элементу чипа: * ховер-карточка по клавиатурному фокусу) — элементы событий FullCalendar * рендерятся вне React-дерева, поэтому слушатели вешаются нативно в * `eventDidMount`/снимаются в `eventWillUnmount`; `WeakMap` не мешает сборке * мусора при повторном рендере событий. */ const eventFocusCleanups = new WeakMap void>() /** * Актуальный колбэк редактирования из последнего рендера * `ConferenceCalendar`. `renderEventContent` обязан оставаться одной и той же * функцией на всё время жизни модуля (см. комментарий выше про * resetOptions/бесконечный цикл), поэтому не может напрямую замкнуть пропс * компонента — вместо этого читает его отсюда, а компонент синхронизирует * переменную эффектом при каждом изменении пропса. */ let latestOnEditClick: (conference: ConferenceOut) => void = () => {} export type CalendarViewMode = 'dayGridWeek' | 'dayGridMonth' interface ConferenceCalendarProps { view: CalendarViewMode onViewChange: (view: CalendarViewMode) => void occurrences: OccurrenceOut[] isLoading: boolean /** Владельческие конференции текущего пользователя (`/conferences/my`, только свои — `is_owner`) — определяет, показывать ли карандашик на чипе. */ myConferenceById: Map /** Сообщает видимый диапазон сетки (UTC ISO) — используется для запроса вхождений. */ onRangeChange: (fromIso: string, toIso: string) => void onOccurrenceClick: (occurrence: OccurrenceOut) => void onEditClick: (conference: ConferenceOut) => void } /** * Не зависит от пропсов/состояния компонента — модульная константа-функция * (см. комментарий выше про стабильность identity). В чипе — только название * (обрезается многоточием по цепочке min-width:0/overflow:hidden ниже по * дереву, см. calendar.css) и, у владельца, карандашик редактирования; время/ * статус/номер — только в информационном окне (`ConferenceOccurrenceDialog`). */ function renderEventContent(arg: EventContentArg) { const occ = arg.event.extendedProps.occurrence as OccurrenceOut const ownedConference = arg.event.extendedProps.ownedConference as ConferenceOut | undefined return (
{occ.is_closed && {ownedConference && ( )}
) } /** «г.» — сноска Intl.DateTimeFormat('ru-RU', ...) после года при формате year+month(+day); в подписи периода не нужна. */ const YEAR_SUFFIX_RE = /\s*г\.(?=\s|$)/g /** * Подписи дней в самой сетке, из которых надо убрать «г.» (см. * `useStripYearSuffix`): месячный вид — номер дня в ячейке, недельный — * подпись колонки дня. */ const DAY_LABEL_SELECTOR = '.fc-daygrid-day-number, .fc-col-header-cell-cushion' /** Убирает «г.» из `aria-label`/`title` элемента; ничего не пишет, если менять нечего. */ function stripYearSuffixAttrs(el: Element) { for (const attr of ['aria-label', 'title']) { const value = el.getAttribute(attr) if (!value) continue const cleaned = value.replace(YEAR_SUFFIX_RE, '') if (cleaned !== value) el.setAttribute(attr, cleaned) } } /** * Убирает «г.» из служебных подписей дней внутри сетки FullCalendar. * * Кроме подписи периода (её формат мы задаём сами, см. `buildTitleFormatter`), * дату дня FullCalendar подставляет ещё и в подпись ячейки — она видна в * accessibility-дереве и как всплывающая подсказка. Формат этой подписи * библиотека наружу НЕ отдаёт: `buildNavLinkAttrs` (@fullcalendar/core) * форматирует дату жёстко зашитым `{year,month:'long',day:'numeric'}` и кладёт * результат в `aria-label` (в `title` — когда включены navLinks), а с локалью * `ru` Intl всегда даёт «20 июля 2026 г.». Настройкой это не убирается, * поэтому правим уже отрендеренный атрибут. * * Почему MutationObserver, а не хуки `dayCellDidMount`/`dayHeaderDidMount`: * при переключении недельного вида на месячный FullCalendar ПЕРЕИСПОЛЬЗУЕТ * уже смонтированные ячейки — `didMount` для них повторно не зовётся, а * подпись внутри перерисовывается заново, и «г.» возвращается (поймали на * живой проверке: после переключения вида ровно строка переиспользованных * ячеек оставалась с «г.»). Наблюдатель же ловит любую перерисовку. * Зацикливания нет: `stripYearSuffixAttrs` пишет атрибут, только если он * реально меняется, поэтому вызванная нашей же правкой итерация ничего не * пишет и цепочка обрывается. */ function useStripYearSuffix(rootRef: React.RefObject) { useEffect(() => { const root = rootRef.current if (!root) return const clean = () => root.querySelectorAll(DAY_LABEL_SELECTOR).forEach(stripYearSuffixAttrs) clean() const observer = new MutationObserver(clean) observer.observe(root, { subtree: true, childList: true, attributeFilter: ['aria-label', 'title'] }) return () => observer.disconnect() }, [rootRef]) } /** * Форма аргумента, который FullCalendar передаёт функции-`titleFormat` * (`VerboseFormattingArg` из @fullcalendar/core/internal-common — не * реэкспортируется из публичного `@fullcalendar/core`, поэтому типизируем * структурно сами, только нужные поля). */ interface TitleFormatArg { start: { marker: Date } end?: { marker: Date } } /** * Строит titleFormat-функцию FullCalendar на Intl.DateTimeFormat вместо * ruLocale-зависимого форматтера — тот всегда добавляет «г.» после года. * FullCalendar передаёт границы диапазона уже с учётом exclusivity (конец * периода минус 1мс, см. `dateEnv.formatRange` в @fullcalendar/core) — этого * достаточно, чтобы Intl.formatRange сам корректно схлопнул одинаковые * месяц/год и красиво отформатировал переход через месяц/год. */ function buildTitleFormatter(options: Intl.DateTimeFormatOptions) { const formatter = new Intl.DateTimeFormat('ru-RU', { ...options, timeZone: 'UTC' }) return (arg: TitleFormatArg): string => { const raw = arg.end ? formatter.formatRange(arg.start.marker, arg.end.marker) : formatter.format(arg.start.marker) return raw.replace(YEAR_SUFFIX_RE, '') } } const CALENDAR_VIEWS = { dayGridWeek: { titleFormat: buildTitleFormatter({ year: 'numeric', month: 'long', day: 'numeric' }) }, dayGridMonth: { titleFormat: buildTitleFormatter({ year: 'numeric', month: 'long' }) }, } /** * Обёртка над FullCalendar (см. design/mockups/calendar.html): недельная * (только будни, 5 колонок) / месячная сетка вхождений конференций, свой * тулбар (вид, навигация по датам) вместо встроенного headerToolbar. * События рендерятся как «чипы» (`.conf-chip`) — только название (+карандашик * у владельца), статус/время/номер — в информационном окне по клику * (`ConferenceOccurrenceDialog`) — вместо позиционирования по времени * (timeGrid), т.к. менеджер конференций показывает список вхождений по дням, * не занятость переговорной по часам (переговорных комнат в модели нет). */ export function ConferenceCalendar({ view, onViewChange, occurrences, isLoading, myConferenceById, onRangeChange, onOccurrenceClick, onEditClick, }: ConferenceCalendarProps) { const calendarRef = useRef(null) const titleRef = useRef(null) const gridRef = useRef(null) useStripYearSuffix(gridRef) // См. комментарий у `latestOnEditClick` в начале файла — синхронизируем // переменную вместо того, чтобы замыкать пропс напрямую в renderEventContent. useEffect(() => { latestOnEditClick = onEditClick }, [onEditClick]) // Ховер-карточка конференции — состояние держим внутри // компонента (не поднимаем в CalendarPage), чтобы колбэки FullCalendar ниже // оставались стабильными по identity между рендерами (см. комментарий в // начале файла про resetOptions/бесконечный цикл при нестабильных пропсах). const [hoverTarget, setHoverTarget] = useState<{ occurrence: OccurrenceOut; anchorEl: HTMLElement } | null>(null) const handleEventMouseEnter = useCallback((arg: EventHoveringArg) => { setHoverTarget({ occurrence: arg.event.extendedProps.occurrence as OccurrenceOut, anchorEl: arg.el }) }, []) const handleEventMouseLeave = useCallback((arg: EventHoveringArg) => { setHoverTarget((prev) => (prev && prev.anchorEl === arg.el ? null : prev)) }, []) // Клавиатурная доступность ховер-карточки: чипы FullCalendar рендерятся // самой библиотекой вне React-дерева, поэтому фокус отслеживаем нативными // слушателями focusin/focusout (не React onFocus/onBlur). const handleEventDidMount = useCallback((arg: EventMountArg) => { arg.el.tabIndex = 0 const occurrence = arg.event.extendedProps.occurrence as OccurrenceOut const handleFocusIn = () => setHoverTarget({ occurrence, anchorEl: arg.el }) const handleFocusOut = () => setHoverTarget((prev) => (prev && prev.anchorEl === arg.el ? null : prev)) arg.el.addEventListener('focusin', handleFocusIn) arg.el.addEventListener('focusout', handleFocusOut) eventFocusCleanups.set(arg.el, () => { arg.el.removeEventListener('focusin', handleFocusIn) arg.el.removeEventListener('focusout', handleFocusOut) }) }, []) const handleEventWillUnmount = useCallback((arg: EventMountArg) => { eventFocusCleanups.get(arg.el)?.() eventFocusCleanups.delete(arg.el) }, []) useEffect(() => { calendarRef.current?.getApi().changeView(view) }, [view]) function goPrev() { calendarRef.current?.getApi().prev() } function goNext() { calendarRef.current?.getApi().next() } function goToday() { calendarRef.current?.getApi().today() } // Мемоизировано по `occurrences`/`myConferenceById` — иначе на каждый рендер // создавался бы новый массив-`events` той же длины/содержимого, и // FullCalendar считал бы его «изменившейся опцией» (см. комментарий у // модульных констант выше). const events: EventInput[] = useMemo( () => occurrences.map((occ) => { const owned = myConferenceById.get(occ.conference_id) return { id: `${occ.conference_id}:${occ.starts_at}`, title: occ.title ?? 'Конференция без названия', start: occ.starts_at, end: occ.ends_at, classNames: ['conf-chip'], extendedProps: { occurrence: occ, ownedConference: owned?.is_owner ? owned : undefined }, } }), [occurrences, myConferenceById], ) const handleEventClick = useCallback( (info: EventClickArg) => { onOccurrenceClick(info.event.extendedProps.occurrence as OccurrenceOut) }, [onOccurrenceClick], ) const handleDatesSet = useCallback( (arg: DatesSetArg) => { const api = calendarRef.current?.getApi() if (api && titleRef.current) titleRef.current.textContent = api.view.title onRangeChange(arg.start.toISOString(), arg.end.toISOString()) }, [onRangeChange], ) return (
) }