O Pesadelo dos Timezones: Manipulando Datas com date-fns
UTC como padrão universal, ISO 8601 no transporte, date-fns vs Temporal API, date-fns-tz para conversões de timezone, armadilhas do new Date() e como estruturar lógica de negócio com datas.
Datas e fusos horários são responsáveis por uma categoria inteira de bugs difíceis de reproduzir: o agendamento que aparece uma hora errada para o cliente de Manaus, o relatório de vendas que muda de total dependendo do servidor, o cron que dispara no horário errado no horário de verão. Esses bugs não aparecem em desenvolvimento local e chegam em produção às 2h da manhã.
A raiz do problema é a confusão entre instante (um ponto específico no tempo, independente de fuso) e representação local (como esse instante é exibido num fuso específico). Este artigo estabelece os princípios para trabalhar corretamente com datas em aplicações Node.js e React, usando date-fns e date-fns-tz.
O Princípio Fundamental: UTC no Servidor, Local no Cliente
- Frontend — captura a data/hora no fuso do usuário, converte para UTC e envia no formato ISO 8601 com sufixo
Z(ex:2024-12-01T17:00:00.000Z) - Backend — armazena em UTC no banco. Faz cálculos, comparações e lógica de negócio sempre em UTC. Nunca converte para timezone regional.
- Frontend — recebe as datas do backend em UTC e converte para o timezone local do usuário apenas na camada de exibição.
O banco de dados deve usar o tipo TIMESTAMP WITH TIME ZONE (PostgreSQL) ou DATETIME armazenado como UTC (MySQL com explicit_defaults_for_timestamp=ON). Configure o TypeORM para usar UTC:
import { DataSource } from 'typeorm';
export const dataSource = new DataSource({
type: 'postgres',
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT ?? 5432),
username: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
// Garante que o TypeORM e o PostgreSQL conversam em UTC
// Sem isso, o PostgreSQL pode aplicar o timezone do servidor
extra: {
options: '-c timezone=UTC',
},
});
// Variável de ambiente do sistema também deve ser UTC:
// process.env.TZ = 'UTC'; // No topo do entry point, antes de qualquer import de data
// Ou no script de start: TZ=UTC node dist/server.jsArmadilhas do new Date() no Node.js
O objeto Date do JavaScript é famoso por suas pegadinhas. Aqui estão as mais comuns:
// ❌ ARMADILHA 1: Parsing de string sem timezone
// Comportamento diferente em browsers vs Node.js!
new Date('2024-12-01') // Interpreted as UTC midnight: 2024-12-01T00:00:00Z
new Date('2024-12-01T14:00:00') // Interpreted as LOCAL time: depende do TZ do sistema!
// ✅ CORRETO: sempre use ISO 8601 com timezone explícito
new Date('2024-12-01T14:00:00Z') // UTC explícito
new Date('2024-12-01T14:00:00-03:00') // Brasília explícito (converte para UTC internamente)
// ❌ ARMADILHA 2: toLocaleDateString() usa o timezone do SERVIDOR
// Se o servidor está em UTC, não mostrará o horário de Brasília
date.toLocaleDateString('pt-BR') // Resultado depende do TZ do servidor!
// ❌ ARMADILHA 3: Comparação de datas
// datas são objetos — '===' compara referências, não valores
const d1 = new Date('2024-01-01');
const d2 = new Date('2024-01-01');
d1 === d2 // false! Referências diferentes
d1.getTime() === d2.getTime() // true ✅
// Use date-fns: isEqual(d1, d2) ✅date-fns: Imutável e Tree-shakeable
O date-fns é a alternativa moderna ao Moment.js: imutável (cada função retorna uma nova data), tree-shakeable (você importa apenas o que usa), e puramente funcional.
npm install date-fns date-fns-tzimport {
startOfHour,
isBefore,
isAfter,
isEqual,
addHours,
differenceInMinutes,
format,
parseISO,
} from 'date-fns';
import { ptBR } from 'date-fns/locale';
export class CreateAppointmentUseCase {
async execute({ dateISO, providerId, customerId }: CreateAppointmentDTO) {
// parseISO: garante parsing correto de string ISO 8601
// NUNCA use new Date(dateString) com strings sem timezone
const requestedDate = parseISO(dateISO);
// Agendamentos só ocorrem em horas cheias
// startOfHour: trunca os minutos e segundos (14:37 → 14:00)
const appointmentDate = startOfHour(requestedDate);
// Impede agendamento no passado
if (isBefore(appointmentDate, new Date())) {
throw new AppError('Não é possível agendar em datas passadas.');
}
// Horário comercial: 8h às 17h (última hora é 17:00 para encerrar às 18h)
const openingHour = 8;
const closingHour = 17;
const hour = appointmentDate.getUTCHours(); // UTC hour
if (hour < openingHour || hour > closingHour) {
throw new AppError('Agendamentos apenas entre 08:00 e 17:00.');
}
// Verifica conflito de horário
const existingAppointment = await this.appointmentsRepo.findByDateAndProvider(
appointmentDate,
providerId
);
if (existingAppointment) {
throw new AppError('Este horário já está reservado.');
}
const appointment = await this.appointmentsRepo.create({
id: crypto.randomUUID(),
date: appointmentDate,
providerId,
customerId,
});
// Para logs/notificações: formata em PT-BR
const formattedDate = format(
appointmentDate,
"EEEE, d 'de' MMMM 'às' HH:mm",
{ locale: ptBR }
);
// Resultado: 'segunda-feira, 1 de dezembro às 14:00'
return { appointment, formattedDate };
}
}date-fns-tz: Conversões de Timezone
Para sistemas multi-timezone (usuários em fusos diferentes), use date-fns-tz para converter entre timezones de forma explícita:
import { toZonedTime, fromZonedTime, format } from 'date-fns-tz';
// Cenário: exibir horário de um evento para usuários em fusos diferentes
function displayEventTime(utcDate: Date, userTimezone: string): string {
// Converte o instante UTC para a representação local do timezone do usuário
const zonedDate = toZonedTime(utcDate, userTimezone);
// Formata exibindo o offset do timezone
return format(zonedDate, "dd/MM/yyyy HH:mm (zzz)", { timeZone: userTimezone });
}
// Evento armazenado: 2024-12-01T17:00:00Z (UTC)
const eventUTC = new Date('2024-12-01T17:00:00Z');
console.log(displayEventTime(eventUTC, 'America/Sao_Paulo'));
// → '01/12/2024 14:00 (GMT-3)' (horário de Brasília)
console.log(displayEventTime(eventUTC, 'America/Manaus'));
// → '01/12/2024 13:00 (GMT-4)' (horário de Manaus)
console.log(displayEventTime(eventUTC, 'America/New_York'));
// → '01/12/2024 12:00 (GMT-5)' (horário de Nova York)
// Cenário inverso: usuário informou horário LOCAL, converte para UTC
function userLocalToUTC(localDateString: string, userTimezone: string): Date {
// Interpreta a string como horário local do timezone do usuário
// e converte para UTC
return fromZonedTime(localDateString, userTimezone);
}
// Usuário em Manaus diz: 'quero agendar para 14:00 de 01/12'
const utcForStorage = userLocalToUTC('2024-12-01 14:00:00', 'America/Manaus');
console.log(utcForStorage.toISOString());
// → '2024-12-01T18:00:00.000Z' (14:00 em Manaus = 18:00 UTC)Formatação no Frontend React
O frontend recebe datas sempre em UTC (ISO 8601 com Z) e deve converter para o timezone local do usuário para exibição:
import { format, formatRelative, formatDistanceToNow } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { toZonedTime } from 'date-fns-tz';
// Timezone do usuário detectado automaticamente pelo browser
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// → 'America/Sao_Paulo' (ou o timezone do usuário)
export function formatLocalDate(utcDateString: string): string {
const utcDate = new Date(utcDateString); // ISO 8601 com Z → parse correto
const localDate = toZonedTime(utcDate, userTimezone);
return format(localDate, "dd/MM/yyyy 'às' HH:mm", { locale: ptBR });
}
export function formatRelativeDate(utcDateString: string): string {
const utcDate = new Date(utcDateString);
// 'há 2 dias', 'em 3 horas', 'há 5 minutos'
return formatDistanceToNow(utcDate, { addSuffix: true, locale: ptBR });
}
// Para inputs de data e hora que o usuário preenche:
// 1. Capture com <input type="datetime-local"> (retorna horário local)
// 2. Converta com fromZonedTime(value, userTimezone) antes de enviar à API
// 3. A API recebe e armazena em UTCA Temporal API é o futuro do JavaScript para manipulação de datas — com suporte nativo a timezones, durações e calendários. Ainda está em Stage 3 no TC39 (2024). Quando chegar ao Stage 4 e ter suporte amplo, será a substituição definitiva para date-fns. Enquanto isso, date-fns + date-fns-tz é o stack recomendado.
Conclusão
O modelo correto é simples: UTC no transporte e armazenamento, timezone local apenas na exibição. O date-fns facilita a lógica de negócio com funções imutáveis e tree-shakeable. O date-fns-tz resolve conversões explícitas quando você precisa. E parseISO ao invés de new Date(string) evita a classe mais comum de bugs com datas no JavaScript.