Calcula diferencias de eventos y aplica formato a las duraciones con Temporal

Funciones web: Temporal Intl.DurationFormat

Calcular el tiempo transcurrido o restante entre eventos (como vencimientos de pruebas, cuentas regresivas de suscripciones o ventanas de ANS) y presentar esos períodos en un formato localizado y legible por humanos históricamente requería cálculos manuales o bibliotecas externas. El objeto Date heredado solo hace un seguimiento de los milisegundos de la época, lo que hace que la aritmética del mes calendario, las transiciones del horario de verano (DST) y el balanceo de unidades sean propensos a errores.

La API de Temporal vincula Temporal.ZonedDateTime con Temporal.Duration y Intl.DurationFormat para proporcionar diferencias de tiempo seguras para el horario de verano, balanceo de unidades configurable, comparaciones de duración que tienen en cuenta el calendario y formato localizado.

Cómo implementarla

  1. Obtén extremos de Temporal.ZonedDateTime: Usa Temporal.Now.zonedDateTimeISO() para la hora actual en la zona horaria del sistema o pasa una cadena de zona horaria de IANA explícita (Temporal.Now.zonedDateTimeISO(timeZone)) cuando la zona horaria del evento de destino difiera de la predeterminada del sistema del usuario.
  2. Calcula los diferenciales con .since() y .until():
    • Usa now.since(start, options) para calcular el tiempo transcurrido desde un evento pasado.
    • Usa now.until(end, options) para calcular el tiempo restante hasta un evento futuro.
    • Pasa largestUnit (p.ej., 'year', 'month', 'day', 'hour') y smallestUnit (p.ej., 'minute') para equilibrar y redondear el Temporal.Duration resultante.
  3. Equilibra o compara objetos Temporal.Duration independientes:
    • Crea duraciones independientes con Temporal.Duration.from({ minutes: 90 }) y reequilibra las unidades con duration.round({ largestUnit: 'hour' }) (proporcionando relativeTo cuando se equilibren meses, años o transiciones de horario de verano).
    • Compara dos duraciones con Temporal.Duration.compare(d1, d2, { relativeTo }). Se requiere pasar un PlainDate o ZonedDateTime como relativeTo siempre que cualquiera de las duraciones contenga unidades de calendario (years, months, weeks) o cruce los límites del horario de verano.
  4. Formato para humanos vs. máquinas:
    • Usa duration.toLocaleString(locale, options) (que delega en Intl.DurationFormat) para el texto localizado para el usuario.
    • Usa duration.toString() solo para las cadenas de duración ISO 8601 legibles por máquinas (como P1M2DT4H).

Ejemplo: Diferencia de vencimiento de la prueba y formato localizado

// 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;

Ejemplo: Balanceo de duraciones independientes (round y 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"

Implementación estratégica y prácticas recomendadas

  • SÍ usa Temporal.ZonedDateTime para cálculos que involucren eventos del mundo real que ocurren en zonas horarias específicas (como renovaciones de suscripciones o cuentas regresivas de eventos) y pasa un timeZone explícito a Temporal.Now.zonedDateTimeISO(timeZone) cuando calcules en una zona que no sea la predeterminada del sistema.
  • SÍ combina cadenas de fecha y hora separadas con métodos de Temporal, p.ej., Temporal.PlainDate.from(dateStr).toPlainDateTime(timeStr).toZonedDateTime(tz) o Temporal.PlainDate.from(dateStr).toZonedDateTime({ timeZone: tz, plainTime: timeStr }), en lugar de interpolar una cadena ISO como `${dateStr}T${timeStr}`.
  • SÍ pasa largestUnit (p.ej., 'day', 'month', 'year') en .since(), .until() o .round() cuando quieras que las duraciones se equilibren más allá de las horas; los métodos de diferencia de ZonedDateTime se establecen de forma predeterminada en 'hour'.
  • SÍ usa Temporal.Duration.compare(d1, d2, { relativeTo }) cuando compares dos duraciones, proporcionando un anclaje relativeTo siempre que cualquiera de las duraciones involucre unidades de calendario (months, years) o días que tengan en cuenta la zona horaria.
  • SÍ debes dar formato a las duraciones para la visualización del usuario con duration.toLocaleString() o new Intl.DurationFormat(locale, { style }) ('long', 'short', 'narrow' o 'digital') en lugar de interpolar ${duration.years}y ${duration.months}m... de forma manual.
  • SÍ usa duration.toString() para las cadenas de duración ISO 8601 legibles por máquinas (p.ej., PT1H30M), nunca como texto para el usuario.
  • SÍ renderices un PlainTime como HH:MM (p.ej., para <input type="time">) con .toPlainTime().toString({ smallestUnit: 'minute' }) en lugar de la cadena .slice(0, 5).
  • NO modifiques las instancias directamente; los objetos Temporal son inmutables. Las operaciones como .add(), .subtract(), .round() o .with() devuelven una instancia nueva.
  • NO recurras a la aritmética de Date/.getTime() para el diferencial en sí; Date no tiene operaciones matemáticas de calendario que tengan en cuenta la zona horaria y solo tiene precisión de milisegundos.
  • SÍ uses la propiedad nativa .sign del Temporal.Duration (o Temporal.ZonedDateTime.compare()) calculado para verificar si una duración representa un momento pasado o vencido (sign < 0) o un inicio pendiente futuro.

Estrategia de resguardo

Resguardos y compatibilidad de navegadores para Temporal

Para los navegadores que aún no admiten la API de Temporal nativa, usa la detección de funciones y carga temporal-polyfill de forma dinámica (~15 KB min+gz).

Ten en cuenta que importar temporal-polyfill no asigna automáticamente el objeto Temporal al alcance global. Asígnalo de forma explícita si tu código depende de 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