Форматирование диапазонов дат

Понятие диапазона дат и его роль в локализации

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

Локализация диапазонов дат требует не только перевода формата отображения, но и учёта правил конкретной локали: порядка компонентов, разделителей, сокращений месяцев, правил опущения повторяющихся частей даты. FormatJS предоставляет инструменты, основанные на стандарте ECMAScript Internationalization API (Intl), для корректного форматирования таких интервалов.


Базовый механизм: Intl.DateTimeFormat.formatRange

Современный JavaScript включает встроенный метод форматирования диапазонов дат:

const formatter = new Intl.DateTimeFormat('ru-RU', {
  year: 'numeric',
  month: 'long',
  day: 'numeric'
});

formatter.formatRange(
  new Date(2026, 0, 1),
  new Date(2026, 0, 10)
);

Метод formatRange автоматически определяет, какие части дат можно опустить для компактного отображения. Например, если год одинаковый, он может быть показан один раз:

  • 1–10 января 2026 г.

Поведение зависит от локали и реализовано в движке JavaScript, а не в самой библиотеке FormatJS.


Роль FormatJS в форматировании диапазонов

FormatJS расширяет и нормализует работу с датами через набор пакетов, основными из которых являются:

  • @formatjs/intl-datetimeformat
  • react-intl
  • @formatjs/intl-utils

Основная задача FormatJS — обеспечить:

  • единообразное поведение в разных окружениях (включая старые браузеры)
  • полифиллы для отсутствующих методов (в том числе formatRange)
  • интеграцию с React через декларативный API

Использование formatDateTimeRange в react-intl

В экосистеме React основным инструментом является react-intl. Он предоставляет форматирование диапазонов через метод formatDateTimeRange.

Пример использования:

import { useIntl } from 'react-intl';

function DateRange() {
  const intl = useIntl();

  const range = intl.formatDateTimeRange(
    new Date(2026, 0, 1),
    new Date(2026, 0, 10),
    {
      year: 'numeric',
      month: 'long',
      day: 'numeric'
    }
  );

  return <span>{range}</span>;
}

Метод инкапсулирует вызовы Intl.DateTimeFormat.formatRange и добавляет поддержку fallback-логики для сред, где метод отсутствует.


Поведение форматирования в разных локалях

Форматирование диапазонов дат существенно зависит от локали.

Русская локаль

new Intl.DateTimeFormat('ru-RU', { month: 'short', day: 'numeric' })
  .formatRange(new Date(2026, 0, 1), new Date(2026, 0, 10));

Результат:

  • 1–10 янв.

Английская локаль (US)

new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' })
  .formatRange(new Date(2026, 0, 1), new Date(2026, 0, 10));

Результат:

  • Jan 1–10

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


Сокращение повторяющихся частей диапазона

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

Пример с одинаковым месяцем

new Intl.DateTimeFormat('en-GB', {
  year: 'numeric',
  month: 'long',
  day: 'numeric'
}).formatRange(
  new Date(2026, 0, 1),
  new Date(2026, 0, 10)
);

Результат:

  • 1–10 January 2026

Здесь месяц и год выводятся один раз, поскольку они совпадают.


Пример с разными месяцами

new Intl.DateTimeFormat('en-GB', {
  year: 'numeric',
  month: 'long',
  day: 'numeric'
}).formatRange(
  new Date(2026, 0, 28),
  new Date(2026, 1, 3)
);

Результат:

  • 28 January – 3 February 2026

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


Управление точностью отображения

FormatJS и Intl позволяют управлять степенью детализации через options:

  • year
  • month
  • day
  • hour
  • minute
  • second

Пример с временем

new Intl.DateTimeFormat('en-US', {
  year: 'numeric',
  month: 'short',
  day: 'numeric',
  hour: '2-digit',
  minute: '2-digit'
}).formatRange(
  new Date(2026, 0, 1, 10, 0),
  new Date(2026, 0, 1, 18, 30)
);

Результат:

  • Jan 1, 2026, 10:00 AM – 6:30 PM

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


Полифиллы FormatJS и поддержка formatRange

В старых средах метод formatRange может отсутствовать. FormatJS решает эту проблему через полифилл @formatjs/intl-datetimeformat.

Пример подключения:

import '@formatjs/intl-datetimeformat/polyfill';
import '@formatjs/intl-datetimeformat/locale-data/en';
import '@formatjs/intl-datetimeformat/locale-data/ru';

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


fallback-логика форматирования диапазонов

Если formatRange недоступен, библиотека может использовать стратегию:

  1. Форматировать обе даты отдельно
  2. Удалить дублирующиеся части вручную
  3. Объединить строки через локализованный разделитель

Упрощённая логика:

function fallbackRangeFormat(a, b, formatter) {
  return `${formatter.format(a)} – ${formatter.format(b)}`;
}

В реальных реализациях FormatJS используется более сложная система сравнения полей даты (year, month, day, hour и т.д.), чтобы минимизировать избыточность.


Особенности сравнения дат внутри диапазона

Форматирование диапазона зависит от различий между компонентами:

  • если отличаются только дни — показывается один месяц
  • если отличаются месяцы — показываются оба
  • если отличается год — каждый элемент получает годовую часть

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


Интеграция с MessageFormat и ICU-синтаксисом

FormatJS часто используется вместе с ICU MessageFormat, где диапазоны дат могут быть частью интернационализированных строк.

Пример:

import { defineMessages, useIntl } from 'react-intl';

const messages = defineMessages({
  event: {
    id: 'event.period',
    defaultMessage: 'Событие проходит с {start} по {end}'
  }
});
intl.formatMessage(messages.event, {
  start: intl.formatDateTimeRange(start, end)
});

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


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

Форматирование диапазонов включает:

  • создание экземпляров Intl форматтера
  • разбор компонент даты
  • сравнение полей дат
  • сборку результирующей строки

Оптимизации включают:

  • кэширование экземпляров Intl.DateTimeFormat
  • переиспользование форматтеров для одинаковых опций
  • минимизацию повторных вычислений при рендеринге UI

Ограничения и особенности поведения

Некоторые аспекты требуют учёта:

  • поведение formatRange не полностью идентично во всех движках JavaScript
  • различия возможны в Safari, Chrome и Node.js версиях
  • локали могут по-разному сокращать диапазоны
  • ручное форматирование может нарушить локализационную консистентность

FormatJS сглаживает часть этих различий, но не устраняет фундаментальные различия ICU-реализаций.


Работа с частичными диапазонами

В некоторых случаях диапазон может содержать неполные даты (например, только месяц и год). Тогда форматтер адаптируется к доступной точности:

new Intl.DateTimeFormat('ru-RU', { month: 'long', year: 'numeric' })
  .formatRange(
    new Date(2026, 0, 1),
    new Date(2026, 5, 1)
  );

Результат:

  • январь – июнь 2026 г.

Уровень детализации автоматически снижается до месяца.


Поведение при одинаковых датах

Если начальная и конечная даты совпадают:

new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
  .formatRange(date, date);

Результат:

  • Jan 1, 2026

Диапазон схлопывается до единственного значения, что предотвращает избыточное отображение.


Сравнение formatRange и ручной конкатенации

Ручное формирование диапазона:

`${start.toLocaleDateString()} - ${end.toLocaleDateString()}`

Проблемы такого подхода:

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

Использование formatRange устраняет эти проблемы за счёт стандартизированного алгоритма ICU.