Por que Datas e Números Precisam de Localização

OpenL Team 10/2/2025

TABLE OF CONTENTS

Se você envia globalmente, a mesma string pode significar coisas diferentes para diferentes usuários: 12/01/2025, 1.234, 1,234, 12:00, 00:00. Essas pequenas diferenças não são cosméticas — elas afetam a confiança, compreensão e até mesmo a conformidade legal. Este guia explica por que datas e números devem ser localizados, o que realmente muda por localidade e como implementá-lo corretamente em todo o seu produto.

O que Muda por Localidade

  • Datas: ordem (MDY vs DMY vs YMD), separadores, nomes de meses/dias; alguns mercados usam calendários não gregorianos.
  • Horários: relógios de 12 horas vs 24 horas, marcadores AM/PM, fusos horários, regras de horário de verão, início da semana (domingo vs segunda-feira).
  • Números: separadores decimais (. vs ,), estilos de agrupamento (1,234 vs 1.234 vs 1 234), agrupamento indiano (1,23,456), espaços não separáveis, estilos de menos/mais (hífen vs verdadeiro menos ).
  • Moeda: símbolo vs código ISO, colocação do símbolo (prefixo/sufixo), espaçamento estreito vs amplo, precisão decimal (0, 2 ou 3+), negativos contábeis (por exemplo, (1 234,56 €)).
  • Percentuais e unidades: espaçamento e colocação do sinal (50% vs 50 %), nomes locais para unidades, métrico vs imperial.

Impacto nos Negócios

  • Confiança e conversão: Preços, taxas e datas que “parecem erradas” reduzem compras e aumentam a rotatividade.
  • Risco operacional: Datas mal interpretadas podem alterar reservas ou prazos; erros de análise quebram faturas, exportações e análises.
  • Conformidade: Documentos financeiros com formatos errados podem violar regras de faturamento, impostos ou relatórios.
  • Carga de suporte: Usuários abrem tickets para esclarecer horários, moedas e entradas de números que não correspondem às expectativas.

Incidente do Mundo Real

Uma plataforma europeia de reservas de viagens exibiu datas de partida como 01/03/2024 sem contexto de localidade. Clientes dos EUA interpretaram isso como “3 de janeiro”, enquanto clientes do Reino Unido e europeus leram como “1 de março”.

Impacto:

  • 12% das reservas internacionais foram feitas para datas erradas
  • O serviço de atendimento ao cliente recebeu mais de 3.400 tickets de esclarecimento em uma semana
  • $2,3M em reembolsos e taxas de remarcação
  • 8% dos clientes afetados migraram para concorrentes

Causa raiz: Formato DD/MM/YYYY codificado em confirmações de e-mail enquanto o site usava MM/DD/YYYY para usuários dos EUA. A correção exigiu apenas 3 horas de tempo de engenharia — a ambiguidade custou 800 vezes mais do que a prevenção teria custado.

Lição: Datas sem contexto são uma bomba-relógio para qualquer produto global.

Armadilhas Comuns

  • Formatos codificados: MM/DD/YYYY ou 1,234.56 embutidos na UI, e-mails, PDFs ou exportações CSV.
  • Armazenamento de strings localizadas: Salvar “Dec 1, 2025” em vez de tempo ISO causa erros de análise e desvio de fuso horário.
  • Análise ingênua: Usar padrões de local do servidor; assumir separadores ou relógios de 12/24 horas.
  • Concatenação de strings: Construir “quantidade + moeda” como "$" + 1234.56 em vez de formatação sensível ao local.
  • Ambiguidade de fuso horário: Mostrar horários locais sem zonas explícitas para ações sensíveis ao tempo.
  • Inconsistência do navegador: Safari, Firefox e Chrome podem formatar datas/números de maneira diferente; sempre teste em todos os navegadores-alvo.
  • Lacunas de renderização no lado do servidor: Node.js sem dados completos do ICU (node --with-intl=full-icu) produz formatação incompleta ou incorreta.
  • Armadilhas de desempenho: Criar novas instâncias Intl.* em loops ou ciclos de renderização em vez de armazenar formatadores em cache.

Melhores Práticas

  • Armazenar valores canônicos: Datas/horas como ISO-8601 em UTC; dinheiro como unidades menores (centavos) ou decimais de alta precisão.
  • Renderizar por localidade do usuário: Use APIs sensíveis à localidade (por exemplo, Intl.DateTimeFormat, Intl.NumberFormat) para exibição.
  • Validar e analisar por localidade: Aceitar entrada de localidade quando apropriado; mostrar espaços reservados/exemplos para guiar os usuários.
  • Ser explícito com o tempo: Mostrar abreviações de fuso horário ou deslocamentos; permitir seleção do usuário onde os resultados dependem do tempo.
  • Clareza de moeda: Preferir códigos ISO onde existe ambiguidade; respeitar a colocação de símbolos e espaçamento por localidade.
  • Centralizar formatação: Uma camada de utilidade para todas as superfícies (UI, e-mails, PDFs, exportações) para garantir consistência.
  • Cache de formatadores: Chamadas de construtor Intl.* são caras; criar uma vez e reutilizar instâncias por localidade.

Quando NÃO Localizar

Nem tudo deve ser localizado. Aqui estão cenários onde formatos consistentes e legíveis por máquina são melhores:

  • Exportações legíveis por máquina: Arquivos CSV consumidos por pipelines de dados ou ferramentas de análise devem usar um formato fixo (por exemplo, datas ISO-8601, ponto como separador decimal). Documente este formato claramente nos cabeçalhos de exportação ou arquivos README.
  • Respostas de API: APIs REST/GraphQL devem retornar datas como strings ISO-8601 e números no formato padrão JSON. Deixe os clientes lidarem com a localização com base nas preferências de seus usuários.
  • Logs internos e métricas: Logs, painéis de monitoramento e consultas de banco de dados se beneficiam de formatos consistentes para análise, agregação e alerta. Use ISO-8601 e formatos numéricos padrão.
  • Identificadores canônicos: IDs de transação, números de pedido ou referências que os usuários podem precisar comunicar entre localidades devem evitar formatação específica de localidade.

Regra prática: Localize para humanos que leem conteúdo; use formatos padrão para máquinas que processam dados.

Plano de Implementação

  1. Superfícies de auditoria: Inventário de datas, horários, números, moeda na UI, e-mails, PDFs, exportações CSV/Excel, análises, logs.
  2. Definir locais: Listar locais suportados e padrões de fallback; especificar política de 12/24 horas por local.
  3. Construir utilitários: Envolver APIs Intl.* (ou uma biblioteca) com ajudantes e testes compartilhados; implementar cache de formatadores.
  4. Estratégia de entrada: Atualizar formulários para aceitar e validar formatos de locais; armazenar valores canônicos juntamente com a entrada bruta.
  5. Regras de conteúdo: Documentar estilo para datas curtas/longas, datas relativas, exibição de moeda e formatação de porcentagem.
  6. Implementação: Converter primeiro as superfícies de maior tráfego e maior risco; lançar por trás de um sinalizador de recurso, se necessário.

Estratégia de Teste

Além de QA manual, implementar testes automatizados para evitar regressões:

  • Testes de snapshot: Congelar saídas formatadas para cada local suportado. Mudanças desencadeiam revisão para garantir que são intencionais.
  • Testes de ida e volta: Verificar que format → parse → format produz resultados estáveis. Detecta perda de precisão ou formatos ambíguos.
  • Casos extremos: Testar números negativos, zero, números muito grandes (bilhões/trilhões), transições de DST, anos bissextos e datas de limite (1 de janeiro, 31 de dezembro).
  • Validação entre navegadores: Testes automatizados no Chrome, Firefox, Safari e Edge para detectar diferenças de renderização.
  • Fallback de local: Verificar degradação graciosa quando locais não suportados são solicitados.
  • Benchmarks de desempenho: Medir custo de criação de formatador; garantir que o cache está funcionando em produção.

Dicas de Desempenho

APIs Intl.* são poderosas, mas podem ser lentas se mal utilizadas. Siga estas diretrizes:

// ❌ RUIM: Cria novo formatador em cada chamada
function formatPrice(amount, locale) {
  return new Intl.NumberFormat(locale, { 
    style: 'currency', 
    currency: 'USD' 
  }).format(amount);
}

// ✅ BOM: Cache formatadores por localidade 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); }


- **Cache de instâncias de formatadores**: A construção de `Intl.*` é cara (~1–5ms); formatar com uma instância existente é rápido (~0.01ms).
- **Evite loops**: Nunca crie formatadores dentro de `map()`, `forEach()`, ou loops de renderização.
- **Carregamento preguiçoso de dados de localidade**: Para aplicativos web, considere a divisão de código para carregar apenas localidades necessárias, reduzindo o tamanho do pacote.
- **Servidor**: Use um único formatador por ciclo de requisição/resposta; evite criar por item em grandes conjuntos de dados.

## Lista de Verificação de QA

- Datas são inequívocas em `en-US`, `en-GB`, `de-DE`, `fr-FR`, `hi-IN`, `ja-JP`, `ar-EG`.
- O tempo usa a convenção correta de 12/24 horas; marcadores AM/PM aparecem onde esperado; fusos horários mostrados para fluxos críticos de tempo.
- Números usam separadores decimais e de agrupamento corretos; espaços não quebráveis onde padrão (por exemplo, `fr-FR`).
- A moeda mostra o símbolo/código correto, posicionamento e precisão decimal para cada localidade; negativos exibidos corretamente.
- Entradas aceitam e analisam dados de localidade do usuário (ou impõem claramente um formato suportado); mensagens de validação são sensíveis à localidade.
- Exportações (CSV/PDF) correspondem às expectativas de localidade ou documentam claramente formatos fixos para consumo por máquina.
- Cross-browser: Datas, horários e números são renderizados de forma consistente no Chrome, Firefox, Safari e Edge.
- Desempenho: Nenhuma criação de formatador em loops; instâncias em cache reutilizadas em renderizações.

## Exemplos de Código

### JavaScript

```js
// Datas
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 (day/month/year)

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

// Times with timezone display
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

// Numbers
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 as group separator)

// Indian numbering
console.log(new Intl.NumberFormat('hi-IN').format(1234567.89)); // → 12,34,567.89

// Currency
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 (no decimals)

// Percent and units
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 %

// Formatter caching example
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

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

# Datas
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

# Horários com fuso horário
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

# Numeração indiana
print(format_number(1234567.89, locale='hi_IN'))  # → 12,34,567.89

# Moeda
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

# Percentual
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;

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

// Horários
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

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

```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

    // Moeda (usando pacote number)
    fmt.Println(pUS.Sprint(number.Decimal(n, number.Scale(2))))  // → 1,234.56

    // Datas - O pacote time do Go usa layouts em vez de formatação de localidade
    // Para formatação completa de datas i18n, use github.com/goodsign/monday ou 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: A biblioteca padrão do Go tem suporte limitado a localidades. Para uso em produção, considere:

Recursos Úteis

Fechar

Acertar datas e números é uma vitória de baixo esforço e alto impacto: maior confiança, menos erros e uma experiência global mais suave. Centralize a formatação, armazene valores canônicos, use APIs sensíveis à localidade em todos os lugares onde seus usuários leem ou digitam tempo, dinheiro ou números — e saiba quando pular a localização para dados legíveis por máquina. Com cache e testes adequados, você construirá um sistema robusto que se escala entre localidades sem sacrificar o desempenho.