Como "burlar" os efeitos inesperados do timezone (backend e frontend)
Introdução
Trabalhar com datas em algumas linguages de programação e bancos de dados é por muitos considerada uma dor de cabeça. Quem nunca se deparou com issues onde a data/hora de alguma informação está sendo exibida incorretamente? A causa disso está normalmente relacionada ao timezone e também à forma incorreta como esses dados são persistidos e recuperados.
Embora alguns bancos de dados, como o PostgreSQL, possuem tipos específicos para lidarem com date e timestamp (com e sem o timezone), outros não o possuem, ficando a cargo do desenvolvedor escolher o tipo que melhor agrada. E nestes meus anos de experiência, já me deparei com as mais variadas formas de persistir uma data: string no formato ISO 8601, formatação pt-BR, formato timestamp nativo do banco, número, entre outras formas.
O que vamos abordar neste artigo é a solução que considero ser a mais adequada para armazenar e recuperar datas, mas em especial, aquelas que não deveriam sofrer os efeitos do timezone, como data de nascimento, data de vencimento de boleto, data de fundação da empresa, etc, pois em campos como "data de criação do post", "data de publicação", a forma mais correta é timestamp com timezone (para PostgreSQL), ou para ser mais independente de tecnologia, epoch number.
Afinal de contas, se você nasceu no dia 15/04/2001, às 22h00, no Brasil, isto não significa que se você se mudar para o Japão vai passar a comemorar seu aniversário em 16/04, não é mesmo?
Vou demonstrar a implementação no banco de dados, no backend e no frontend. A linguagem que utilizaremos como exemplo será o Typescript para backend, e o Javascript para o frontend. Mas você poderá adaptar a solução para a linguagem de programação que desejar!
Se prepare para também aprender alguns conceitos de DDD (Domain Driven Design), em especial, o conceito de value objects.
Os efeitos (às vezes indesejados) do timezone
Supomos que você está desenvolvendo uma tela de cadastro onde exista o campo "Data de nascimento", e para facilitar sua vida, e também validar se o dado inserido é uma data válida, você adicionou um componente Calendar ou DatePicker da sua biblioteca favorita para o usuário selecionar a data.
E ao submeter o formulário, você requisitou a API:
fetch('/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringfy({
...user
})
})
Até aí nada demais. O problema é que apareceu uma issue dizendo que a data de nascimento estava sendo mostrada de forma errada, ou seja, quando o usuário seleciona o dia 15/04/2000, na tela de seu perfil está aparecendo a data de 14/04/2000. Mas ele só notou isso quando estava em uma viagem para outro país, e que quando estava no Brasil, não notou o erro.
E agora, onde está o problema? No envio, na armazenamento, na exibição, em todos?
Este é um exemplo típico de formato incorreto para datas onde o timezone não pode influenciar no campo. Os navegadores tratam campos do tipo Date
aplicando automaticamente o timezone da máquina do usuário que está acessando a página para melhor experiência do usuário. Assim, essa informação está sendo transmitida e recuperada no formato de data padrão do Javascript.
Experimente agora executar os comandos abaixo no console de seu navegador e altere o timezone de sua máquina para notar diferentes comportamentos. Ele simula uma data que foi criada em um fuso horário diferente do fuso em que está sendo exibida:
const date = new Date('2000-04-05Z')
console.log(date) // Fri Apr 04 2000 21:00:00 GMT-0300 (Brasilia Standard Time)
Agora que conseguimos entender o problema, vamos para a solução.
Banco de dados
A primeira coisa que ficou clara neste exemplo é que o tipo de dados para campos como data de nascimento, feriado, ou seja, datas que não mudam com o timezone, não pode ser timestamp ou numérico (epoch que representa o instante exato em segundos ou milissegundos), mas sim uma string, preferencialmente no formato ISO-8601, contendo somente ano, mês e dia (ex: '2000-04-15'
).
Assim, o DDL para criação da tabela users
seria algo do tipo:
CREATE TABLE users (
id varchar(50),
name varchar(255),
age int,
birth_date varchar(10)
);
Backend utilizando value object
Quanto ao backend, para que possamos ter uma validação de que este campo seja realmente uma data (note que como ele é armazenado como string, o banco de dados vai aceitar qualquer coisa), precisamos criar uma classe que vamos chamar de SafeDate
e que será um value object.
Value objects, em DDD, são tipos especiais de atributos de uma entidade que não possuem identidade (por exemplo, um id), sendo que sua identidade na verdade é o seu próprio valor (ou uma composição de valores dentro da classe). Podem ser considerados value objects campos como datas, valores monetários, endereço, entre outros.
Assim, nosso value object será assim definido:
export class SafeDate {
private _value: string;
get value() {
return this._value;
}
constructor(value: string) {
if (value.length !== 10) throw new Error('invalid date');
const parsedDate = Date.parse(value);
if (Number.isNaN(parsedDate)) throw new Error('invalid date');
this._value = value;
}
}
Basicamente este value object possui uma regra no construtor da classe que não permite criar um SafeDate com data inválida.
Pronto, basta utilizar esse value object na entidade User (não vou entrar no mérito das demais implementações desta entidade):
export class User {
private _birthDate: SafeDate;
get birthDate() {
return this._birthDate;
}
get toJSON() {
return {
birthDate: this._birthDate.value,
// demais campos da entidade
}
}
}
Assim, o valor será sempre armazenado e recuperado no formato string.
Frontend
Para enviar a informação, o único cuidado é não enviar o timezone na requisição à API, já que o campo deve ser string
no formato YYYY-MM-DD
, e não Date
. Então, não tem muito segredo: basta enviar no formato desejado, obtendo a data no formato Date que vem do seu componente de calendário. Aqui o efeito do timezone não será um problema: não importa aonde você esteja, o campo de data será sempre considerando o seu timezone, e isso é o que basta.
Caso esteja usando uma biblioteca como date-fns:
format(user.birthDate, 'yyyy-MM-dd')
Mas para exibir a informação, talvez seja preciso tomar alguns cuidados.
Se você está criando uma aplicação onde será utilizada quase que exclusivamente por brasileiros, você poderá "chumbar" o formato que usamos por aqui:
birthDate = user.birthDate.split('-').reverse().join('/')
Mas eu particularmente não gosto dessa abordagem, já que não podemos pensar "pequeno", e se um dia a aplicação for escalada para o resto do globo, isso pode ser um problema. Além disso, mostrar em outros formatos (como '15 de fevereiro de 2001', por exemplo), pode ficar mais complicado sem utilizar alguma biblioteca de manipulação de datas.
Resumindo, você precisará converter essa string para o tipo Date
. E como fica o timezone, ele não pode influenciar!
Para isso existe na Date API um método chamado getTimezoneOffset()
que retorna um número que representa a quantidade de minutos que estamos distante do GMT (o famoso horário zulu ou horário de Londres, no meridiano de Greenwich).
Por exemplo, o comando abaixo, se executado com o horário de Brasília, irá retornar 180:
console.log(new Date().getTimezoneOffset())
Lembrando que para timezones que estão à frente do horário de Londres, os valores serão negativos.
Assim, para "normalizar" essa data, exibindo sempre a mesma data não importa o timezone, basta adicionar esse offset à nossa data que retornou da API, e assim teremos uma data, no formato Date
, seguramente correta para exibir no navegador do usuário.
Vou exemplificar sem utilizar nenhuma biblioteca de manipulação de datas, para que você possa testar direto em seu navegador, mas você poderá utilizar uma biblioteca como date-fns
ou moment
:
function addMinutes(date, minutes) {
return new Date(date.getTime() + minutes * 60000);
}
const date = addMinutes(new Date('2001-04-05'), date.getTimezoneOffset());
console.log(date); // Thu Apr 05 2001 00:00:00 GMT-0300 (Brasilia Standard Time)
Se executar o código acima no console de seu navegador, vai notar que o retorno foi a data correta. E pode mudar o timezone de sua máquina, que não terá qualquer efeito indesejado.
Conclusão
Enfim, trabalhar com datas pode ser algo desafiador em alguns casos de uso, mas seguindo estas práticas provavelmente você terá menos dores de cabeça, principalmente nestes casos onde a data não pode sofrer os efeitos do timezone.
Lembrando, não há um certo e errado na forma como você vai tratar o problema, mas nos anos de desenvolvimento essa abordagem foi a que menos me deu problema. Se você trata a questão de datas de uma outra forma, comente abaixo qual sua estratégia!
Até mais, pessoal!