Por qué las fechas y los números necesitan localización

OpenL Team 10/2/2025

TABLE OF CONTENTS

Si envías a nivel mundial, la misma cadena puede significar cosas diferentes para distintos usuarios: 12/01/2025, 1.234, 1,234, 12:00, 00:00. Esas pequeñas diferencias no son cosméticas: afectan la confianza, la comprensión e incluso el cumplimiento legal. Esta guía explica por qué las fechas y los números deben ser localizados, qué cambia realmente según la localidad y cómo implementarlo correctamente en todo tu producto.

Qué Cambia Según la Localidad

  • Fechas: orden (MDY vs DMY vs YMD), separadores, nombres de meses/días; algunos mercados usan calendarios no gregorianos.
  • Horas: relojes de 12 horas vs 24 horas, marcadores AM/PM, zonas horarias, reglas de horario de verano, inicio de la semana (domingo vs lunes).
  • Números: separadores decimales (. vs ,), estilos de agrupación (1,234 vs 1.234 vs 1 234), agrupación india (1,23,456), espacios de no separación, estilos de menos/más (guion vs verdadero menos ).
  • Moneda: símbolo vs código ISO, colocación del símbolo (prefijo/sufijo), espaciado estrecho vs amplio, precisión decimal (0, 2 o 3+), negativos contables (por ejemplo, (1 234,56 €)).
  • Porcentaje y unidades: espaciado y colocación del signo (50% vs 50 %), nombres locales para unidades, métrico vs imperial.

Impacto en el Negocio

  • Confianza y conversión: Precios, tarifas y fechas que “parecen incorrectos” reducen las compras y aumentan la rotación.
  • Riesgo operativo: Las fechas mal interpretadas pueden cambiar reservas o plazos; los errores de análisis rompen facturas, exportaciones y análisis.
  • Cumplimiento: Los documentos financieros con formatos incorrectos pueden violar las reglas de facturación, impuestos o informes.
  • Carga de soporte: Los usuarios abren tickets para aclarar horas, monedas y entradas de números que no coinciden con las expectativas.

Incidente en el Mundo Real

Una plataforma europea de reservas de viajes mostró las fechas de salida como 01/03/2024 sin contexto de localidad. Los clientes de EE. UU. interpretaron esto como “3 de enero” mientras que los clientes del Reino Unido y Europa lo leyeron como “1 de marzo”.

Impacto:

  • El 12% de las reservas internacionales se realizaron para las fechas incorrectas
  • El servicio al cliente recibió más de 3,400 tickets de aclaración en una semana
  • $2.3M en reembolsos y tarifas de reprogramación
  • El 8% de los clientes afectados se cambiaron a la competencia

Causa raíz: Formato DD/MM/YYYY codificado en las confirmaciones por correo electrónico mientras que el sitio web utilizaba MM/DD/YYYY para los usuarios de EE. UU. La solución requirió solo 3 horas de tiempo de ingeniería: la ambigüedad costó 800 veces más que la prevención.

Lección: Las fechas sin contexto son una bomba de tiempo para cualquier producto global.

Errores Comunes

  • Formatos codificados: MM/DD/YYYY o 1,234.56 integrados en la interfaz de usuario, correos electrónicos, PDFs o exportaciones CSV.
  • Almacenamiento de cadenas localizadas: Guardar “Dec 1, 2025” en lugar de tiempo ISO causa errores de análisis y desajuste de zona horaria.
  • Análisis ingenuo: Usar configuraciones regionales del servidor por defecto; asumir separadores o relojes de 12/24 horas.
  • Concatenación de cadenas: Construir “monto + moneda” como "$" + 1234.56 en lugar de formateo consciente de la localidad.
  • Ambigüedad de zona horaria: Mostrar horas locales sin zonas explícitas para acciones sensibles al tiempo.
  • Inconsistencia del navegador: Safari, Firefox y Chrome pueden formatear fechas/números de manera diferente; siempre probar en todos los navegadores objetivo.
  • Brechas en el renderizado del lado del servidor: Node.js sin datos completos de ICU (node --with-intl=full-icu) produce formateo incompleto o incorrecto.
  • Trampas de rendimiento: Crear nuevas instancias de Intl.* en bucles o ciclos de renderizado en lugar de almacenar en caché los formateadores.

Mejores Prácticas

  • Almacenar valores canónicos: Fechas/horas como ISO‑8601 en UTC; dinero en unidades menores (centavos) o decimales de alta precisión.
  • Renderizar por configuración regional del usuario: Utilizar APIs conscientes de la configuración regional (por ejemplo, Intl.DateTimeFormat, Intl.NumberFormat) para la visualización.
  • Validar y analizar por configuración regional: Aceptar la entrada de configuración regional cuando sea apropiado; mostrar marcadores de posición/ejemplos para guiar a los usuarios.
  • Ser explícito con el tiempo: Mostrar abreviaturas de zona horaria o compensaciones; permitir la selección del usuario donde los resultados dependen del tiempo.
  • Claridad de moneda: Preferir códigos ISO donde exista ambigüedad; respetar la colocación de símbolos y el espaciado por configuración regional.
  • Centralizar el formato: Una capa de utilidad para todas las superficies (UI, correos electrónicos, PDFs, exportaciones) para asegurar la consistencia.
  • Cachear formateadores: Las llamadas al constructor Intl.* son costosas; crear una vez y reutilizar instancias por configuración regional.

Cuándo NO Localizar

No todo debe ser localizado. Aquí hay escenarios donde los formatos consistentes y legibles por máquina son mejores:

  • Exportaciones legibles por máquina: Los archivos CSV consumidos por tuberías de datos o herramientas de análisis deben usar un formato fijo (por ejemplo, fechas ISO-8601, punto como separador decimal). Documentar este formato claramente en los encabezados de exportación o archivos README.
  • Respuestas de API: Las APIs REST/GraphQL deben devolver fechas como cadenas ISO-8601 y números en formato JSON estándar. Permitir que los clientes manejen la localización según las preferencias de sus usuarios.
  • Registros internos y métricas: Los registros, paneles de monitoreo y consultas de bases de datos se benefician de formatos consistentes para el análisis, la agregación y las alertas. Usar ISO-8601 y formatos numéricos estándar.
  • Identificadores canónicos: Los IDs de transacción, números de pedido o referencias que los usuarios puedan necesitar comunicar a través de configuraciones regionales deben evitar el formato específico de la configuración regional.

Regla general: Localizar para humanos que leen contenido; usar formatos estándar para máquinas que procesan datos.

Plan de Implementación

  1. Superficies de auditoría: Fechas de inventario, horas, números, moneda en la interfaz de usuario, correos electrónicos, PDFs, exportaciones CSV/Excel, analíticas, registros.
  2. Definir locales: Listar los locales soportados y sus valores predeterminados; especificar la política de 12/24 horas por local.
  3. Construir utilidades: Envolver las APIs Intl.* (o una biblioteca) con ayudantes compartidos y pruebas; implementar almacenamiento en caché del formateador.
  4. Estrategia de entrada: Actualizar formularios para aceptar y validar formatos locales; almacenar valores canónicos junto con la entrada en bruto.
  5. Reglas de contenido: Documentar el estilo para fechas cortas/largas, fechas relativas, visualización de moneda y formato de porcentaje.
  6. Despliegue: Convertir primero las superficies de mayor tráfico y mayor riesgo; implementar detrás de un flag de característica si es necesario.

Estrategia de Pruebas

Más allá del control de calidad manual, implementar pruebas automatizadas para prevenir regresiones:

  • Pruebas de instantáneas: Congelar salidas formateadas para cada local soportado. Los cambios desencadenan una revisión para asegurar que son intencionales.
  • Pruebas de ida y vuelta: Verificar que format → parse → format produce resultados estables. Detecta pérdida de precisión o formatos ambiguos.
  • Casos límite: Probar números negativos, cero, números muy grandes (miles de millones/billones), transiciones de horario de verano, años bisiestos y fechas límite (1 de enero, 31 de diciembre).
  • Validación entre navegadores: Pruebas automatizadas en Chrome, Firefox, Safari y Edge para detectar diferencias de renderizado.
  • Degradación de locales: Verificar degradación elegante cuando se solicitan locales no soportados.
  • Puntos de referencia de rendimiento: Medir el costo de creación del formateador; asegurar que el almacenamiento en caché funcione en producción.

Consejos de Rendimiento

Las APIs Intl.* son poderosas pero pueden ser lentas si se usan incorrectamente. Siga estas pautas:

// ❌ MALO: Crea un nuevo formateador en cada llamada
function formatPrice(amount, locale) {
  return new Intl.NumberFormat(locale, { 
    style: 'currency', 
    currency: 'USD' 
  }).format(amount);
}

```js
// ✅ BUENO: Cachear formateadores por localización
const formatters = new Map();
function getFormatter(locale) {
  if (!formatters.has(locale)) {
    formatters.set(locale, new Intl.NumberFormat(locale, { 
      style: 'currency', 
      currency: 'USD' 
    }));
  }
  return formatters.get(locale);
}

function formatPrice(amount, locale) {
  return getFormatter(locale).format(amount);
}
  • Cachear instancias de formateadores: La construcción de Intl.* es costosa (~1–5ms); formatear con una instancia existente es rápido (~0.01ms).
  • Evitar bucles: Nunca crear formateadores dentro de map(), forEach(), o bucles de renderizado.
  • Carga diferida de datos de localización: Para aplicaciones web, considera la división de código para cargar solo las localizaciones requeridas, reduciendo el tamaño del paquete.
  • Lado del servidor: Usar un único formateador por ciclo de solicitud/respuesta; evitar crear por ítem en grandes conjuntos de datos.

Lista de Verificación de QA

  • Las fechas son inequívocas en en-US, en-GB, de-DE, fr-FR, hi-IN, ja-JP, ar-EG.
  • El tiempo utiliza la convención correcta de 12/24 horas; los marcadores AM/PM aparecen donde se espera; las zonas horarias se muestran para flujos críticos de tiempo.
  • Los números usan los separadores decimales y de agrupación correctos; espacios no separables donde es estándar (por ejemplo, fr-FR).
  • La moneda muestra el símbolo/código correcto, la colocación y la precisión decimal para cada localización; los negativos se muestran correctamente.
  • Las entradas aceptan y analizan datos de localización del usuario (o imponen claramente un formato compatible); los mensajes de validación son conscientes de la localización.
  • Las exportaciones (CSV/PDF) coinciden con las expectativas de localización o documentan claramente formatos fijos para el consumo por máquina.
  • Compatibilidad entre navegadores: Las fechas, horas y números se renderizan de manera consistente en Chrome, Firefox, Safari y Edge.
  • Rendimiento: No se crean formateadores en bucles; las instancias en caché se reutilizan en los renderizados.

Ejemplos de Código

JavaScript

// Fechas
const date = new Date('2025-01-12T00:00:00Z');

console.log(new Intl.DateTimeFormat('en-US', { dateStyle: 'short', timeZone: 'UTC' }).format(date));
// → 1/12/25
console.log(new Intl.DateTimeFormat('en-GB', { dateStyle: 'short', timeZone: 'UTC' }).format(date));
// → 12/01/2025 (día/mes/año)

console.log(new Intl.DateTimeFormat('ja-JP', { dateStyle: 'medium', timeZone: 'UTC' }).format(date));
// → 2025/01/12

// Tiempos con visualización de zona horaria
const time = new Date('2025-01-12T18:05:00Z');

console.log(new Intl.DateTimeFormat('en-US', { 
  dateStyle: 'short',
  timeStyle: 'short', 
  timeZone: 'America/New_York',
  timeZoneName: 'short' 
}).format(time));
// → 1/12/25, 1:05 PM EST

console.log(new Intl.DateTimeFormat('de-DE', { 
  timeStyle: 'short', 
  timeZone: 'Europe/Berlin', 
  hour12: false 
}).format(time));
// → 19:05

// Números
const n = 1234.56;
console.log(new Intl.NumberFormat('en-US').format(n)); // → 1,234.56
console.log(new Intl.NumberFormat('de-DE').format(n)); // → 1.234,56
console.log(new Intl.NumberFormat('fr-FR').format(n)); // → 1 234,56 (NBSP como separador de grupo)

// Numeración india
console.log(new Intl.NumberFormat('hi-IN').format(1234567.89)); // → 12,34,567.89

// Moneda
console.log(new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n));
// → $1,234.56

console.log(new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n));
// → 1.234,56 €

console.log(new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(1234));
// → ¥1,234 (sin decimales)

// Porcentaje y unidades
console.log(new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 0 }).format(0.5));
// → 50%
console.log(new Intl.NumberFormat('fr-FR', { style: 'percent', maximumFractionDigits: 0 }).format(0.5));
// → 50 %

// Ejemplo de almacenamiento en caché de formateadores
class LocaleFormatter {
  constructor() {
    this.cache = new Map();
  }

  getDateFormatter(locale, options) {
    const key = `date:${locale}:${JSON.stringify(options)}`;
    if (!this.cache.has(key)) {
      this.cache.set(key, new Intl.DateTimeFormat(locale, options));
    }
    return this.cache.get(key);
  }
}

formatDate(date, locale, options = { dateStyle: ‘medium’ }) { return this.getDateFormatter(locale, options).format(date); } }

const formatter = new LocaleFormatter(); console.log(formatter.formatDate(new Date(), ‘en-US’)); // → Jan 12, 2025 console.log(formatter.formatDate(new Date(), ‘fr-FR’)); // → 12 janv. 2025


### Python

```python
from babel.dates import format_date, format_time, format_datetime
from babel.numbers import format_number, format_currency, format_percent
from datetime import datetime
import pytz

# Fechas
date = datetime(2025, 1, 12)
print(format_date(date, format='short', locale='en_US'))  # → 1/12/25
print(format_date(date, format='short', locale='en_GB'))  # → 12/01/2025
print(format_date(date, format='medium', locale='ja_JP')) # → 2025/01/12

# Horas con zona horaria
tz_ny = pytz.timezone('America/New_York')
tz_berlin = pytz.timezone('Europe/Berlin')
time = datetime(2025, 1, 12, 18, 5, tzinfo=pytz.UTC)

print(format_datetime(time.astimezone(tz_ny), 'short', tzinfo=tz_ny, locale='en_US'))
# → 1/12/25, 1:05 PM

print(format_time(time.astimezone(tz_berlin), format='short', tzinfo=tz_berlin, locale='de_DE'))
# → 19:05

# Números
n = 1234.56
print(format_number(n, locale='en_US'))    # → 1,234.56
print(format_number(n, locale='de_DE'))    # → 1.234,56
print(format_number(n, locale='fr_FR'))    # → 1 234,56

# Numeración india
print(format_number(1234567.89, locale='hi_IN'))  # → 12,34,567.89

# Moneda
print(format_currency(n, 'USD', locale='en_US'))  # → $1,234.56
print(format_currency(n, 'EUR', locale='de_DE'))  # → 1.234,56 €
print(format_currency(1234, 'JPY', locale='ja_JP')) # → ¥1,234

# Porcentaje
print(format_percent(0.5, locale='en_US'))  # → 50%
print(format_percent(0.5, locale='fr_FR'))  # → 50 %

Java

import java.text.NumberFormat;
import java.time.ZonedDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Locale;
import java.util.Currency;

// Fechas
ZonedDateTime date = ZonedDateTime.of(2025, 1, 12, 0, 0, 0, 0, ZoneId.of("UTC"));

DateTimeFormatter usFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
    .withLocale(Locale.US);
System.out.println(usFormatter.format(date)); // → 1/12/25

DateTimeFormatter gbFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
    .withLocale(Locale.UK);
System.out.println(gbFormatter.format(date)); // → 12/01/2025

DateTimeFormatter jpFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
    .withLocale(Locale.JAPAN);
System.out.println(jpFormatter.format(date)); // → 2025/01/12

// Horas
ZonedDateTime time = ZonedDateTime.of(2025, 1, 12, 18, 5, 0, 0, ZoneId.of("UTC"));
ZonedDateTime timeNY = time.withZoneSameInstant(ZoneId.of("America/New_York"));

DateTimeFormatter usTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
    .withLocale(Locale.US);
System.out.println(usTimeFormatter.format(timeNY)); // → 1:05 PM

// Números
double n = 1234.56;
NumberFormat usFormat = NumberFormat.getInstance(Locale.US);
System.out.println(usFormat.format(n)); // → 1,234.56

NumberFormat deFormat = NumberFormat.getInstance(Locale.GERMANY);
System.out.println(deFormat.format(n)); // → 1.234,56

NumberFormat frFormat = NumberFormat.getInstance(Locale.FRANCE);
System.out.println(frFormat.format(n)); // → 1 234,56

// Moneda
NumberFormat usCurrency = NumberFormat.getCurrencyInstance(Locale.US);
usCurrency.setCurrency(Currency.getInstance("USD"));
System.out.println(usCurrency.format(n)); // → $1,234.56

NumberFormat deCurrency = NumberFormat.getCurrencyInstance(Locale.GERMANY);
deCurrency.setCurrency(Currency.getInstance("EUR"));
System.out.println(deCurrency.format(n)); // → 1.234,56 €```

NumberFormat jpCurrency = NumberFormat.getCurrencyInstance(Locale.JAPAN);
jpCurrency.setCurrency(Currency.getInstance("JPY"));
System.out.println(jpCurrency.format(1234)); // → ¥1,234

Go

package main

import (
    "fmt"
    "time"
    "golang.org/x/text/language"
    "golang.org/x/text/message"
    "golang.org/x/text/number"
)

func main() {
    // Números
    n := 1234.56

    pUS := message.NewPrinter(language.AmericanEnglish)
    fmt.Println(pUS.Sprintf("%.2f", n)) // → 1,234.56

    pDE := message.NewPrinter(language.German)
    fmt.Println(pDE.Sprintf("%.2f", n)) // → 1.234,56

    pFR := message.NewPrinter(language.French)
    fmt.Println(pFR.Sprintf("%.2f", n)) // → 1 234,56

    // Moneda (usando el paquete number)
    fmt.Println(pUS.Sprint(number.Decimal(n, number.Scale(2))))  // → 1,234.56

    // Fechas - El paquete time de Go utiliza layouts en lugar de formato de locales
    // Para un formato de fecha completo i18n, use github.com/goodsign/monday o similar
    date := time.Date(2025, 1, 12, 0, 0, 0, 0, time.UTC)
    fmt.Println(date.Format("01/02/2006")) // US: 01/12/2025
    fmt.Println(date.Format("02/01/2006")) // EU: 12/01/2025
    fmt.Println(date.Format("2006/01/02")) // ISO: 2025/01/12
}

Nota: La biblioteca estándar de Go tiene soporte limitado para locales. Para uso en producción, considere:

Recursos Útiles

Cierre

Obtener las fechas y números correctamente es una victoria de bajo esfuerzo y alto impacto: mayor confianza, menos errores y una experiencia global más fluida. Centraliza el formato, almacena valores canónicos, usa APIs conscientes de la configuración regional en todos los lugares donde tus usuarios lean o escriban tiempo, dinero o números, y sabe cuándo omitir la localización para datos legibles por máquina. Con un almacenamiento en caché y pruebas adecuados, construirás un sistema robusto que se escala a través de configuraciones regionales sin sacrificar el rendimiento.