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

Веб-функции: Temporal Intl.DurationFormat

Раньше, чтобы рассчитать время, прошедшее или оставшееся между событиями (например, окончание пробного периода, обратный отсчет до окончания подписки или окна SLA), и представить эти промежутки в локализованном, человекочитаемом формате, требовались ручные вычисления или внешние библиотеки. Устаревший объект Date отслеживает только миллисекунды эпохи, что делает арифметику календарных месяцев, переходы на летнее время и балансировку единиц подверженными ошибкам.

API Temporal работает в паре с Temporal.ZonedDateTime, Temporal.Duration и Intl.DurationFormat, чтобы обеспечивать безопасную разницу во времени с учетом перехода на летнее время, настраиваемый баланс единиц, сравнение продолжительности с учетом календаря и локализованное форматирование.

Как реализовать

  1. Получение конечных точек Temporal.ZonedDateTime. Используйте Temporal.Now.zonedDateTimeISO() для текущего времени в системном часовом поясе или передайте явную строку часового пояса IANA – Temporal.Now.zonedDateTimeISO(timeZone), – если часовой пояс целевого события отличается от системного часового пояса пользователя по умолчанию.
  2. Рассчитайте разницу с помощью .since() и .until():
    • Используйте now.since(start, options), чтобы рассчитать время, прошедшее с момента события.
    • Используйте now.until(end, options), чтобы рассчитать время, оставшееся до будущего события.
    • Передайте largestUnit (например, 'year', 'month', 'day', 'hour') и smallestUnit (например, 'minute'), чтобы сбалансировать и округлить полученное значение Temporal.Duration.
  3. Сравнение отдельных объектов Temporal.Duration или поиск баланса между ними:
    • Создавайте отдельные продолжительности с помощью Temporal.Duration.from({ minutes: 90 }) и перераспределяйте единицы с помощью duration.round({ largestUnit: 'hour' }) (указывая relativeTo при распределении по календарным месяцам, годам или переходам на летнее время).
    • Сравните две продолжительности с помощью Temporal.Duration.compare(d1, d2, { relativeTo }). Передача PlainDate или ZonedDateTime в качестве relativeTo требуется, когда любая продолжительность содержит календарные единицы (years, months, weeks) или пересекает границы летнего времени.
  4. Формат для людей и машин
    • Используйте duration.toLocaleString(locale, options) (который делегирует Intl.DurationFormat) для локализованного пользовательского текста.
    • Используйте duration.toString() только для машиночитаемых строк продолжительности ISO 8601 (например, P1M2DT4H).

Пример: разница в сроке действия пробной версии и локализованное форматирование

// 1. Get the current time in an explicit target IANA time zone (or omit the
// argument to use the system's current time zone).
const tz = 'America/New_York';
const now = Temporal.Now.zonedDateTimeISO(tz);

// 2. Combine separate date and time strings directly.
// Do not build an ISO string with template literals; chain Temporal methods instead.
const startDateStr = '2025-01-01';
const startTimeStr = '12:00:00';
const endDateStr = '2025-01-31';
const endTimeStr = '12:00:00';

const start = Temporal.PlainDate.from(startDateStr).toPlainDateTime(startTimeStr).toZonedDateTime(tz);
const end = Temporal.PlainDate.from(endDateStr).toPlainDateTime(endTimeStr).toZonedDateTime(tz);

// 3. Calculate differences using .since() and .until()
// ZonedDateTime differences default to largestUnit: 'hour' (days are not a fixed
// length across DST, so Temporal won't balance into days unless asked). Pass
// largestUnit: 'day' or a calendar unit ('week', 'month', 'year') when you want a
// human-scale breakdown instead of e.g. PT720H. smallestUnit rounds away
// seconds and sub-second noise so the formatted string stays readable.
const timeActive = now.since(start, { largestUnit: 'year', smallestUnit: 'minute' });
const timeRemaining = now.until(end, { largestUnit: 'year', smallestUnit: 'minute' });

// 4. Format durations for display with .toLocaleString() (Intl.DurationFormat)
// instead of manually concatenating unit numbers and suffixes. 'en-US' is used
// here for deterministic output; pass undefined to use the user's locale.
console.log(`Active: ${timeActive.toLocaleString('en-US', { style: 'narrow' })}`);
console.log(`Remaining: ${timeRemaining.toLocaleString('en-US', { style: 'long' })}`);

// Use .toString() for ISO 8601 serialization (e.g., API payloads or <time datetime="...">)
console.log(`ISO 8601: ${timeRemaining.toString()}`);

// 5. Check status via .sign, and compare durations with Temporal.Duration.compare()
const isExpired = timeRemaining.sign < 0;
// Pass relativeTo when comparing durations that may include calendar months/years or DST shifts
const isPastHalfway = Temporal.Duration.compare(timeActive, timeRemaining, { relativeTo: now }) > 0;

Пример: балансировка длительности отдельных объявлений (round и Intl.DurationFormat)

// Convert a raw unit count (e.g., 90 minutes) into a balanced breakdown (1 hour, 30 minutes)
const rawDuration = Temporal.Duration.from({ minutes: 90 });
const balanced = rawDuration.round({ largestUnit: 'hour' });

// Format via duration.toLocaleString() or directly with Intl.DurationFormat
const formatter = new Intl.DurationFormat('en-US', { style: 'long' });
console.log(formatter.format(balanced)); // "1 hour, 30 minutes"

Стратегическое внедрение и рекомендации

  • РЕКОМЕНДУЕТСЯ использовать Temporal.ZonedDateTime для вычислений, связанных с реальными событиями, которые происходят в определенных часовых поясах (например, продление подписки или обратный отсчет до события), и передавать явный timeZone в Temporal.Now.zonedDateTimeISO(timeZone) при вычислении для часового пояса, отличного от системных настроек по умолчанию.
  • РЕКОМЕНДУЕТСЯ объединять отдельные строки даты и времени с помощью методов Temporal, например Temporal.PlainDate.from(dateStr).toPlainDateTime(timeStr).toZonedDateTime(tz) или Temporal.PlainDate.from(dateStr).toZonedDateTime({ timeZone: tz, plainTime: timeStr }), вместо интерполяции строки ISO, например `${dateStr}T${timeStr}`.
  • РЕКОМЕНДУЕТСЯ передавать largestUnit (например, 'day', 'month', 'year') в .since(), .until() или .round(), если вы хотите сбалансировать продолжительность за пределами часов. По умолчанию методы разницы ZonedDateTime используют 'hour'.
  • ИСПОЛЬЗУЙТЕ Temporal.Duration.compare(d1, d2, { relativeTo }) при сравнении двух периодов времени, указывая опорную дату relativeTo, если в одном из периодов используются календарные единицы (months, years) или дни с учетом часового пояса.
  • РЕКОМЕНДУЕТСЯ форматировать длительность для показа пользователям с помощью duration.toLocaleString() или new Intl.DurationFormat(locale, { style }) ('long', 'short', 'narrow' или 'digital'), а не вручную интерполировать ${duration.years}y ${duration.months}m....
  • РЕКОМЕНДУЕТСЯ использовать duration.toString() для машиночитаемых строк длительности ISO 8601 (например, PT1H30M), но не для пользовательского текста.
  • РЕКОМЕНДУЕТСЯ использовать .toPlainTime().toString({ smallestUnit: 'minute' }) вместо строки .slice(0, 5), чтобы представить PlainTime как HH:MM (например, для <input type="time">).
  • НЕ изменяйте экземпляры напрямую. Объекты Temporal неизменяемы. Операции, такие как .add(), .subtract(), .round() или .with(), возвращают новый экземпляр.
  • НЕ используйте арифметику Date/.getTime() для вычисления разницы. В Date нет календарной арифметики с учетом часовых поясов, а точность ограничена миллисекундами.
  • РЕКОМЕНДУЕТСЯ использовать нативное свойство .sign вычисленного объекта Temporal.Duration (или Temporal.ZonedDateTime.compare()), чтобы проверить, представляет ли продолжительность прошедший/истекший момент времени (sign < 0) или предстоящее начало в будущем.

Резервная стратегия

Резервные варианты и поддержка Temporal в браузерах

Для браузеров, которые пока не поддерживают нативный API Temporal, используйте обнаружение функций и динамически загружайте temporal-polyfill (около 15 КБ min+gz).

Обратите внимание, что импорт temporal-polyfill не приводит к автоматическому назначению объекта Temporal глобальной области видимости. Если ваш код зависит от globalThis.Temporal, назначьте его явно.

// Check if Temporal is supported natively
(async () => {
  if (typeof Temporal === 'undefined') {
    // Load the polyfill conditionally
    const module = await import('https://esm.sh/temporal-polyfill');
    globalThis.Temporal = module.Temporal;
    // Extend Date.prototype if needed
    Date.prototype.toTemporalInstant = module.toTemporalInstant;
  }
  initializeApp();
})();

Intl.DurationFormat