Даты, числа и сортировка по локали
Количество уроков в «Параллели» уже получает правильную языковую форму. Теперь займёмся датами обновления, длительностью чтения и порядком карточек. Эти значения нужно хранить так, чтобы их смысл не зависел от языка интерфейса. Строка 10/11/2026 без дополнительного договора неоднозначна: читатель может понимать её как разные календарные дни.
В этом уроке отделим исходное значение от показанного текста. Для представления используем Intl.DateTimeFormat, Intl.NumberFormat и Intl.Collator. Эти объекты не заменяют модель данных и не определяют, какой часовой пояс подходит нашей библиотеке. Такое решение принимаем явно.
Календарный день и момент времени
У статьи бывают два разных поля: редакционная дата публикации и точный момент последнего технического изменения. Первая может быть просто днём без часов и часового пояса. Второй должен однозначно описывать момент на общей временной шкале. Не следует превращать любой календарный день в локальную полночь и затем переносить его между часовыми поясами.
Запишем пример записи карточки:
const article = {
id: "stable-urls",
publishedDay: "2026-10-10",
updatedAt: "2026-10-10T23:30:00Z",
readingMinutes: 12,
title: "Постоянные адреса"
};
publishedDay обозначает выбранный редактором день. updatedAt содержит UTC-момент: суффикс Z является частью договора. Если поле приходит от сервера без часового пояса, нельзя молча предполагать, что сервер и браузер находятся в одном месте. Лучше исправить контракт обмена, чем скрыть неоднозначность красивым форматированием.
Для показа календарного дня создадим дату в UTC и форматтер с тем же поясом. Это технический способ не сдвинуть день; он не превращает редакционную дату в факт публикации ровно в полночь:
const locales = { ru: "ru-RU", en: "en-GB" };
export function formatDay(language, day) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) {
throw new Error("Expected ISO calendar day");
}
const date = new Date(`${day}T00:00:00Z`);
if (Number.isNaN(date.getTime()) ||
date.toISOString().slice(0, 10) !== day) {
throw new Error("Invalid calendar day");
}
return new Intl.DateTimeFormat(locales[language], {
dateStyle: "long", timeZone: "UTC"
}).format(date);
}
Сравнение исходного дня с обратным ISO-представлением полезно: некоторые некорректные числа дней могут нормализоваться в другой месяц. Нам нужен отказ от неверных данных, а не незаметная редакционная правка даты. Проверка языка должна происходить до вызова этой функции; в полном файле примера она выделена в отдельный помощник.
Явный часовой пояс
Точный момент обновления можно показать по правилам библиотеки, например в Europe/Minsk. Если хотим использовать часовой пояс посетителя, это отдельная пользовательская настройка. В обоих случаях форматирование языка и выбор пояса остаются независимыми параметрами.
export function formatInstant(language, value, timeZone = "Europe/Minsk") {
if (!value.endsWith("Z") && !/[+-]\d{2}:\d{2}$/.test(value)) {
throw new Error("Expected an explicit time-zone offset");
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) throw new Error("Invalid instant");
return new Intl.DateTimeFormat(locales[language], {
dateStyle: "medium", timeStyle: "short", timeZone
}).format(date);
}
Для момента 2026-10-10T23:30:00Z дата в Минске будет уже следующим календарным днём. Это ожидаемое следствие выбора пояса, а не неправильный перевод числа. При сравнении интерфейсов нужно записывать и исходный момент, и выбранный пояс. Документация DateTimeFormat описывает соответствующие параметры; точную пунктуацию результата не используем как бизнес-идентификатор.
В HTML сохраните исходное машинное значение в datetime, а локализованный текст сделайте содержимым элемента time. Так пользователь получает понятную дату, а инструменты — однозначное значение. Не пытайтесь затем восстановить дату из уже показанного русского или английского предложения.
Число не становится строкой в модели
Длительность чтения будем хранить числом минут. В интерфейсе можно показать подпись целиком, используя словарь и правила количества. Для дробного показателя, например средней длительности, форматирование задаёт число знаков, но не меняет исходное значение. Округлять данные в хранилище ради красивой карточки не требуется.
const number = new Intl.NumberFormat("ru-RU", {
minimumFractionDigits: 0,
maximumFractionDigits: 1
});
const rawMinutes = 12.34;
const visibleMinutes = number.format(rawMinutes);
Ожидаемое представление использует один дробный знак, а переменная rawMinutes сохраняет исходную точность. Такое различие важно для аналитики: сумма предварительно округлённых карточек может отличаться от округления общей суммы. Форматтер предназначен для вывода, а не для последующих вычислений.
Не разбирайте пользовательский ввод вызовом Number после простого удаления запятой. Парсинг локализованных денежных или числовых строк требует отдельного договора формы и ошибок. В этой серии числа приходят из подготовленных данных; ввод финансовых значений не является частью библиотеки.
Порядок названий зависит от языка
Обычная сортировка JavaScript сравнивает строки по порядку кодовых единиц. Для алфавитного списка карточек удобнее компаратор Intl.Collator. Применим его к копии массива, чтобы не менять исходный порядок учебной последовательности:
export function byTitle(items, language) {
const collator = new Intl.Collator(locales[language], {
usage: "sort", numeric: true, sensitivity: "base"
});
return [...items].sort((a, b) =>
collator.compare(a.title, b.title) || a.id.localeCompare(b.id));
}
Второе сравнение делает порядок устойчивым для равных с точки зрения коллатора заголовков. При этом уроки курса продолжают идти по явному номеру, а не по алфавиту. Перевод названия не должен переставлять «Следующий урок» раньше первого: разные списки требуют разных правил сортировки.
Форматирование после смены языка
Представим интерактивный кабинет, где пользователь меняет язык без полной загрузки страницы. Уже показанные даты не переведутся автоматически после изменения атрибута lang. Форматтер использовал локаль в момент создания. Для нового языка нужно создать соответствующие объекты и повторно отобразить исходные значения. Хранение только готовой строки затруднило бы такую операцию: её нельзя надёжно разобрать обратно.
В статической «Параллели» переход ведёт к отдельному документу, но тот же принцип полезен для браузерных компонентов. Дата в модели сохраняется как ISO-день или однозначный момент, число — как число. Компонент получает язык и выводит представление. Изменение локали не должно обращаться к базе для восстановления точности, потерянной при предыдущем округлении.
Не рассчитывайте на одинаковые пробелы и пунктуацию результата во всех средах. Форматирование может содержать неразрывные разделители, а данные международных правил обновляются вместе с окружением. Для пользовательского вывода это допустимо, для машинного сравнения используйте исходные значения. При экспорте CSV также заранее договоритесь, предназначен ли файл для человека в конкретной локали или для последующей программы.
Наконец, сортировка названий зависит от выбранной подписи. Если в английском списке остаётся русский оригинал без перевода, его лучше явно отметить и принять понятное правило размещения. Нельзя получить полноценную английскую коллацию, просто присвоив русский заголовок полю с новым именем. Источник данных должен сообщать фактический язык текста.
Мы получили отдельный слой представления: исходные даты, числа и ID остаются одинаковыми, а язык влияет на читаемый текст. В следующем уроке перейдём к содержанию статей и соберём из реестра самостоятельные страницы Markdown для каждого опубликованного языка.