Toute application qui affiche des dates finit tôt ou tard par devoir les présenter sous forme relative et lisible : « il y a 3 minutes », « dans 2 jours », « la semaine dernière ». L'approche naïve, c'est un empilement de if/else et de chaînes de caractères écrites à la main.

La bonne approche, c'est Intl.RelativeTimeFormat, disponible dans tous les navigateurs depuis 2020.

Le problème quand on le fait soi-même

Voilà à quoi ressemble en général un formatage de temps relatif fait maison :

function timeAgo(date) {
  const seconds = Math.floor((Date.now() - date) / 1000);

  if (seconds < 60) return `il y a ${seconds} secondes`;
  if (seconds < 3600) return `il y a ${Math.floor(seconds / 60)} minutes`;
  if (seconds < 86400) return `il y a ${Math.floor(seconds / 3600)} heures`;
  return `il y a ${Math.floor(seconds / 86400)} jours`;
}

Et même avec une lib d'i18n comme next-intl, la tentation est de simplement déplacer les chaînes dans les fichiers de traduction sans rien régler du fond :

// messages/fr.json
// {
//   "timeAgo": {
//     "seconds": "il y a {count} secondes",
//     "minutes": "il y a {count} minutes",
//     "hours":   "il y a {count} heures",
//     "days":    "il y a {count} jours"
//   }
// }

import { useTranslations } from 'next-intl';

function useTimeAgo() {
  const t = useTranslations('timeAgo');

  return (date: Date) => {
    const seconds = Math.floor((Date.now() - date.getTime()) / 1000);

    if (seconds < 60)    return t('seconds', { count: seconds });
    if (seconds < 3600)  return t('minutes', { count: Math.floor(seconds / 60) });
    if (seconds < 86400) return t('hours',   { count: Math.floor(seconds / 3600) });
    return t('days', { count: Math.floor(seconds / 86400) });
  };
}

On a l'impression d'avoir résolu le problème de l'i18n parce que les chaînes sont traduisibles, mais en réalité on a juste déplacé la dette. La logique de choix d'unité reste à la main, et surtout aucune des règles de pluralisation des langues cibles n'est appliquée.

Les règles de pluralisation varient énormément d'une langue à l'autre. L'arabe a six formes plurielles. Le gallois a une forme spéciale pour « 2 ». Le russe utilise des formes différentes selon que le nombre vaut 1, 2–4, ou 5 et plus. Votre formateur fait maison va se tromper sur tous ces cas.

Pourquoi réinventer la roue : l'API Intl.RelativeTimeFormat

Le JavaScript moderne possède une multitude d'API très puissantes. Notamment les API d'internationalisation Intl.

Dans notre cas, Intl.RelativeTimeFormat prend une locale et un objet d'options. La méthode format() se charge de toutes les transformations :

const relativeTimeFormat = new Intl.RelativeTimeFormat('fr', { numeric: 'auto' });

relativeTimeFormat.format(-1, 'day');     // "hier"
relativeTimeFormat.format(3, 'hour');     // "dans 3 heures"
relativeTimeFormat.format(-10, 'second'); // "il y a 10 secondes"

Le premier argument de format() est un nombre signé (négatif = passé, positif = futur), et le second est une unité de temps : "second", "minute", "hour", "day", "week", "month", "quarter" ou "year".

Les options

Un certain nombre d'options permettent de peaufiner notre résultat :

L'option numeric

L'option numeric détermine si le formateur utilise les raccourcis du langage naturel ou s'il retombe systématiquement sur des nombres :

const auto   = new Intl.RelativeTimeFormat('fr', { numeric: 'auto' });
const always = new Intl.RelativeTimeFormat('fr', { numeric: 'always' });

auto.format(0, 'day');    // "aujourd’hui"
always.format(0, 'day');  // "dans 0 jour"

auto.format(-1, 'day');   // "hier"
always.format(-1, 'day'); // "il y a 1 jour"

auto.format(1, 'week');   // "la semaine prochaine"
always.format(1, 'week'); // "dans 1 semaine"

numeric: 'auto' est presque toujours ce que vous voulez pour du texte d'interface.

L'option style

Trois options contrôlent le niveau de verbosité :

const long   = new Intl.RelativeTimeFormat('fr', { style: 'long' });
const short  = new Intl.RelativeTimeFormat('fr', { style: 'short' });
const narrow = new Intl.RelativeTimeFormat('fr', { style: 'narrow' });

long.format(-3, 'month');    // "il y a 3 mois"
short.format(-3, 'month');   // "il y a 3 m."
narrow.format(-3, 'month');  // "-3 m."

Utilisez narrow pour les interfaces denses comme les calendriers ou les pastilles de notification, et long pour les textes accessibles.

Un vrai rendu multilingue

C'est là que Intl.RelativeTimeFormat montre toute sa valeur :

const locales = ['en', 'fr', 'de', 'ar', 'ja', 'ru', 'zh'];
const opts = { numeric: 'auto' };

for (const locale of locales) {
  const relativeTimeFormat = new Intl.RelativeTimeFormat(locale, opts);
  console.log(`${locale}: ${relativeTimeFormat.format(-3, 'day')}`);
}

Résultat :

en: 3 days ago
fr: il y a 3 jours
de: vor 3 Tagen
ar: قبل ٣ أيام
ja: 3 日前
ru: 3 дня назад
zh: 3天前

Aucun effort. Pluralisation correcte. Écriture correcte. Système de numération correct — l'arabe obtient automatiquement les chiffres arabo-indiens orientaux.

La traduction a déjà été faite par les développeurs des moteurs JavaScript, plus besoin d'en porter la responsabilité.

Styling

L'utilisation de la méthode format possède néanmoins un désavantage : elle ne permet pas de styliser son rendu aussi finement. Par exemple, si on voulait afficher le texte de durée en italique noir, et la valeur numérique en gras bleu foncé.

Effectivement, la fonction format retournant une string, il faudrait parser, formater et réassembler cette chaîne de caractères, et on perdrait tout intérêt.

C'est là que formatToParts() entre en jeu. Contrairement à format, formatToParts() retourne un tableau structuré :

const relativeTimeFormat = new Intl.RelativeTimeFormat('fr', { numeric: 'always' });
relativeTimeFormat.formatToParts(-5, 'minute');
// [
//   { type: 'literal', value: 'il y a ' },
//   { type: 'integer', value: '5', unit: 'minute' },
//   { type: 'literal', value: ' minutes' }
// ]

Cela permet de rendre quelque chose comme "il y a 5 minutes" dans un composant React sans aucun parsing de chaîne :

function RelativeTime({ value, unit, locale = 'fr' }) {
  const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
  const parts = rtf.formatToParts(value, unit);

  return (
    <span>
      {parts.map((p, i) =>
        p.type === 'integer'
          ? <strong key={i}>{p.value}</strong>
          : <span key={i}>{p.value}</span>
      )}
    </span>
  );
}

En résumé

Intl.RelativeTimeFormat est un must-have du namespace Intl.

Il gère pour vous les parties difficiles de la pluralisation, en prenant en charge la direction d'écriture, le formatage numérique et la traduction du texte.

Alors, convaincu par Intl.RelativeTimeFormat ?
il y a 5 minutes
Complètement
il y a 3 secondes

Si vous livrez des dates lisibles à un public qui dépasse une seule locale, c'est l'outil qu'il vous faut privilégier.