Учёт праздников

В библиотеке js-joda отсутствует встроенное понятие календаря праздников, что соответствует философии неизменяемых, математически чистых типов даты и времени. Праздничные дни рассматриваются как внешнее правило, накладываемое поверх базовых типов LocalDate, ZonedDateTime и LocalDateTime. Это приводит к необходимости строить отдельный слой доменной модели, отвечающий за календарные исключения.

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

  • дата ∈ множество праздничных дней
  • дата ∈ множество рабочих дней (если не праздничная и не выходной)

Такое разделение позволяет строить расширяемые и локализуемые системы календарей.


Представление праздников как набора дат

Базовый способ хранения праздничных дней — множество LocalDate. Это обеспечивает O(1)-проверку принадлежности при использовании структуры Set.

import { LocalDate } from '@js-joda/core';

const holidays = new Set([
  LocalDate.of(2026, 1, 1),
  LocalDate.of(2026, 5, 9),
  LocalDate.of(2026, 12, 31),
]);

Ключевой принцип: все праздники нормализуются до LocalDate, без учёта времени и часового пояса.

Проверка праздничного дня:

function isHoliday(date, holidaySet) {
  return holidaySet.has(date);
}

Интеграция с выходными днями

Праздничный календарь почти всегда пересекается с выходными. В js-joda выходные вычисляются через день недели:

import { DayOfWeek } from '@js-joda/core';

function isWeekend(date) {
  const day = date.dayOfWeek();
  return day === DayOfWeek.SATURDAY || day === DayOfWeek.SUNDAY;
}

Композитное правило рабочего дня:

function isWorkingDay(date, holidays) {
  return !isWeekend(date) && !holidays.has(date);
}

Такой подход делает календарь независимым от региона и политики переноса праздников.


Генерация повторяющихся праздников

Многие праздничные дни повторяются ежегодно (например, 1 января или 9 мая). Вместо хранения конкретных дат удобно хранить шаблоны:

const recurringHolidays = [
  { month: 1, day: 1 },
  { month: 5, day: 9 },
  { month: 12, day: 31 },
];

Преобразование шаблонов в LocalDate для конкретного года:

import { LocalDate } from '@js-joda/core';

function buildHolidaysForYear(year, rules) {
  return new Set(
    rules.map(r => LocalDate.of(year, r.month, r.day))
  );
}

При необходимости поддержки диапазонов лет формируется объединённое множество:

function buildHolidayCalendar(startYear, endYear, rules) {
  const result = new Set();

  for (let y = startYear; y <= endYear; y++) {
    for (const r of rules) {
      result.add(LocalDate.of(y, r.month, r.day));
    }
  }

  return result;
}

Плавающие праздники и правила вычисления дат

Некоторые праздники определяются не фиксированной датой, а правилом (например, «последний понедельник мая»).

js-joda позволяет вычислять такие даты через перебор дней месяца.

Пример: последний понедельник мая

import { LocalDate, DayOfWeek } from '@js-joda/core';

function lastMondayOfMay(year) {
  let date = LocalDate.of(year, 5, 31);

  while (date.dayOfWeek() !== DayOfWeek.MONDAY) {
    date = date.minusDays(1);
  }

  return date;
}

Обобщённая функция для «последнего дня недели месяца»:

function lastWeekdayOfMonth(year, month, weekday) {
  let date = LocalDate.of(year, month, 1).plusMonths(1).minusDays(1);

  while (date.dayOfWeek() !== weekday) {
    date = date.minusDays(1);
  }

  return date;
}

Перенос праздничных дней (observed holidays)

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

Правило переноса может быть различным, но типовой вариант — перенос на следующий понедельник:

function shiftIfWeekend(date) {
  const day = date.dayOfWeek();

  if (day === DayOfWeek.SATURDAY) {
    return date.plusDays(2);
  }

  if (day === DayOfWeek.SUNDAY) {
    return date.plusDays(1);
  }

  return date;
}

Построение набора «фактических» праздников:

function applyObservedRules(holidayDates) {
  return new Set([...holidayDates].map(shiftIfWeekend));
}

Важно учитывать возможные коллизии, когда перенос приводит к совпадению с другим праздником; в таких случаях применяется бизнес-правило приоритета.


Вычисление рабочих дней в диапазоне дат

Одной из наиболее распространённых задач является подсчёт количества рабочих дней между двумя датами.

import { LocalDate } from '@js-joda/core';

function countWorkingDays(start, end, holidays) {
  let count = 0;
  let date = start;

  while (!date.isAfter(end)) {
    if (isWorkingDay(date, holidays)) {
      count++;
    }
    date = date.plusDays(1);
  }

  return count;
}

Такой алгоритм имеет линейную сложность O(n), где n — количество дней в интервале.

Для оптимизации в больших диапазонах используется предварительная агрегация по месяцам или кэширование.


Смещение даты на N рабочих дней

Часто требуется вычислить дату через заданное количество рабочих дней, игнорируя выходные и праздники.

function addWorkingDays(date, days, holidays) {
  let result = date;
  let remaining = days;

  while (remaining > 0) {
    result = result.plusDays(1);

    if (isWorkingDay(result, holidays)) {
      remaining--;
    }
  }

  return result;
}

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


Интеграция с ZonedDateTime и часовыми поясами

Праздничные правила всегда применяются на уровне календарной даты, а не времени. При работе с ZonedDateTime необходимо выделять локальную дату:

import { ZonedDateTime } from '@js-joda/core';

function isHolidayZoned(zonedDateTime, holidays) {
  const localDate = zonedDateTime.toLocalDate();
  return holidays.has(localDate);
}

Ключевое правило:

  • вычисление праздника всегда выполняется в локальной календарной системе
  • часовой пояс влияет только на преобразование момента времени в дату

Комбинированная модель календаря

Для сложных систем формируется единый объект календаря:

class BusinessCalendar {
  constructor(holidaySet) {
    this.holidays = holidaySet;
  }

  isWeekend(date) {
    const d = date.dayOfWeek();
    return d === DayOfWeek.SATURDAY || d === DayOfWeek.SUNDAY;
  }

  isHoliday(date) {
    return this.holidays.has(date);
  }

  isWorkingDay(date) {
    return !this.isWeekend(date) && !this.isHoliday(date);
  }

  nextWorkingDay(date) {
    let d = date.plusDays(1);
    while (!this.isWorkingDay(d)) {
      d = d.plusDays(1);
    }
    return d;
  }
}

Такой слой изолирует бизнес-логику от низкоуровневых операций над датами.


Кэширование и производительность календарей

При интенсивных вычислениях календаря применяется предварительное вычисление множества рабочих дней:

function buildWorkingDayIndex(start, end, holidays) {
  const index = new Set();
  let date = start;

  while (!date.isAfter(end)) {
    if (isWorkingDay(date, holidays)) {
      index.add(date);
    }
    date = date.plusDays(1);
  }

  return index;
}

Проверка принадлежности затем становится O(1), что критично для финансовых и планировочных систем.


Композиция нескольких календарей

В реальных системах часто существует несколько уровней праздников:

  • государственные
  • региональные
  • корпоративные

Композиция выполняется через объединение множеств:

function mergeCalendars(...calendars) {
  const result = new Set();

  for (const cal of calendars) {
    for (const date of cal) {
      result.add(date);
    }
  }

  return result;
}

Такой подход позволяет разделять источники правил без их смешивания на уровне логики вычислений.