Нормализация периодов

Класс Period в библиотеке js-joda хранит период в виде трёх независимых компонентов:

  • годы (years)
  • месяцы (months)
  • дни (days)

В отличие от длительности (Duration), период работает с календарными величинами, а не с точным количеством секунд или миллисекунд.

Период может содержать значения, выходящие за привычные диапазоны:

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

const period = Period.of(1, 15, 40)

console.log(period.toString())
// P1Y15M40D

Здесь:

  • 15M — это 15 месяцев
  • 40D — это 40 дней

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


Метод normalized()

Для нормализации используется метод normalized().

Он преобразует месяцы в годы, если количество месяцев выходит за диапазон 0–11.

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

const period = Period.of(1, 15, 10)

const normalized = period.normalized()

console.log(normalized.toString())
// P2Y3M10D

Результат:

Исходное значение После нормализации
1 год 2 года
15 месяцев 3 месяца
10 дней 10 дней

Как работает нормализация

Алгоритм крайне простой:

общие месяцы = years * 12 + months
новые годы = общие месяцы / 12
новые месяцы = остаток от деления на 12

Дни при этом не изменяются.


Нормализация больших значений месяцев

Пример с положительными значениями

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

const period = Period.of(0, 28, 5)

console.log(period.normalized().toString())
// P2Y4M5D

Разбор:

28 месяцев = 2 года и 4 месяца

Пример с отрицательными месяцами

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

const period = Period.of(3, -15, 0)

console.log(period.normalized().toString())
// P1Y9M

Расчёт:

3 года = 36 месяцев
36 - 15 = 21 месяц
21 месяц = 1 год 9 месяцев

Нормализация не затрагивает дни

Метод normalized() работает только с комбинацией:

  • годы
  • месяцы

Поле дней не преобразуется.

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

const period = Period.of(0, 0, 75)

console.log(period.normalized().toString())
// P75D

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

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

  • 28 дней
  • 29 дней
  • 30 дней
  • 31 день

Автоматически определить эквивалентное количество месяцев невозможно без конкретной даты.


Почему дни не переводятся в месяцы

Рассмотрим пример:

30 дней

Это может означать:

  • февраль
  • апрель
  • июнь
  • любой другой месяц

Результат зависит от контекста календаря.

Например:

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

const date = LocalDate.parse('2025-01-31')

console.log(date.plusDays(30).toString())
// 2025-03-02

Тридцать дней после 31 января — это уже март, а не конец февраля.

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


Нормализация отрицательных периодов

Метод корректно работает и с полностью отрицательными значениями.

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

const period = Period.of(-1, -14, 0)

console.log(period.normalized().toString())
// P-2Y-2M

Расчёт:

-1 год = -12 месяцев
-12 - 14 = -26 месяцев
-26 месяцев = -2 года -2 месяца

Особенности смешанных знаков

Период может содержать компоненты с разными знаками:

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

const period = Period.of(1, -25, 0)

console.log(period.normalized().toString())
// P-1Y-1M

Разбор:

1 год = 12 месяцев
12 - 25 = -13 месяцев
-13 месяцев = -1 год -1 месяц

Это важная особенность: после нормализации знак периода может измениться.


Нормализация и неизменяемость объектов

Period является immutable-типом.

Метод normalized() не изменяет исходный объект.

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

const original = Period.of(1, 15, 0)

const normalized = original.normalized()

console.log(original.toString())
// P1Y15M

console.log(normalized.toString())
// P2Y3M

Исходный объект остаётся прежним.


Когда нормализация особенно полезна

Сериализация данных

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

const period = Period.of(0, 36, 0)

console.log(period.normalized().toString())
// P3Y

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

Без нормализации одинаковые интервалы могут выглядеть по-разному.

const p1 = Period.of(1, 12, 0)
const p2 = Period.of(2, 0, 0)

Логически это одинаковые значения:

24 месяца

После нормализации:

console.log(p1.normalized().toString())
// P2Y

console.log(p2.normalized().toString())
// P2Y

Формирование пользовательского вывода

Нормализованные периоды легче воспринимаются человеком.

Сравнение:

P5Y27M

и

P7Y3M

Второй вариант значительно понятнее.


Нормализация после арифметических операций

После сложения или вычитания периодов часто появляются избыточные месяцы.

Пример сложения

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

const p1 = Period.of(1, 8, 0)
const p2 = Period.of(0, 9, 0)

const result = p1.plus(p2)

console.log(result.toString())
// P1Y17M

console.log(result.normalized().toString())
// P2Y5M

Пример вычитания

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

const p1 = Period.of(5, 2, 0)
const p2 = Period.of(1, 8, 0)

const result = p1.minus(p2)

console.log(result.toString())
// P4Y-6M

console.log(result.normalized().toString())
// P3Y6M

Отличие normalized() от ручного пересчёта

Иногда выполняют пересчёт вручную:

const totalMonths = years * 12 + months

Однако normalized():

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

Нормализация и ISO-8601

Строковое представление периода соответствует стандарту ISO-8601.

До нормализации:

P1Y15M

После нормализации:

P2Y3M

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

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


Поведение при нулевых значениях

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

const period = Period.of(0, 0, 0)

console.log(period.normalized().toString())
// P0D

Нулевой период остаётся неизменным.


Работа с очень большими значениями

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

const period = Period.of(0, 250, 0)

console.log(period.normalized().toString())
// P20Y10M

Нормализация особенно полезна при накопительных расчётах.


Практический пример: возраст подписки

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

const subscription = Period.of(2, 18, 0)

console.log(subscription.toString())
// P2Y18M

console.log(subscription.normalized().toString())
// P3Y6M

Нормализованное представление значительно лучше подходит для отображения в интерфейсе.


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

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

const quarterly = Period.ofMonths(3)

let total = Period.ZERO

for (let i = 0; i < 10; i++) {
    total = total.plus(quarterly)
}

console.log(total.toString())
// P30M

console.log(total.normalized().toString())
// P2Y6M

Без нормализации значение постепенно становится менее читаемым.


Ограничения метода normalized()

Метод не выполняет:

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

Для подобных задач требуется работа с LocalDate.


Связь нормализации с LocalDate

Период может вести себя по-разному в зависимости от даты применения.

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

const period = Period.ofMonths(1)

const d1 = LocalDate.parse('2025-01-31')
const d2 = LocalDate.parse('2025-02-01')

console.log(d1.plus(period).toString())
// 2025-02-28

console.log(d2.plus(period).toString())
// 2025-03-01

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


Проверка необходимости нормализации

Иногда полезно определить, содержит ли период избыточные месяцы.

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

const period = Period.of(0, 18, 0)

const needsNormalization =
    Math.abs(period.months()) >= 12

console.log(needsNormalization)
// true

Рекомендации по использованию

Нормализовать после арифметики

const result =
    p1.plus(p2).normalized()

Нормализовать перед выводом

console.log(period.normalized().toString())

Не использовать нормализацию для календарной логики

Неправильно:

75 дней ≈ 2 месяца

Правильно:

  • использовать LocalDate
  • выполнять реальные календарные вычисления

Типичные ошибки

Ожидание преобразования дней

Неправильно:

Period.ofDays(40).normalized()

Ожидание:

P1M10D

Фактический результат:

P40D

Попытка сравнивать ненормализованные периоды

Period.of(1, 12, 0)
Period.of(2, 0, 0)

Строки отличаются, хотя интервалы эквивалентны.


Предположение, что normalized() меняет объект

Неправильно:

period.normalized()

console.log(period)

Метод возвращает новый объект.


Внутреннее представление периода

Внутри Period хранит:

years
months
days

Нормализация не создаёт новую модель данных и не переводит период в абсолютную величину времени.

Это исключительно преобразование структуры хранения месяцев и лет.


Сравнение с Duration

Duration нормализуется иначе.

Для Duration:

90 секунд = 1 минута 30 секунд

Для Period:

40 дней != 1 месяц 10 дней

Причина — различная природа календарного и точного времени.


Итоговая схема поведения normalized()

Компонент Обрабатывается
Годы Да
Месяцы Да
Дни Нет
Часы Нет
Минуты Нет
Секунды Нет

Основная формула нормализации

=years+months


Формула пересчёта обратно

years=,months=totalMonths