Проблемы с високосными годами

Работа с датами в JavaScript традиционно сопровождается множеством скрытых проблем. Одна из самых опасных категорий ошибок связана с високосными годами. Неправильная обработка 29 февраля приводит к сбоям в расчётах периодов, возраста, дедлайнов, подписок, финансовых отчётов и расписаний.

Библиотека js-joda реализует API, вдохновлённый Java Time API из Java 8, и предоставляет строгую, предсказуемую и безопасную модель работы с календарными датами.


Что такое високосный год

В григорианском календаре:

  • год является високосным, если делится на 4;
  • годы, делящиеся на 100, не являются високосными;
  • годы, делящиеся на 400, снова являются високосными.

Примеры:

Год Високосный
2024 Да
2025 Нет
1900 Нет
2000 Да

В стандартном Date JavaScript подобные детали часто скрыты внутри встроенной реализации браузера или движка Node.js. В js-joda календарные вычисления работают строго и прозрачно.


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

Для работы с календарными правилами используется класс Year.

const { Year } = require('@js-joda/core');

console.log(Year.of(2024).isLeap()); // true
console.log(Year.of(2025).isLeap()); // false
console.log(Year.of(1900).isLeap()); // false
console.log(Year.of(2000).isLeap()); // true

Метод isLeap() реализует все правила григорианского календаря.


Создание даты 29 февраля

Если год високосный, дата создаётся корректно:

const { LocalDate } = require('@js-joda/core');

const date = LocalDate.of(2024, 2, 29);

console.log(date.toString());

Результат:

2024-02-29

Ошибка при создании несуществующей даты

Попытка создать 29 февраля в невисокосном году вызывает исключение.

const { LocalDate } = require('@js-joda/core');

const date = LocalDate.of(2023, 2, 29);

Ошибка:

DateTimeException: Invalid date 'February 29' as '2023' is not a leap year

Это важное отличие от встроенного Date, который может автоматически “перепрыгнуть” на март и скрыть проблему.


Почему автоматическая коррекция опасна

Стандартный Jav * aScript:

const date = new Date(2023, 1, 29);

console.log(date);

Результат:

Wed Mar 01 2023

Ошибка остаётся незамеченной.

js-joda использует строгую модель:

  • несуществующая дата запрещена;
  • вычисление сразу прерывается;
  • проблема обнаруживается на раннем этапе.

Добавление года к 29 февраля

Одна из самых сложных ситуаций — перенос даты на следующий год.

const { LocalDate } = require('@js-joda/core');

const date = LocalDate.of(2024, 2, 29);

const nextYear = date.plusYears(1);

console.log(nextYear.toString());

Результат:

2025-02-28

js-joda автоматически корректирует дату до последнего допустимого дня месяца.


Поведение plusYears()

Правила работы метода:

Исходная дата Результат
2024-02-29 + 1 год 2025-02-28
2020-02-29 + 4 года 2024-02-29
2024-02-29 - 1 год 2023-02-28

Пример:

const leap = LocalDate.of(2020, 2, 29);

console.log(leap.plusYears(4).toString());
console.log(leap.plusYears(1).toString());
console.log(leap.minusYears(1).toString());

Добавление месяцев и високосные годы

Метод plusMonths() также учитывает календарные ограничения.

const date = LocalDate.of(2024, 1, 31);

console.log(date.plusMonths(1).toString());

Результат:

2024-02-29

Для невисокосного года:

const date = LocalDate.of(2023, 1, 31);

console.log(date.plusMonths(1).toString());

Результат:

2023-02-28

Последний день февраля

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

const { YearMonth } = require('@js-joda/core');

console.log(YearMonth.of(2024, 2).lengthOfMonth());
console.log(YearMonth.of(2023, 2).lengthOfMonth());

Результат:

29
28

Количество дней в году

const { Year } = require('@js-joda/core');

console.log(Year.of(2024).length());
console.log(Year.of(2023).length());

Результат:

366
365

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

Иногда дата приходит извне:

  • из формы;
  • из API;
  • из CSV;
  • из базы данных.

Безопасная проверка:

const { LocalDate } = require('@js-joda/core');

function isValidDate(year, month, day) {
    try {
        LocalDate.of(year, month, day);
        return true;
    } catch {
        return false;
    }
}

console.log(isValidDate(2024, 2, 29));
console.log(isValidDate(2023, 2, 29));

Расчёт возраста людей, родившихся 29 февраля

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

const { LocalDate, Period } = require('@js-joda/core');

const birthDate = LocalDate.of(2000, 2, 29);
const currentDate = LocalDate.of(2025, 2, 28);

const age = Period.between(birthDate, currentDate);

console.log(age.years());

Результат:

24

На следующий день:

const currentDate = LocalDate.of(2025, 3, 1);

Возраст станет:

25

Високосные годы и интервалы времени

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

Ошибка:

const days = 365 * 5;

Проблема:

  • некоторые годы содержат 366 дней;
  • интервалы становятся неточными;
  • возникают накопительные ошибки.

Правильный подход:

const start = LocalDate.of(2020, 1, 1);
const end = start.plusYears(5);

console.log(end.toString());

Разница между Period и Duration

Високосные годы особенно хорошо демонстрируют различие между календарными и временными интервалами.

Period

Работает с:

  • годами;
  • месяцами;
  • днями.

Учитывает календарь.

const period = Period.ofYears(1);

Duration

Работает с:

  • секундами;
  • миллисекундами;
  • наносекундами.

Не учитывает календарные особенности.

const { Duration } = require('@js-joda/core');

const duration = Duration.ofDays(365);

Ошибка использования Duration вместо Period

const {
    LocalDateTime,
    Duration
} = require('@js-joda/core');

const date = LocalDateTime.parse('2024-02-29T10:00');

const result = date.plus(Duration.ofDays(365));

console.log(result.toString());

Результат:

2025-02-28T10:00

Но:

console.log(
    date.plusYears(1).toString()
);

Результат:

2025-02-28T10:00

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


Ошибки при ручной проверке високосности

Неверная реализация:

function isLeap(year) {
    return year % 4 === 0;
}

Ошибка:

console.log(isLeap(1900));

Результат:

true

Правильная реализация:

function isLeap(year) {
    return (
        (year % 4 === 0 && year % 100 !== 0) ||
        year % 400 === 0
    );
}

Однако в js-joda нет необходимости писать такую логику вручную.


Использование TemporalAdjusters

Частая задача — получить последний день февраля независимо от года.

const {
    LocalDate,
    TemporalAdjusters
} = require('@js-joda/core');

const date = LocalDate.of(2024, 2, 10);

const lastDay = date.with(
    TemporalAdjusters.lastDayOfMonth()
);

console.log(lastDay.toString());

Результат:

2024-02-29

Для 2023:

2023-02-28

Високосные годы и сортировка дат

Поскольку LocalDate является immutable-объектом и использует строгую календарную модель, сортировка выполняется корректно.

const dates = [
    LocalDate.of(2024, 2, 29),
    LocalDate.of(2023, 2, 28),
    LocalDate.of(2025, 1, 1)
];

dates.sort((a, b) => a.compareTo(b));

console.log(dates);

Проверка количества дней между датами

const {
    LocalDate,
    ChronoUnit
} = require('@js-joda/core');

const start = LocalDate.of(2024, 1, 1);
const end = LocalDate.of(2025, 1, 1);

const days = ChronoUnit.DAYS.between(start, end);

console.log(days);

Результат:

366

Для невисокосного года:

365

Работа с годовыми подписками

Проблемная логика:

const expiration = created.plusDays(365);

Ошибка:

  • подписка может закончиться раньше;
  • появляется смещение дат;
  • возможны финансовые ошибки.

Правильный подход:

const expiration = created.plusYears(1);

Високосные годы и расписания

Проблемы часто возникают в системах:

  • cron;
  • банковских операций;
  • медицинских записей;
  • авиабронирования;
  • страхования.

Пример:

const paymentDate = LocalDate.of(2024, 2, 29);

console.log(
    paymentDate.plusMonths(1).toString()
);

Результат:

2024-03-29

Но:

console.log(
    paymentDate.plusYears(1).toString()
);

Результат:

2025-02-28

Immutable-подход и безопасность вычислений

Все объекты js-joda неизменяемы.

const original = LocalDate.of(2024, 2, 29);

const modified = original.plusYears(1);

console.log(original.toString());
console.log(modified.toString());

Результат:

2024-02-29
2025-02-28

Исходный объект не изменяется.


Практические рекомендации

Использовать LocalDate для календарных дат

const date = LocalDate.of(2024, 2, 29);

Не хранить годы как фиксированное количество дней

Плохо:

365 * years

Хорошо:

date.plusYears(years)

Не использовать встроенный Date для сложной бизнес-логики

Причины:

  • автоматическая коррекция дат;
  • timezone-эффекты;
  • мутабельность;
  • непредсказуемые преобразования.

Всегда использовать API библиотеки для календарных вычислений

Плохо:

year % 4 === 0

Хорошо:

Year.of(year).isLeap()

Разделять календарные и временные интервалы

Тип Назначение
Period Годы, месяцы, дни
Duration Секунды и время

Типичные ошибки при работе с високосными годами

Ошибка Последствие
plusDays(365) вместо plusYears(1) Смещение даты
Ручная проверка високосности Логические ошибки
Использование Date Скрытая коррекция
Игнорирование 29 февраля Сбой расчётов
Хранение года как 365 дней Накопление ошибки
Использование timestamp для календарных вычислений Потеря точности

Пример безопасной системы продления подписки

const {
    LocalDate
} = require('@js-joda/core');

function renewSubscription(startDate) {
    return startDate.plusYears(1);
}

const created = LocalDate.of(2024, 2, 29);

const renewed = renewSubscription(created);

console.log(renewed.toString());

Результат:

2025-02-28

Сравнение подходов

Задача Date js-joda
Создание 2023-02-29 Автокоррекция Исключение
Проверка високосности Вручную isLeap()
Immutable API Нет Да
Календарная арифметика Нестабильна Предсказуема
Работа с периодами Ограничена Полноценная
Безопасность бизнес-логики Низкая Высокая