Синхронизация двух календарей

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

Типичный сценарий состоит из двух полей ввода:

  • календарь A → startDate
  • календарь B → endDate

Оба календаря создаются как независимые экземпляры Pikaday, но связываются через общую бизнес-логику.

Ключевой принцип заключается в том, что:

  • выбор startDate влияет на минимально допустимую дату endDate
  • выбор endDate влияет на максимально допустимую дату startDate

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

Инициализация двух связанных календарей

Базовая структура создаёт два объекта и связывает их через callbacks:

const startPicker = new Pikaday({
    field: document.getElementById('start'),
    onSelect: function(date) {
        endPicker.setMinDate(date);
    }
});

const endPicker = new Pikaday({
    field: document.getElementById('end'),
    onSelect: function(date) {
        startPicker.setMaxDate(date);
    }
});

В этой модели синхронизация строится на двух методах:

  • setMinDate(date) — ограничивает минимальную дату выбора
  • setMaxDate(date) — ограничивает максимальную дату выбора

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

Поддержание консистентности состояния

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

Расширенная логика:

const startPicker = new Pikaday({
    field: document.getElementById('start'),
    onSelect: function(date) {
        if (endPicker.getDate() && date > endPicker.getDate()) {
            endPicker.setDate(null);
        }
        endPicker.setMinDate(date);
    }
});

Здесь вводится дополнительная проверка:

  • если текущий endDate меньше нового startDate, он сбрасывается
  • минимальная граница всегда синхронизируется

Обратная синхронизация

Аналогичный принцип применяется для второго календаря:

const endPicker = new Pikaday({
    field: document.getElementById('end'),
    onSelect: function(date) {
        if (startPicker.getDate() && date < startPicker.getDate()) {
            startPicker.setDate(null);
        }
        startPicker.setMaxDate(date);
    }
});

Таким образом достигается двусторонняя связь, при которой оба календаря поддерживают валидный диапазон без конфликтов.

Использование общих правил ограничения диапазона

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

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

Эти ограничения комбинируются с синхронизацией:

const minAllowed = new Date();
const maxAllowed = new Date();
maxAllowed.setMonth(maxAllowed.getMonth() + 6);

const startPicker = new Pikaday({
    field: document.getElementById('start'),
    minDate: minAllowed,
    maxDate: maxAllowed,
    onSelect: function(date) {
        endPicker.setMinDate(date);
        enforceRangeLimit(date);
    }
});

Функция контроля диапазона:

function enforceRangeLimit(startDate) {
    const maxRangeDate = new Date(startDate);
    maxRangeDate.setDate(maxRangeDate.getDate() + 30);

    endPicker.setMaxDate(maxRangeDate);
}

Обновление UI без повторной инициализации

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

Используются методы:

  • setDate(date, triggerChange)
  • gotoDate(date)
  • setMinDate(date)
  • setMaxDate(date)

Пример централизованного обновления:

function syncCalendars(from, to, selectedDate, type) {
    if (type === 'start') {
        to.setMinDate(selectedDate);

        if (to.getDate() && to.getDate() < selectedDate) {
            to.setDate(null);
        }
    }

    if (type === 'end') {
        from.setMaxDate(selectedDate);

        if (from.getDate() && from.getDate() > selectedDate) {
            from.setDate(null);
        }
    }
}

Синхронизация через общий state-объект

При масштабировании интерфейса до сложных форм удобно вводить единое состояние:

const state = {
    startDate: null,
    endDate: null
};

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

const startPicker = new Pikaday({
    field: document.getElementById('start'),
    onSelect: function(date) {
        state.startDate = date;
        state.endDatePicker.setMinDate(date);

        if (state.endDate && state.endDate < date) {
            state.endDatePicker.setDate(null);
            state.endDate = null;
        }
    }
});

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

Предотвращение гонок состояния

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

let isSyncing = false;

const startPicker = new Pikaday({
    field: document.getElementById('start'),
    onSelect: function(date) {
        if (isSyncing) return;

        isSyncing = true;
        endPicker.setMinDate(date);

        if (endPicker.getDate() && endPicker.getDate() < date) {
            endPicker.setDate(null);
        }

        isSyncing = false;
    }
});

Это предотвращает рекурсивные вызовы синхронизации.

Динамическая пересборка диапазона

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

function updateAllowedRange(min, max) {
    startPicker.setMinDate(min);
    startPicker.setMaxDate(max);

    endPicker.setMinDate(min);
    endPicker.setMaxDate(max);

    if (startPicker.getDate() < min) startPicker.setDate(null);
    if (endPicker.getDate() > max) endPicker.setDate(null);
}

Взаимодействие с пользовательским вводом

Пользователь может вводить дату вручную в input, минуя календарь. В этом случае необходимо синхронизировать состояние и при изменении текста:

document.getElementById('start').addEventListener('change', function(e) {
    const date = new Date(e.target.value);
    startPicker.setDate(date);
    endPicker.setMinDate(date);
});

Такой подход обеспечивает единое поведение независимо от способа ввода.

Обновление визуального состояния календарей

Синхронизация касается не только логики, но и визуального отображения:

  • блокировка недоступных дат
  • подсветка диапазона
  • пересчёт отображаемого месяца при изменении границ

Для этого используется метод:

startPicker.draw();
endPicker.draw();

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

Управление крайними случаями

Особое внимание требуется при:

  • одинаковых датах start и end
  • сбросе одного из значений
  • переключении месяцев после изменения диапазона
  • переходе через границы допустимого периода

В таких случаях логика должна сохранять непротиворечивость:

if (startDate && endDate && startDate.getTime() === endDate.getTime()) {
    endPicker.setDate(null);
}

Согласованность состояния важнее сохранения каждого пользовательского действия без изменений.