Como lidar com timezone em aplicações web

Lidar com timezone em aplicações web é um dos desafios mais persistentes no desenvolvimento de software moderno. Um erro aparentemente simples — como armazenar uma data sem considerar o fuso horário do usuário — pode gerar inconsistências graves, desde notificações enviadas no horário errado até registros financeiros incorretos. Este artigo aborda os fundamentos, estratégias práticas e armadilhas comuns para gerenciar timezones de forma robusta.

1. Fundamentos sobre timezone no ecossistema web

O primeiro princípio é claro: armazene tudo em UTC. UTC (Coordinated Universal Time) é o padrão de tempo independente de fusos horários e horário de verão. Ao salvar timestamps em UTC, você mantém um ponto de referência único e evita ambiguidades.

É crucial entender a diferença entre:
- Timezone: região geográfica com regras de horário (ex: America/Sao_Paulo)
- Offset: diferença em horas/minutos em relação ao UTC (ex: -03:00)
- Horário de verão (DST): ajuste sazonal que altera o offset temporariamente

O IANA Time Zone Database (tz database) é a base de dados oficial que mantém essas regras atualizadas. Sistemas modernos confiam nela para resolver conversões corretamente.

// Exemplo: diferença entre timestamp e timezone
// Timestamp UTC: 2025-01-15T14:00:00Z
// Timezone America/Sao_Paulo: 2025-01-15T11:00:00-03:00 (horário padrão)
// Timezone America/Sao_Paulo com DST: 2025-01-15T10:00:00-02:00 (se aplicável)

2. Coletando o timezone do usuário no frontend

No navegador, a API Intl oferece a maneira mais confiável de detectar o timezone do usuário:

const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
console.log(userTimezone); // "America/Sao_Paulo", "Asia/Tokyo", etc.

Para maior precisão, combine com geolocalização:

if ("geolocation" in navigator) {
  navigator.geolocation.getCurrentPosition(
    (position) => {
      // Enviar coordenadas para o backend que retorna timezone via API
      fetch(`/api/timezone?lat=${position.coords.latitude}&lng=${position.coords.longitude}`)
        .then(res => res.json())
        .then(data => console.log(data.timezone));
    },
    () => console.log("Usando fallback:", userTimezone)
  );
}

Fallbacks importantes: para usuários com JavaScript desabilitado, use cabeçalhos HTTP como Accept-Language ou permita configuração manual no perfil.

3. Armazenamento e manipulação de datas no backend

No servidor, sempre armazene timestamps como UTC:

// Armazenamento em ISO 8601 (recomendado)
"2025-01-15T14:00:00.000Z"

// Ou como Unix timestamp (segundos desde epoch)
1736949600

Cuidados com bancos de dados:
- TIMESTAMP WITH TIME ZONE (PostgreSQL): armazena em UTC internamente, mas aceita conversão
- TIMESTAMP WITHOUT TIME ZONE: perigoso — não guarda informação de fuso, assumindo timezone da sessão

Bibliotecas recomendadas:

Node.js/JavaScript (date-fns-tz):

const { format, utcToZonedTime } = require('date-fns-tz');

const utcDate = new Date('2025-01-15T14:00:00Z');
const timezone = 'America/Sao_Paulo';
const zonedDate = utcToZonedTime(utcDate, timezone);
const result = format(zonedDate, 'yyyy-MM-dd HH:mm:ssXXX', { timeZone: timezone });
// "2025-01-15 11:00:00-03:00"

Luxon:

const { DateTime } = require('luxon');

const dt = DateTime.fromISO('2025-01-15T14:00:00Z', { zone: 'utc' });
const local = dt.setZone('America/Sao_Paulo');
console.log(local.toFormat('yyyy-MM-dd HH:mm')); // "2025-01-15 11:00"

4. Exibição de datas no fuso do usuário

No frontend, converta UTC para o timezone local:

const utcDate = new Date('2025-01-15T14:00:00Z');

const options = {
  timeZone: 'America/Sao_Paulo',
  year: 'numeric',
  month: 'long',
  day: 'numeric',
  hour: '2-digit',
  minute: '2-digit'
};

const formatter = new Intl.DateTimeFormat('pt-BR', options);
console.log(formatter.format(utcDate)); // "15 de janeiro de 2025 11:00"

Estratégia de renderização: para evitar discrepâncias entre server-side e client-side (hydration), envie timestamps UTC brutos e formate apenas no cliente. Se precisar renderizar no servidor, use o timezone do cabeçalho Accept-Language ou um cookie configurado.

5. Lidando com horário de verão (DST)

DST introduz dois problemas clássicos:
- Horário inexistente: quando o relógio adianta, 1h da manhã "pula" para 2h
- Horário ambíguo: quando o relógio atrasa, 1h da manhã ocorre duas vezes

Bibliotecas como Luxon tratam isso automaticamente:

const { DateTime } = require('luxon');

// Horário ambíguo (DST termina)
const ambiguous = DateTime.fromISO('2025-02-16T01:30:00', {
  zone: 'America/Sao_Paulo'
});
console.log(ambiguous.offset); // -02:00 ou -03:00? Luxon escolhe o primeiro

// Para resolver explicitamente:
const first = DateTime.fromISO('2025-02-16T01:30:00', {
  zone: 'America/Sao_Paulo',
  setZone: true
}).set({ offset: -2 }); // Força offset do DST

6. Agendamentos e recorrências sensíveis a timezone

Para eventos recorrentes, nunca armazene apenas o offset — use o timezone IANA completo:

// Correto
{
  "event": "Reunião semanal",
  "startTime": "10:00",
  "timezone": "America/Sao_Paulo",
  "rrule": "FREQ=WEEKLY;BYDAY=MO"
}

// Errado (offset muda com DST)
{
  "event": "Reunião semanal",
  "startTime": "10:00",
  "offset": "-03:00",
  "rrule": "FREQ=WEEKLY;BYDAY=MO"
}

Para notificações, converta sempre para UTC do momento do agendamento:

// Agendamento para 10/03/2025 às 10:00 em São Paulo
const eventDate = DateTime.fromISO('2025-03-10T10:00:00', {
  zone: 'America/Sao_Paulo'
});
const utcTimestamp = eventDate.toUTC().toISO();
// Enviar notificação quando UTC atingir esse timestamp

7. Testes e validação de timezone

Crie testes que simulem diferentes fusos e cenários de DST:

// Exemplo com Jest
describe('Timezone conversion', () => {
  test('should convert UTC to America/Sao_Paulo correctly', () => {
    const utcDate = new Date('2025-01-15T14:00:00Z');
    const result = convertToTimezone(utcDate, 'America/Sao_Paulo');
    expect(result.getHours()).toBe(11); // -3h no horário padrão
  });

  test('should handle DST transition correctly', () => {
    // 16/02/2025 fim do DST em SP (relógio atrasa)
    const utcDate = new Date('2025-02-16T03:00:00Z');
    const result = convertToTimezone(utcDate, 'America/Sao_Paulo');
    expect(result.getHours()).toBe(0); // Volta para 00:00
  });
});

Use ferramentas como timezone-mock para simular fusos em ambiente de teste:

const timezoneMock = require('timezone-mock');

timezoneMock.register('America/Sao_Paulo');
// Todos os Date() agora usam esse fuso

8. Boas práticas e armadilhas comuns

Evite:
- Converter strings manualmente com new Date('2025-01-15') — isso assume timezone local
- Usar Date.parse() sem especificar timezone
- Confiar em APIs que retornam datas sem indicar o fuso (ex: "2025-01-15T14:00:00" sem "Z" ou offset)

Checklist final:
1. Armazenamento: sempre UTC, em formato ISO 8601 com "Z" ou Unix timestamp
2. Exibição: converta no frontend usando Intl.DateTimeFormat com timezone do usuário
3. Agendamento: armazene timezone IANA completo, não apenas offset
4. Logging: registre timestamps em UTC + timezone de origem para auditoria

// Log seguro
console.log(`Evento criado em ${new Date().toISOString()} (UTC) pelo usuário no fuso ${userTimezone}`);

Referências