Как форматировать диапазоны дат, например 1–5 янв

Используйте JavaScript для отображения диапазонов дат с учетом локали и умного удаления повторяющихся элементов

Введение

Диапазоны дат встречаются во многих веб-приложениях. Системы бронирования показывают доступность с 1 по 5 января, календари событий отображают многодневные конференции, аналитические панели показывают данные за определённые периоды, а отчёты охватывают финансовые кварталы или произвольные интервалы. Такие диапазоны сообщают, что что-то относится ко всем датам между двумя границами.

Если форматировать диапазоны дат вручную, просто соединяя две даты через дефис, результат получается излишне громоздким. Диапазон с 1 января 2024 по 5 января 2024 не требует повторения месяца и года во второй дате. Запись «1–5 января 2024» передаёт ту же информацию, но короче, убирая лишние детали.

В JavaScript есть метод formatRange() у Intl.DateTimeFormat, который автоматически форматирует диапазоны дат. Этот метод сам определяет, какие компоненты даты нужно показать в каждой части диапазона, применяет локальные правила для разделителей и форматирования, а также убирает ненужные повторы.

Почему для диапазонов дат важно умное форматирование

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

Если диапазон в пределах одного дня, достаточно показать только разницу во времени: «10:00–14:00». Повторять полную дату для обоих времён не имеет смысла.

Если диапазон дат находится в пределах одного месяца, указывайте месяц только один раз и перечисляйте оба числа: «1–5 января 2024». Повторять «январь» дважды не стоит — это усложняет восприятие и не добавляет ясности.

Если диапазон охватывает разные месяцы в одном году, указывайте оба месяца, но год можно опустить у первой даты: «25 декабря 2024 – 2 января 2025» требует полного указания, а «15 января – 20 февраля 2024» в некоторых локалях допускает опущение года у первой даты.

Если диапазон охватывает несколько лет, обязательно указывайте год для обеих дат: «1 декабря 2023 – 15 марта 2024».

Ручная реализация этих правил требует проверки, какие компоненты дат различаются, и соответствующего построения формата. В разных локалях эти правила применяются по-разному: используются разные разделители и порядок элементов. Метод formatRange() инкапсулирует эту логику.

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

Метод formatRange() принимает два объекта Date и возвращает отформатированную строку. Создайте экземпляр Intl.DateTimeFormat с нужной локалью и опциями, затем вызовите formatRange() с начальной и конечной датой.

const formatter = new Intl.DateTimeFormat("en-US");

const start = new Date(2024, 0, 1);
const end = new Date(2024, 0, 5);

console.log(formatter.formatRange(start, end));
// Output: "1/1/24 – 1/5/24"

Форматтер применяет стандартный американский формат дат к обеим датам и соединяет их длинным тире. Для дат в одном месяце выводятся обе полные даты, так как формат по умолчанию довольно короткий.

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

const formatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

const start = new Date(2024, 0, 1);
const end = new Date(2024, 0, 5);

console.log(formatter.formatRange(start, end));
// Output: "January 1 – 5, 2024"

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

Как formatRange оптимизирует вывод диапазона дат

Метод formatRange() анализирует обе даты и определяет, какие компоненты отличаются. В каждой части диапазона он включает только необходимые элементы.

Если даты находятся в одном месяце и году, во второй части указывается только конечный день.

const formatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

console.log(formatter.formatRange(
  new Date(2024, 0, 1),
  new Date(2024, 0, 5)
));
// Output: "January 1 – 5, 2024"

console.log(formatter.formatRange(
  new Date(2024, 0, 15),
  new Date(2024, 0, 20)
));
// Output: "January 15 – 20, 2024"

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

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

const formatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

console.log(formatter.formatRange(
  new Date(2024, 0, 15),
  new Date(2024, 1, 20)
));
// Output: "January 15 – February 20, 2024"

Форматтер включает оба названия месяца, но год ставит в конце, распространяя его на весь диапазон.

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

const formatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

console.log(formatter.formatRange(
  new Date(2023, 11, 25),
  new Date(2024, 0, 5)
));
// Output: "December 25, 2023 – January 5, 2024"

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

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

Если в вашем формате есть компоненты времени, метод formatRange() применяет такое же умное опущение к полям времени.

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

const formatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric",
  hour: "numeric",
  minute: "numeric"
});

const start = new Date(2024, 0, 1, 10, 0);
const end = new Date(2024, 0, 1, 14, 30);

console.log(formatter.formatRange(start, end));
// Output: "January 1, 2024, 10:00 AM – 2:30 PM"

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

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

const formatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric",
  hour: "numeric",
  minute: "numeric"
});

const start = new Date(2024, 0, 1, 10, 0);
const end = new Date(2024, 0, 2, 14, 30);

console.log(formatter.formatRange(start, end));
// Output: "January 1, 2024, 10:00 AM – January 2, 2024, 2:30 PM"

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

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

Форматирование диапазонов адаптируется к правилам каждой локали: порядку компонентов даты, разделителям и тому, какие элементы можно опустить.

const enFormatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

console.log(enFormatter.formatRange(
  new Date(2024, 0, 1),
  new Date(2024, 0, 5)
));
// Output: "January 1 – 5, 2024"

const deFormatter = new Intl.DateTimeFormat("de-DE", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

console.log(deFormatter.formatRange(
  new Date(2024, 0, 1),
  new Date(2024, 0, 5)
));
// Output: "1.–5. Januar 2024"

const jaFormatter = new Intl.DateTimeFormat("ja-JP", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

console.log(jaFormatter.formatRange(
  new Date(2024, 0, 1),
  new Date(2024, 0, 5)
));
// Output: "2024年1月1日~5日"

В английском сначала указывается месяц, а год — в конце. В немецком сначала идут числа дней с точками, затем название месяца и год. В японском используется порядок год-месяц-день и тильда (~) как разделитель диапазона. В каждом языке свои правила, какие элементы показывать один раз, а какие — дважды.

Эти различия распространяются и на диапазоны, охватывающие разные месяцы.

const enFormatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

console.log(enFormatter.formatRange(
  new Date(2024, 0, 15),
  new Date(2024, 1, 20)
));
// Output: "January 15 – February 20, 2024"

const deFormatter = new Intl.DateTimeFormat("de-DE", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

console.log(deFormatter.formatRange(
  new Date(2024, 0, 15),
  new Date(2024, 1, 20)
));
// Output: "15. Januar – 20. Februar 2024"

const frFormatter = new Intl.DateTimeFormat("fr-FR", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

console.log(frFormatter.formatRange(
  new Date(2024, 0, 15),
  new Date(2024, 1, 20)
));
// Output: "15 janvier – 20 février 2024"

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

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

Можно использовать опцию dateStyle, чтобы управлять длиной формата, как и при форматировании одной даты.

const shortFormatter = new Intl.DateTimeFormat("en-US", {
  dateStyle: "short"
});

console.log(shortFormatter.formatRange(
  new Date(2024, 0, 1),
  new Date(2024, 0, 5)
));
// Output: "1/1/24 – 1/5/24"

const mediumFormatter = new Intl.DateTimeFormat("en-US", {
  dateStyle: "medium"
});

console.log(mediumFormatter.formatRange(
  new Date(2024, 0, 1),
  new Date(2024, 0, 5)
));
// Output: "Jan 1 – 5, 2024"

const longFormatter = new Intl.DateTimeFormat("en-US", {
  dateStyle: "long"
});

console.log(longFormatter.formatRange(
  new Date(2024, 0, 1),
  new Date(2024, 0, 5)
));
// Output: "January 1 – 5, 2024"

const fullFormatter = new Intl.DateTimeFormat("en-US", {
  dateStyle: "full"
});

console.log(fullFormatter.formatRange(
  new Date(2024, 0, 1),
  new Date(2024, 0, 5)
));
// Output: "Monday, January 1 – Friday, January 5, 2024"

Стиль short выдаёт числовые даты и не применяет интеллектуальное опущение, так как формат уже компактный. Стили medium и long сокращают или полностью пишут название месяца и убирают лишние элементы. Стиль full добавляет названия дней недели для обеих дат.

Использование formatRangeToParts для кастомного оформления

Метод formatRangeToParts() возвращает массив объектов, представляющих компоненты отформатированного диапазона. Это позволяет стилизовать или изменять отдельные части результата.

const formatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

const parts = formatter.formatRangeToParts(
  new Date(2024, 0, 1),
  new Date(2024, 0, 5)
);

console.log(parts);

Результат — это массив объектов, у каждого есть свойства type, value и source.

[
  { type: "month", value: "January", source: "startRange" },
  { type: "literal", value: " ", source: "startRange" },
  { type: "day", value: "1", source: "startRange" },
  { type: "literal", value: " – ", source: "shared" },
  { type: "day", value: "5", source: "endRange" },
  { type: "literal", value: ", ", source: "shared" },
  { type: "year", value: "2024", source: "shared" }
]

Свойство type определяет компонент: месяц, день, год или текст. Свойство value содержит отформатированный текст. Свойство source показывает, относится ли компонент к начальной дате, конечной или общий для обеих.

Эти части можно использовать для создания кастомного HTML с разным стилем для разных компонентов.

const formatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

const parts = formatter.formatRangeToParts(
  new Date(2024, 0, 1),
  new Date(2024, 0, 5)
);

let html = "";

parts.forEach(part => {
  if (part.type === "month") {
    html += `<span class="month">${part.value}</span>`;
  } else if (part.type === "day") {
    html += `<span class="day">${part.value}</span>`;
  } else if (part.type === "year") {
    html += `<span class="year">${part.value}</span>`;
  } else if (part.type === "literal" && part.source === "shared" && part.value.includes("–")) {
    html += `<span class="separator">${part.value}</span>`;
  } else {
    html += part.value;
  }
});

console.log(html);
// Output: <span class="month">January</span> <span class="day">1</span><span class="separator"> – </span><span class="day">5</span>, <span class="year">2024</span>

Этот подход сохраняет форматирование, специфичное для локали, и при этом позволяет задавать свой визуальный стиль.

Что происходит, если даты совпадают

Если передать одинаковую дату в параметры начала и конца, formatRange() выведет одну отформатированную дату вместо диапазона.

const formatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

const date = new Date(2024, 0, 1);

console.log(formatter.formatRange(date, date));
// Output: "January 1, 2024"

Форматтер понимает, что диапазон с одинаковыми границами — это не настоящий диапазон, и форматирует как одну дату. Такое поведение сохраняется даже если объекты Date разные, но значения у них одинаковые.

const formatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric"
});

const start = new Date(2024, 0, 1, 10, 0);
const end = new Date(2024, 0, 1, 10, 0);

console.log(formatter.formatRange(start, end));
// Output: "January 1, 2024"

Хотя это разные объекты Date, они представляют одну и ту же дату и время. Форматтер выводит одну дату, потому что у диапазона нулевая длительность на уровне точности выбранного формата. Если формат не включает время, то время не учитывается, и даты считаются равными.

Когда использовать formatRange, а когда ручное форматирование

Используйте formatRange(), когда показываете пользователям диапазоны дат. Это актуально для бронирований, длительности событий, отчетных периодов, окон доступности и любых других временных промежутков. Метод гарантирует корректное форматирование для локали и оптимальное опускание лишних компонентов.

Не используйте formatRange(), если нужно показать несколько несвязанных дат. Например, список дедлайнов вроде «1 января, 15 января, 1 февраля» лучше выводить отдельными вызовами format() для каждой даты, а не как диапазон.

Также не стоит использовать formatRange(), если показываете сравнения или разницу между датами. Если вы отображаете, насколько одна дата раньше или позже другой, это относительный расчет времени, а не диапазон.