Calculer les différences entre les événements et formater les durées avec Temporal

Fonctionnalités Web : Temporal Intl.DurationFormat

Le calcul du temps écoulé ou restant entre des événements (comme l'expiration d'un essai, le compte à rebours d'un abonnement ou les fenêtres de SLA) et la présentation de ces périodes dans un format localisé et lisible par l'humain nécessitaient auparavant des calculs manuels ou des bibliothèques externes. L'ancien objet Date ne suit que les millisecondes d'epoch, ce qui rend les calculs de mois calendaires, les transitions d'heure d'été et l'équilibrage des unités sujets aux erreurs.

L'API Temporal associe Temporal.ZonedDateTime à Temporal.Duration et Intl.DurationFormat pour fournir des différences de temps sécurisées pour l'heure d'été, un équilibrage d'unité configurable, des comparaisons de durée tenant compte du calendrier et une mise en forme localisée.

Implémentation

  1. Obtenir les points de terminaison Temporal.ZonedDateTime : utilisez Temporal.Now.zonedDateTimeISO() pour l'heure actuelle dans le fuseau horaire du système, ou transmettez une chaîne de fuseau horaire IANA explicite (Temporal.Now.zonedDateTimeISO(timeZone)) lorsque le fuseau horaire de l'événement cible diffère du paramètre système par défaut de l'utilisateur.
  2. Calculer les différentiels avec .since() et .until() :
    • Utilisez now.since(start, options) pour calculer le temps écoulé depuis un événement passé.
    • Utilisez now.until(end, options) pour calculer le temps restant avant un événement futur.
    • Transmettez largestUnit (par exemple, 'year', 'month', 'day', 'hour') et smallestUnit (par exemple, 'minute') pour équilibrer et arrondir le Temporal.Duration obtenu.
  3. Équilibrer ou comparer des objets Temporal.Duration autonomes :
    • Créez des durées autonomes avec Temporal.Duration.from({ minutes: 90 }) et rééquilibrez les unités avec duration.round({ largestUnit: 'hour' }) (en fournissant relativeTo lors de l'équilibrage entre les mois calendaires, les années ou les transitions d'heure d'été).
    • Comparez deux durées avec Temporal.Duration.compare(d1, d2, { relativeTo }). Vous devez transmettre un PlainDate ou un ZonedDateTime en tant que relativeTo chaque fois que l'une des durées contient des unités de calendrier (years, months, weeks) ou franchit les limites de l'heure d'été.
  4. Format pour les humains et les machines :
    • Utilisez duration.toLocaleString(locale, options) (qui délègue à Intl.DurationFormat) pour le texte localisé visible par l'utilisateur.
    • Utilisez duration.toString() uniquement pour les chaînes de durée ISO 8601 lisibles par machine (comme P1M2DT4H).

Exemple : Différence d'expiration de l'essai et mise en forme localisée

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

Exemple : Équilibrer les durées autonomes (round et 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"

Implémentation stratégique et bonnes pratiques

  • À FAIRE : Utilisez Temporal.ZonedDateTime pour les calculs impliquant des événements réels qui se produisent dans des fuseaux horaires spécifiques (comme les renouvellements d'abonnement ou les comptes à rebours d'événements), et transmettez un timeZone explicite à Temporal.Now.zonedDateTimeISO(timeZone) lors du calcul par rapport à un fuseau horaire autre que le paramètre système par défaut.
  • À FAIRE : Combinez des chaînes de date et d'heure distinctes avec des méthodes temporelles, par exemple Temporal.PlainDate.from(dateStr).toPlainDateTime(timeStr).toZonedDateTime(tz) ou Temporal.PlainDate.from(dateStr).toZonedDateTime({ timeZone: tz, plainTime: timeStr }), au lieu d'interpoler une chaîne ISO comme `${dateStr}T${timeStr}`.
  • PASSEZ largestUnit (par exemple, 'day', 'month', 'year') dans .since(), .until() ou .round() lorsque vous souhaitez équilibrer les durées au-delà des heures. Les méthodes de différence ZonedDateTime sont définies par défaut sur 'hour'.
  • À FAIRE : Utilisez Temporal.Duration.compare(d1, d2, { relativeTo }) lorsque vous comparez deux durées, en fournissant un ancrage relativeTo chaque fois que l'une des durées implique des unités de calendrier (months, years) ou des jours tenant compte du fuseau horaire.
  • À FAIRE : Formatez les durées pour l'affichage utilisateur à l'aide de duration.toLocaleString() ou new Intl.DurationFormat(locale, { style }) ('long', 'short', 'narrow' ou 'digital') au lieu d'interpoler manuellement ${duration.years}y ${duration.months}m....
  • À FAIRE : Utilisez duration.toString() pour les chaînes de durée ISO 8601 lisibles par machine (par exemple, PT1H30M), jamais pour le texte visible par l'utilisateur.
  • À FAIRE : affichez un PlainTime sous la forme HH:MM (par exemple, pour <input type="time">) à l'aide de .toPlainTime().toString({ smallestUnit: 'minute' }) plutôt que de la chaîne .slice(0, 5).
  • NE MODIFIEZ PAS les instances directement. Les objets Temporal sont immuables. Les opérations telles que .add(), .subtract(), .round() ou .with() renvoient une nouvelle instance.
  • N'utilisez PAS l'arithmétique Date/.getTime() pour la différence elle-même. Date n'a pas de calculs de calendrier tenant compte du fuseau horaire et n'offre qu'une précision en millisecondes.
  • UTILISEZ la propriété native .sign de l'élément Temporal.Duration (ou Temporal.ZonedDateTime.compare()) calculé pour vérifier si une durée représente un point temporel passé/expiré (sign < 0) ou un démarrage en attente futur.

Stratégie de remplacement

Solutions de remplacement et compatibilité des navigateurs avec Temporal

Pour les navigateurs qui ne sont pas encore compatibles avec l'API Temporal native, utilisez la détection de fonctionnalités et chargez dynamiquement temporal-polyfill (~15 Ko min+gz).

Notez que l'importation de temporal-polyfill n'attribue pas automatiquement l'objet Temporal au champ d'application global. Attribuez-la explicitement si votre code repose sur 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