Создание собственного календаря трудозатрат с React и Day.js

Создание собственного календаря трудозатрат с React и Day.js

1. Предыстория создания календаря численности персонала с нуля

В веб-сервисе календарь может выглядеть как простой интерфейс для выбора дат, но при его реализации необходимо учитывать несколько факторов: количество дней в каждом месяце, соответствие дат дням недели, пустые ячейки и размещение информации для каждой даты. В частности, в отличие от обычного календаря, который показывает только даты, в календаре численности персонала нужно отображать рабочую информацию, например количество сотрудников и суммы. Поэтому нам требовалась структура, отвечающая требованиям сервиса.

В этом проекте я с нуля реализовал календарь численности персонала, используемый в сервисе BanJang Note. Помимо обычных дат, он может показывать информацию для каждого дня. Я сосредоточился на разделении обязанностей компонентов, чтобы их можно было повторно использовать на разных экранах.

Одним из важных аспектов при реализации базовой структуры календаря было разделение вычисления дат и их визуального представления. Даже если даты вычисляются точно, календарь не будет работать должным образом, если они расположены не под теми днями недели. И наоборот, даже хорошо спроектированная структура экрана не поможет, если ошибки в вычислении дат приведут к пропуску дат или их отображению в неправильных позициях. Поэтому я учитывал и логику вычисления дат, и структуру размещения результатов на экране.

2. Компоненты React и структура данных дат

Сначала я разделил обязанности между CustomCalendar, который управляет календарём целиком; CalendarMonthGrid, который отображает даты одного месяца; и функцией buildWeeks, которая создаёт массив дат.

CustomCalendar управляет общим состоянием календаря и компоновкой экрана, а CalendarMonthGrid отображает дни недели и даты полученного месяца. buildWeeks вычисляет даты для указанного месяца и группирует их по неделям.

Такое разделение функциональности позволяет независимо проверять логику вычисления дат и структуру экрана. При изменении способа генерации дат сокращается объём необходимых изменений в других частях интерфейса, а при обновлении интерфейса ограничивается влияние на вычисление дат.

Для данных каждого дня я определил тип CalendarDayData.

export type CalendarDayData = {
  effort?: number;
  amount?: number;
  holidayName?: string;
  totalWorkers?: number;
  attendedWorkers?: number;
  memo?: string;
};

Я добавил ? к каждому свойству, поскольку доступная информация может различаться от даты к дате. Для некоторых дат может быть указано только количество сотрудников, а для других — название праздника или заметка. Поэтому структура позволяет передавать только необходимую информацию.

Данные для каждой даты хранятся в объекте, где ключами служат строки с датами.

type WorkdaysCalendarProps = {
  value: Dayjs | null;
  onChange: (date: Dayjs | null) => void;
  data?: Record<string, CalendarDayData>;
};

Record<string, CalendarDayData> — это тип, который связывает строковые ключи с объектами, содержащими информацию для каждой даты. Если использовать даты в качестве ключей, нужную информацию для определённого дня можно находить напрямую.

const calendarData: Record<string, CalendarDayData> = {
  '2026-10-01': {
    effort: 1,
  },
  '2026-10-02': {
    effort: 0.5,
    memo: '오전 근무',
  },
};

Чтобы использовать эту структуру, необходимо придерживаться единого формата ключей с датами. 2026-10-01 и 2026.10.01 — разные строки, поэтому при генерации дат и поиске данных нужно использовать один и тот же формат. В этой реализации я использовал format() из Day.js, чтобы привести даты к формату YYYY-MM-DD.

3. Вычисление дат с помощью Day.js

Одна из ключевых задач при реализации календаря — точно вычислить даты для заданного месяца. Количество дней в месяцах различается, а пустые ячейки в начале зависят от того, на какой день недели приходится первое число. Поэтому просто перечислить даты начиная с первого числа недостаточно для создания календарной сетки.

В этом проекте я использовал Day.js, чтобы упростить вычисления дат. Day.js — это библиотека JavaScript, предоставляющая функции для создания дат, определения количества дней в месяце, изменения дат, их сравнения и преобразования в строки.

Сначала я создал текущую дату с помощью dayjs() и использовал startOf(), чтобы получить начало заданной единицы времени.

const [visibleMonth, setVisibleMonth] = useState(value ?? dayjs());

const next = visibleMonth
  .add(delta, 'month')
  .startOf('month');

visibleMonth обозначает отображаемый в данный момент месяц. Если родительский компонент передаёт дату, календарь начинается с неё; в противном случае используется текущая дата.

При переходе между месяцами я прибавляю нужное количество месяцев с помощью add(), а затем использую startOf('month'), чтобы установить дату на первое число этого месяца. Например, если прибавить один месяц к 16 октября 2026 года, получится 16 ноября, но после применения startOf('month') дата будет установлена на 1 ноября. При управлении датами по месяцам сохранять число не нужно, поэтому такой подход задаёт понятную основу для переключения месяцев.

daysInMonth() возвращает количество дней в заданном месяце. Благодаря этому не нужно определять количество дней в каждом месяце с помощью условных операторов.

for (let day = 1; day <= month.daysInMonth(); day += 1) {
  days.push(month.startOf('month').date(day));
}

Цикл выполняется от первого до последнего дня месяца. date(day) — это метод, устанавливающий число месяца и создающий каждую дату относительно его первого дня. Даже если количество дней меняется в зависимости от года, как в случае с февралем, daysInMonth() возвращает фактическое количество дней, поэтому можно использовать одну и ту же логику.

Для определения дня недели я использую day(). Этот метод представляет воскресенье числом 0, понедельник — 1, а субботу — 6. Проверив день недели, на который приходится первое число месяца, можно вычислить, сколько пустых ячеек нужно добавить перед датами.

Важную роль играет и format(), который преобразует даты в строки.

const dateKey = date.format('YYYY-MM-DD');
const monthTitle = visibleMonth.format('YYYY년 M월');

Первый фрагмент кода формирует ключ для получения данных конкретной даты, а второй — заголовок месяца для отображения на экране. MM всегда представляет месяц двумя цифрами, а M — однозначные месяцы без ведущего нуля. Поэтому для ключа данных используется единый формат YYYY-MM-DD, а для заголовка экрана — более читаемый формат YYYY год M месяц.

Для сравнения дат я использовал isSame().

const isSelected = value?.isSame(date, 'day');
const isToday = date.isSame(dayjs(), 'day');

Если передать 'day' вторым аргументом в isSame(), сравниваются сами даты, а не время. Чтобы проверить, относятся ли две даты к одному месяцу, можно указать 'month'. Если выбирать единицу сравнения в соответствии с задачей, можно отдельно обрабатывать даты и месяцы.

Ещё одна особенность Day.js — неизменяемость объектов. add() и startOf() возвращают результат вычисления, не изменяя исходный объект даты напрямую. Благодаря этому можно сохранить исходную дату и отдельно работать с обновлённой. Такой подход помогает избежать случайного изменения исходного значения при вычислении дат и упрощает управление последовательностью изменений состояния React.

4. Группировка дат по неделям

После вычисления дат нужно расположить их в массиве так, чтобы их можно было отобразить в виде настоящего календаря. В этой реализации я использовал функцию buildWeeks, чтобы создать массив дат и разделить его на группы по семь элементов — по одной на каждую неделю.

const buildWeeks = (month: Dayjs) => {
  const days: (Dayjs | null)[] = Array.from(
    { length: month.startOf('month').day() },
    () => null,
  );

  for (let day = 1; day <= month.daysInMonth(); day += 1) {
    days.push(month.startOf('month').date(day));
  }

  return Array.from(
    { length: Math.ceil(days.length / 7) },
    (_, index) => days.slice(index * 7, index * 7 + 7),
  );
};

На первом этапе я создаю пустые ячейки, необходимые перед первым числом месяца. Я получаю день недели для первого числа с помощью startOf('month').day() и добавляю в массив столько значений null. Если первое число приходится на воскресенье, пустые ячейки не нужны; если на среду — в начале нужны три ячейки.

Здесь я использовал Array.from() для создания массива заданной длины. Позиции без дат обозначаются значением null, а тип массива определяется как (Dayjs | null)[], что указывает на возможность содержать как реальные даты, так и пустые ячейки.

На втором этапе в цикле добавляются даты в количестве, возвращённом daysInMonth(). Затем я делю длину всего массива на 7 и применяю Math.ceil(), чтобы вычислить, сколько недель потребуется. Для разделения массива на группы по семь элементов я использовал slice().

Этот процесс преобразует плоский массив дат в массив, сгруппированный по неделям. На экране внешний цикл может создавать каждую неделю, а внутренний — отображать ячейки с датами.

Однако в текущей реализации добавляются пустые ячейки перед первым числом месяца, но отдельно не создаются пустые ячейки для заполнения оставшихся позиций последней недели. В результате массив последней недели может содержать меньше семи элементов. Если структура каждой строки должна быть одинаковой, можно добавить пустые ячейки в конец массива дат или использовать CSS-компоновку, чтобы сохранять одинаковый размер строк и столбцов.

Как видно из этого примера, способ генерации массива дат также влияет на выравнивание на экране. Если в календаре возникает проблема с выравниванием, важно проверить не только CSS, но и то, правильно ли добавлены пустые ячейки в массив дат и сколько элементов содержит каждая неделя.

5. Компоновка календаря с помощью CSS Grid

После вычисления дат нужно отобразить полученные данные в виде настоящего календаря. В этой реализации я отделил область с днями недели от области с датами и расположил каждую неделю в отдельной строке.

<div className="custom-calendar-weekdays">
  {WEEKDAYS.map((weekday) => (
    <div key={weekday} className="custom-calendar-weekday">
      {weekday}
    </div>
  ))}
</div>

<div className="custom-calendar-grid">
  {buildWeeks(month).map((week, weekIdx) => (
    <div className="custom-calendar-week" key={weekIdx}>
      {week.map((date, idx) => {
        if (!date) {
          return (
            <div
              key={`empty-${weekIdx}-${idx}`}
              className="custom-calendar-day empty"
            />
          );
        }

        return (
          <div
            key={date.format('YYYY-MM-DD')}
            className="custom-calendar-day"
          >
            <div className="custom-calendar-day-number">
              {date.date()}
            </div>
          </div>
        );
      })}
    </div>
  ))}
</div>

Дни недели отображаются перебором массива WEEKDAYS, а область с датами — перебором массива, сгруппированного по неделям и возвращаемого buildWeeks(). Если структура массива данных соответствует структуре интерфейса, становится понятно, как результаты вычисления дат отображаются на экране.

Важно также отображать пустые ячейки как отдельные элементы. Если просто пропускать элементы без дат, реальные даты могут сдвинуться и оказаться под неправильными днями недели. Поэтому пустые ячейки должны занимать место в том же потоке компоновки, что и ячейки с датами, чтобы даты отображались в правильных позициях.

При создании календаря с помощью CSS Grid важно сохранять постоянную ширину семи столбцов. Ширина каждой ячейки с датой не должна меняться в зависимости от объёма информации, а расположение чисел и сведений о персонале должно оставаться неизменным.

Нужно также учитывать границы. Если задать границы со всех сторон каждой соседней ячейки с датой, они могут наложиться друг на друга и выглядеть толще. С другой стороны, удаление некоторых границ для решения этой проблемы может привести к отсутствию линий в последней строке или столбце. Поэтому нужно решить, отвечают ли за границы отдельные ячейки или вся область, и убедиться, что все элементы, включая пустые ячейки, оформлены по единым правилам.

Внешний вид последней строки календаря также может различаться в зависимости от структуры массива дат. Как отмечалось выше, если пустые ячейки не добавляются в последнюю неделю, её вид будет зависеть от того, как CSS размещает элементы. Поэтому для создания желаемой компоновки необходимо проверить не только CSS, но и способ генерации данных с датами.

Эта реализация показала, что в интерфейсе календаря трудно рассматривать генерацию данных и компоновку экрана как независимые задачи. Для стабильной компоновки календаря нужно учитывать одновременно точность вычисления дат, корректное представление пустых ячеек и соблюдение единых правил для каждой строки и каждого столбца.

6. Выводы по итогам реализации

Создавая календарь численности персонала с нуля, я понял, что календарь — это не просто интерфейс со списком дат. Для его работы должны взаимодействовать вычисления дат, структуры данных, проектирование компонентов и CSS-компоновка.

Day.js позволяет наглядно описать процесс определения начала месяца и количества дней в нём, проверки дней недели, создания дат и их преобразования в нужный формат. В частности, использование startOf(), daysInMonth(), day(), date(), format(), add() и isSame() для соответствующих задач помогло сократить объём условной логики для вычисления дат и сделать назначение кода более понятным.

С точки зрения поддержки проекта полезно также выделить логику вычисления дат в отдельную функцию и связать её с компонентами, формирующими интерфейс. Разделение генерации дат и визуального представления позволяет проверять каждую часть независимо и упрощает определение объёма изменений при добавлении новых функций.

Самое главное, я понял, что структура массива дат напрямую влияет на выравнивание на экране. Чтобы даты отображались под правильными днями недели, необходимо точно вычислять пустые ячейки перед первым числом и учитывать структуру последней строки. При устранении проблемы на экране нужно проверять способ генерации данных, а не только менять CSS.

Этот проект помог мне понять не только то, как создавать экран в соответствии с дизайном, но и принципы вычислений и структуры данных, необходимые для корректной работы интерфейса. Я понял, что для написания стабильного и удобного в сопровождении кода недостаточно просто использовать методы библиотеки: важно понимать, какие значения возвращает каждый метод и зачем он нужен в реализации.

KKAMJJING

Site footer