Tutoriels//9 min de lecture

Localisation des dates et heures : formats, fuseaux horaires et les bugs qui s'ensuivent

Eray Gündoğmuş
Partager

Localisation des dates et heures : formats, fuseaux horaires et les bugs qui s'ensuivent

Si vous avez déjà publié une fonctionnalité qui affichait la mauvaise date aux utilisateurs en Australie, ou stocké accidentellement une heure dans le fuseau horaire local alors que vous vouliez UTC, vous savez déjà à quel point la gestion des dates et des heures peut être impitoyable. Ce ne sont pas des cas limites. C'est la norme.

La localisation des dates et heures se trouve à l'intersection de deux problèmes distincts difficiles : l'internationalisation (différents formats, calendriers, langues) et les systèmes distribués (fuseaux horaires, transitions heure d'été, dérive d'horloge). Se tromper sur l'un ou l'autre signifie que les utilisateurs voient des données incohérentes, ou pire, des données plausibles en apparence mais subtilement décalées de quelques heures ou jours.

Cet article couvre l'ensemble du stack : comment les dates doivent être stockées, transmises et affichées. Il comprend des exemples de code fonctionnels en JavaScript, Python et Ruby, et aborde les bugs spécifiques qui piègent les équipes qui sautent les détails.


Le problème du format : MM/JJ/AAAA vs JJ/MM/AAAA et tout le reste

Il n'existe pas de façon universellement reconnue d'écrire une date. Cette seule phrase est responsable d'innombrables tickets de confusion utilisateur.

Le conflit le plus courant est entre le format américain (MM/JJ/AAAA) et le format européen/mondial (JJ/MM/AAAA). La date 04/05/2024 signifie le 5 avril aux États-Unis et le 4 mai en Allemagne. Il est impossible de savoir laquelle est correcte à partir de la chaîne seule sans connaître la locale de l'utilisateur.

Au-delà de ce conflit d'ordre, les formats divergent davantage :

  • Japon, Chine, Corée : AAAA/MM/JJ ou YYYY年MM月DD日
  • ISO 8601 : AAAA-MM-JJ (le seul format sans ambiguïté, raison pour laquelle vous devriez l'utiliser pour le stockage et les APIs)
  • Inde : JJ-MM-AAAA, mais souvent écrit avec des points ou des barres obliques
  • Iran, Afghanistan, Éthiopie : Utilisent des calendriers entièrement différents (persan, éthiopien), pas seulement des ordres différents de dates grégoriennes

Pour l'affichage, la règle est : ne jamais coder en dur un format de date. Dérivez-le toujours à partir de la locale de l'utilisateur. C'est seulement une dimension du défi plus large de la localisation versus internationalisation — une distinction qui affecte bien plus que les formats de date. Comprendre les règles de pluralisation dans différentes langues est un autre domaine connexe où le comportement spécifique à la locale nécessite une gestion attentive.


ISO 8601 : le seul format à utiliser pour le stockage et les APIs

Si vous stockez des dates ou les transmettez entre services, utilisez ISO 8601. Toujours. Sans exception.

Les dates ISO 8601 ressemblent à 2024-04-05 pour les dates et 2024-04-05T14:30:00Z pour les dates-heures. Les propriétés clés qui le rendent adapté à l'utilisation backend :

  • Sans ambiguïté : pas d'ordre dépendant de la locale
  • Triable : le tri lexicographique équivaut au tri chronologique
  • Fuseau horaire explicite : le suffixe Z (ou le décalage +05:30) fait du fuseau horaire une partie de la valeur
  • Universellement pris en charge : tout langage de programmation majeur peut l'analyser sans bibliothèques supplémentaires

L'erreur la plus courante dans la gestion des dates est de stocker une chaîne de date-heure locale dans une base de données. Quand cette chaîne a été produite sur un serveur à Francfort, elle signifie une chose. Quand elle est lue sur un serveur à New York, elle signifie autre chose. ISO 8601 avec un décalage UTC explicite élimine cette ambiguïté.

# Python : Toujours stocker en UTC, toujours inclure les informations de fuseau horaire
from datetime import datetime, timezone

# MAUVAIS : datetime naïf, pas d'informations de fuseau horaire
bad = datetime.now()  # "2024-04-05 14:30:00" — heure locale ? UTC ? on ne sait pas

# CORRECT : datetime UTC avec conscience du fuseau horaire
good = datetime.now(timezone.utc)  # "2024-04-05T14:30:00+00:00"
good_str = good.isoformat()  # "2024-04-05T14:30:00+00:00"
// JavaScript : Utiliser des chaînes ISO pour la sérialisation
const now = new Date();

// MAUVAIS : dépendant de la locale, non portable
const bad = now.toLocaleDateString(); // "4/5/2024" (US), "05.04.2024" (DE)

// CORRECT : ISO 8601, UTC explicite
const good = now.toISOString(); // "2024-04-05T14:30:00.000Z"
# Ruby : Utiliser UTC et ISO 8601
# MAUVAIS
Time.now.to_s  # "2024-04-05 14:30:00 +0200" — le fuseau horaire local s'infiltre

# CORRECT
Time.now.utc.iso8601  # "2024-04-05T12:30:00Z"

Fuseaux horaires : stocker en UTC, afficher en heure locale

Le principe directeur de la gestion des fuseaux horaires est simple à énoncer et facile à oublier sous la pression des délais : stocker en UTC, afficher en heure locale de l'utilisateur.

UTC n'est pas un fuseau horaire, c'est un standard. Il n'observe pas l'heure d'été. Il ne se décale pas. Un horodatage UTC est un point absolu dans le temps qui signifie exactement la même chose partout sur Terre.

Lorsque vous stockez en UTC et connaissez le fuseau horaire de l'utilisateur, vous pouvez toujours calculer l'heure locale correcte. Lorsque vous stockez l'heure locale sans le fuseau horaire, vous avez perdu des informations que vous ne pouvez pas récupérer.

D'où devriez-vous obtenir le fuseau horaire de l'utilisateur ? Plusieurs sources, dans l'ordre approximatif de fiabilité :

  1. Préférence explicite de l'utilisateur stockée dans son profil (la plus précise, contrôlée par l'utilisateur)
  2. API du navigateur : Intl.DateTimeFormat().resolvedOptions().timeZone retourne la chaîne de fuseau horaire IANA
  3. Géolocalisation par IP (approximative, mauvaise pour les utilisateurs mobiles, échoue avec les VPN)
// Obtenir le fuseau horaire de l'utilisateur depuis le navigateur
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// ex. "America/New_York", "Europe/Berlin", "Asia/Kolkata"

Une fois que vous avez le fuseau horaire, passez-le à votre logique de formatage. N'essayez pas de convertir manuellement les horodatages UTC en heure locale. Utilisez des bibliothèques établies qui comprennent les transitions d'heure d'été.


Heure d'été : pourquoi l'arithmétique manuelle des décalages échoue

L'heure d'été (DST) est la raison pour laquelle vous ne devriez jamais coder en dur les décalages de fuseau horaire.

« L'Allemagne est UTC+1 » est faux la moitié de l'année. L'Allemagne observe CET (UTC+1) en hiver et CEST (UTC+2) en été. Si vous codez +1 en dur et qu'un utilisateur crée un événement à 10h en mars, quand l'été arrive, votre décalage stocké est incorrect et l'événement apparaît à la mauvaise heure.

La base de données de fuseaux horaires Olson (IANA) — des noms comme America/New_York, Europe/Berlin, Asia/Kolkata — contient le calendrier historique complet et futur des transitions d'heure d'été pour chaque fuseau horaire. Chaque plateforme l'intègre. Utilisez le fuseau horaire nommé, jamais un décalage brut.

Bugs de transition DST à surveiller :

  • L'heure ambiguë : les horloges reculent, donc 1h30 se produit deux fois. « 1h30 » est ambigu sans savoir quelle occurrence.
  • L'heure inexistante : les horloges avancent, donc 2h30 n'existe pas. Certains analyseurs s'ajustent silencieusement.
  • L'événement récurrent décalé : un événement récurrent hebdomadaire à « 15h heure locale » devrait rester à 15h heure locale après une transition DST — ce qui signifie que l'heure UTC change d'une heure.
// JavaScript : laisser l'API Intl gérer le DST pour vous
const formatter = new Intl.DateTimeFormat('de-DE', {
  timeZone: 'Europe/Berlin',
  hour: '2-digit',
  minute: '2-digit',
  day: '2-digit',
  month: '2-digit',
  year: 'numeric',
});

// Sûr à la fois en CET et en CEST
const utcDate = new Date('2024-03-31T01:30:00Z'); // Jour de transition DST en Europe
console.log(formatter.format(utcDate)); // "31.03.2024, 03:30"
# Python : utiliser zoneinfo (Python 3.9+) ou pytz
from zoneinfo import ZoneInfo
from datetime import datetime

utc_time = datetime(2024, 3, 31, 1, 30, tzinfo=ZoneInfo("UTC"))
berlin_time = utc_time.astimezone(ZoneInfo("Europe/Berlin"))
print(berlin_time)  # 2024-03-31 03:30:00+02:00 — conversion consciente du DST
# Ruby : utiliser la gem TZInfo pour une gestion fiable du DST
require 'tzinfo'

tz = TZInfo::Timezone.get('Europe/Berlin')
utc = Time.utc(2024, 3, 31, 1, 30)
local = tz.utc_to_local(utc)
# Retourne l'heure correctement ajustée pour le DST

JavaScript Intl.DateTimeFormat : la bonne façon de formater les dates

L'API Intl.DateTimeFormat est intégrée dans chaque environnement d'exécution JavaScript moderne. Elle gère le formatage spécifique à la locale, la conversion de fuseau horaire et les systèmes de calendrier sans dépendances externes.

// Formatage de date basique avec conscience de la locale
const date = new Date('2024-08-15T09:00:00Z');

// Anglais américain
new Intl.DateTimeFormat('en-US', { dateStyle: 'long' }).format(date);
// "August 15, 2024"

// Allemand
new Intl.DateTimeFormat('de-DE', { dateStyle: 'long' }).format(date);
// "15. August 2024"

// Japonais
new Intl.DateTimeFormat('ja-JP', { dateStyle: 'long' }).format(date);
// "2024年8月15日"

// Avec heure et fuseau horaire
new Intl.DateTimeFormat('en-GB', {
  dateStyle: 'full',
  timeStyle: 'short',
  timeZone: 'Europe/London',
}).format(date);
// "Thursday, 15 August 2024 at 10:00"

Le formatage de temps relatif (ex. « il y a 3 heures ») utilise Intl.RelativeTimeFormat :

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

rtf.format(-1, 'day');   // "hier"
rtf.format(-3, 'hour');  // "il y a 3 heures"
rtf.format(2, 'week');   // "dans 2 semaines"

Notez que Intl.RelativeTimeFormat nécessite que vous calculiez la valeur et l'unité. Il ne détermine pas pour vous si quelque chose s'est passé « hier » ou « il y a 3 jours ». Des bibliothèques comme date-fns ou Temporal (le successeur de l'API Date actuelle) ajoutent cette logique par-dessus Intl.


Python et Ruby : Babel, strftime et leurs limites

Python : Babel pour le formatage avec conscience de la locale

La bibliothèque standard strftime en Python produit une sortie avec conscience de la locale uniquement via la locale système, ce qui est peu fiable dans les environnements de production. Pour une i18n correcte, utilisez Babel :

from babel.dates import format_date, format_datetime, format_time
from datetime import datetime, timezone

dt = datetime(2024, 8, 15, 9, 0, 0, tzinfo=timezone.utc)

# Formater pour différentes locales
format_date(dt, locale='en_US')    # 'Aug 15, 2024'
format_date(dt, locale='de_DE')    # '15.08.2024'
format_date(dt, locale='ja_JP')    # '2024/08/15'
format_date(dt, format='long', locale='ar_SA')  # '١٥ أغسطس ٢٠٢٤'

# Avec conversion de fuseau horaire
from babel.dates import get_timezone
format_datetime(
    dt,
    format='full',
    tzinfo=get_timezone('America/New_York'),
    locale='en_US'
)
# 'Thursday, August 15, 2024 at 5:00:00 AM Eastern Daylight Time'

Ruby : strftime est aveugle à la locale

Le strftime intégré de Ruby n'est pas conscient de la locale. Time.now.strftime('%B %d, %Y') produit toujours des noms de mois en anglais quelle que soit la locale de l'application. Pour une sortie consciente de la locale, utilisez la gem i18n (standard dans Rails) avec des chaînes de format spécifiques à la locale, ou utilisez twitter_cldr pour un formatage basé sur CLDR :

require 'twitter_cldr'

date = DateTime.new(2024, 8, 15, 9, 0, 0)

# Anglais
date.localize(:en).to_long_s  # "August 15, 2024"

# Allemand
date.localize(:de).to_long_s  # "15. August 2024"

# Japonais
date.localize(:ja).to_long_s  # "2024年8月15日"

# Temps relatif
time_ago = 3.hours.ago
time_ago.localize(:fr).ago.to_s  # "il y a 3 heures"

Dans les applications Rails, I18n.l(date, format: :long) avec des fichiers YAML de locale est l'approche conventionnelle, mais les chaînes de format dans ces fichiers YAML nécessitent toujours une localisation manuelle pour chaque langue.


Erreurs courantes qui causent des bugs en production

1. Stocker l'heure locale dans la base de données

-- MAUVAIS : Dans quel fuseau horaire est-ce ?
created_at DATETIME DEFAULT NOW()

-- CORRECT : Toujours stocker en UTC
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() AT TIME ZONE 'UTC'

Si le type de colonne de votre base de données est DATETIME sans conscience du fuseau horaire, vous avez déjà perdu des informations. Passez à TIMESTAMP WITH TIME ZONE (ou équivalent) et assurez-vous que votre couche d'application écrit toujours en UTC.

2. Utiliser JavaScript Date() pour analyser des chaînes non ISO

// MAUVAIS : Le comportement d'analyse est défini par l'implémentation pour les chaînes non ISO
new Date('05/04/2024')  // 4 mai ou 5 avril ? Dépend de la locale de l'environnement

// CORRECT : N'analyser que les chaînes ISO 8601, ou utiliser une bibliothèque
new Date('2024-05-04')  // Toujours le 4 mai 2024

3. Supposer que le fuseau horaire du serveur est UTC

De nombreux environnements cloud utilisent UTC par défaut, mais beaucoup ne le font pas. Le code qui suppose que new Date() retourne UTC sans vérifier les variables d'environnement TZ se comportera différemment selon les déploiements.

// MAUVAIS : Suppose que le serveur est en UTC
const today = new Date().toISOString().split('T')[0];

// CORRECT : Être explicite sur ce que vous calculez
const todayUTC = new Date().toISOString().split('T')[0]; // C'est bien UTC via toISOString
// Mais si vous voulez « aujourd'hui dans le fuseau horaire de l'utilisateur » :
const todayLocal = new Intl.DateTimeFormat('en-CA', {
  timeZone: userTimezone
}).format(new Date()); // "2024-08-15"

4. Minuit n'est pas une heure par défaut sûre

Si vous créez un événement à minuit UTC pour une date donnée, cet événement tombe la veille pour les utilisateurs en UTC-5 à UTC-12. Les « événements sur toute la journée » doivent stocker uniquement une date (AAAA-MM-JJ), pas une date-heure.

5. Traiter le décalage comme un fuseau horaire

+05:30 est un décalage, pas un fuseau horaire. L'Inde (Asia/Kolkata) est toujours UTC+5:30 et n'observe pas le DST, donc dans ce cas la distinction est inoffensive. Mais pour +10:00, cela pourrait être Australia/Sydney (observe le DST) ou Pacific/Port_Moresby (ne l'observe pas). Stockez toujours le nom de fuseau horaire IANA à côté ou à la place du décalage brut.


Gestion des préférences de fuseau horaire des utilisateurs dans les applications web

Une implémentation complète nécessite trois choses :

  1. Détecter ou collecter le fuseau horaire à l'inscription : Utilisez l'API du navigateur comme valeur par défaut, laissez les utilisateurs le remplacer dans les paramètres.
  2. Stocker la chaîne de fuseau horaire IANA dans le profil utilisateur : Pas un décalage, pas un nom de ville.
  3. L'appliquer au moment du rendu, pas au moment du stockage : Les horodatages restent en UTC dans la base de données ; la conversion se produit dans la couche d'application lors de l'affichage à l'utilisateur.
// Côté frontend : détecter et envoyer au backend
const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;

// À l'inscription ou à la sauvegarde du profil :
await api.updateProfile({ timezone: detectedTimezone });

// Lors du rendu des dates reçues de l'API :
function formatEventTime(isoString, userTimezone, locale) {
  return new Intl.DateTimeFormat(locale, {
    timeZone: userTimezone,
    dateStyle: 'medium',
    timeStyle: 'short',
  }).format(new Date(isoString));
}

// Utilisation
formatEventTime('2024-08-15T14:30:00Z', 'Asia/Tokyo', 'ja-JP');
// "2024/08/15 23:30"

Côté serveur, lorsque vous devez interpréter « tous les événements d'un utilisateur pour un jour calendaire donné », vous devez convertir son « aujourd'hui » en une plage UTC :

from datetime import datetime
from zoneinfo import ZoneInfo

def get_day_utc_range(date_str: str, user_timezone: str):
    """Convertit une date locale en une plage de datetime UTC."""
    tz = ZoneInfo(user_timezone)
    local_start = datetime.fromisoformat(f"{date_str}T00:00:00").replace(tzinfo=tz)
    local_end = datetime.fromisoformat(f"{date_str}T23:59:59").replace(tzinfo=tz)
    utc_start = local_start.astimezone(ZoneInfo("UTC"))
    utc_end = local_end.astimezone(ZoneInfo("UTC"))
    return utc_start, utc_end

# « Aujourd'hui » pour un utilisateur à Tokyo est une plage UTC différente de celle d'un utilisateur à New York
start, end = get_day_utc_range("2024-08-15", "Asia/Tokyo")
# Requête : WHERE created_at BETWEEN start AND end

Tester la localisation des dates

Les bugs de localisation des dates sont souvent invisibles en développement car les développeurs et les serveurs CI ont tendance à être dans le même fuseau horaire. Les tests doivent couvrir explicitement :

  1. Plusieurs locales : tester au minimum en-US, de-DE, ja-JP, ar-SA (RTL + chiffres différents)
  2. Dates de transition DST : mars et novembre (hémisphère nord), septembre et avril (hémisphère sud)
  3. Cas limites de fuseau horaire : tester avec UTC-12, UTC+14, Inde (UTC+5:30), Népal (UTC+5:45)
  4. Frontières d'année/mois : du 31 décembre au 1er janvier selon les fuseaux horaires
// Jest : tester avec des dates fixes pour éviter l'instabilité
describe('formatEventTime', () => {
  const testCases = [
    {
      input: '2024-03-31T01:30:00Z', // Transition DST en Europe
      timezone: 'Europe/Berlin',
      locale: 'de-DE',
      expected: '31.03.2024, 03:30', // Avance à 03:30
    },
    {
      input: '2024-08-15T14:30:00Z',
      timezone: 'Asia/Kolkata',
      locale: 'hi-IN',
      expected: '15/8/2024, 8:00 pm', // UTC+5:30
    },
  ];

  test.each(testCases)('formate correctement pour $locale dans $timezone', ({ input, timezone, locale, expected }) => {
    expect(formatEventTime(input, timezone, locale)).toBe(expected);
  });
});

Pour les environnements CI, définissez explicitement la variable d'environnement TZ sur UTC pour éviter que les tests ne dépendent du fuseau horaire local du serveur :

TZ=UTC npx jest

La gestion des dates est également étroitement liée à d'autres conventions spécifiques aux locales qui font trébucher les produits mondiaux. Si vous développez pour plusieurs marchés, les règles de pluralisation dans différentes langues et la mise en œuvre correcte du SEO i18n avec des balises hreflang sont des domaines connexes à examiner avec la gestion des fuseaux horaires. Les équipes sérieuses sur les tests d'internationalisation devraient également consulter les stratégies complètes de tests i18n qui vont au-delà des simples formats de date.


Où Better i18n s'intègre

Le formatage manuel des dates est résolvable — l'API Intl et des bibliothèques comme Babel gèrent bien la couche de rendu. Le problème plus difficile est la mise à l'échelle : lorsque vous avez une application servant 20 locales, les préférences de format de date doivent être cohérentes dans chaque composant, chaque modèle d'e-mail et chaque export. Cette cohérence se dégrade à mesure que les équipes grandissent.

Better i18n répond à cela en permettant à votre code d'application de référencer des clés de format conscientes de la locale plutôt que de coder en dur les options Intl dans chaque composant. Quand un format doit changer pour une locale — disons que vous découvrez que vos utilisateurs allemands préfèrent un style de date différent — vous le mettez à jour en un seul endroit plutôt que de chercher dans les fichiers de composants.

Pour les applications React, la page des fonctionnalités couvre les intégrations qui combinent le contexte de locale avec le formatage des dates, afin que les composants affichent toujours les dates dans le format préféré de la locale active sans que chaque composant gère sa propre instance de Intl.DateTimeFormat.

La page des fonctionnalités couvre la livraison CDN des données de locale, ce qui importe pour le formatage des dates car l'ensemble complet de données CLDR pour toutes les locales est volumineux. Le chargement différé des données de locale à la demande — plutôt que de tout regrouper — maintient des poids de page initiaux bas sans sacrifier la correction.


Résumé

La localisation des dates et heures n'est pas un problème cosmétique. Les bugs qu'elle produit sont des bugs de correction : des événements au mauvais jour, des horodatages qui se décalent après le DST, des dates qui signifient des choses différentes pour les utilisateurs dans différentes régions.

Les pratiques qui préviennent la plupart des problèmes :

  • Stocker et transmettre ISO 8601 UTC partout : bases de données, APIs, logs
  • Stocker les noms de fuseau horaire IANA (ex. America/New_York) à côté des enregistrements utilisateur, pas des décalages bruts
  • Utiliser Intl.DateTimeFormat en JavaScript plutôt que des chaînes de format manuelles
  • Utiliser Babel (Python) ou twitter_cldr (Ruby) pour le formatage conscient de la locale dans le code backend
  • Ne jamais coder en dur les décalages de fuseau horaire — utiliser la base de données IANA via la bibliothèque standard de votre environnement d'exécution
  • Tester explicitement sur les dates de transition DST, dans plusieurs fuseaux horaires et avec plusieurs locales

Les couches de stockage et de transmission sont largement indépendantes du langage : ISO 8601 et UTC fonctionnent partout. La couche d'affichage est là où vit la logique spécifique à la locale, et c'est là que les outils et les bibliothèques font le plus gagner du temps. Une base solide dans la gestion des dates/heures fait partie de la discipline plus large de la localisation de contenu global — faire en sorte que chaque aspect de votre produit se sente natif pour chaque marché que vous servez.


Rendez votre application mondiale avec better-i18n

better-i18n combine des traductions alimentées par l'IA, des workflows natifs git et une livraison CDN mondiale en une plateforme orientée développeur. Arrêtez de gérer des feuilles de calcul et commencez à livrer dans chaque langue.

Commencer gratuitement → · Explorer les fonctionnalités · Lire la documentation

Comments

Loading comments...