Executando verificação de segurança...
0

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!

Carregando publicação patrocinada...
2

Realmente lidar com isso é extremamente complicado. Eu vou só dar uma pincelada superficial.

Se vai trabalhar com datas, não use horário junto. Se fizer isso não terá muitos problemas. Se a tecnologia usada não tem data sozinha, azar, você terá que pelo menos na manipulação desconsiderar e zera o horário sempre, aí não dá problema com fuso horário.

Utilize sempre tipos prontos para lidar com isso, a não ser que não tenha outra forma. Em geral é mais bem feito do que você poderá fazer. O assunto é complexo demais, e cheio de armadilhas para você tentar reproduzir. Dá muito trabalho e provavelmente será bugado. Isso vale até para banco de dados, não se deve inventar maneira de armazenar (o SQLite não tem data, tem que usar uma forma de timestamp e definir regras).

Para problemas de fuso, tem solução relativamente simples. A não ser que tenha uma indicação para fazer diferente deve-se trabalhar com UTC, ou seja, fuso horário neutro. Assim não dá problema. Somente na interação com o cliente é que o fuso deve ser considerado (se fizer sentido), então quando o usuário escolhe um horário ou vai mostra para ele é feita uma conversão entre UTC e o fuso que se sabe que ele usa. E o ideal é usar algo pronto e não tentar reinventar a roda que é difícil. A maior parte das soluções que as pessoas apresentam são gambiarras.

Eu acho que o kht dará uma resposta mais completa, já que ele tem até livro publicado sobre o assunto.

Faz sentido para você?

Espero ter ajudado.


Farei algo que muitos pedem para aprender a programar corretamente, gratuitamente. Para saber quando, me segue nas suas plataformas preferidas. Quase não as uso, não terá infindas notificações (links aqui).

1

Isso mesmo, maniero. Nos casos de atributos que representam o momento exato (como data de criação do post) eu sempre uso epoch number. Daí realmente não há problemas e a data sempre será exibida corretamente no navegador do usuário, não importando o timezone. A armadilha mesmo é em datas onde o timezone não pode alterar a exibição, como a data de nascimento.

Eu deixei de usar tipos nativos de bancos de dados (date, timestamp, timestampz, etc), pois alguns têm e outros não, e eventualmente se você está em um ambiente de microsserviços onde cada um usa um banco diferente (incluindo ai bancos não relacionais), isso pode ser um problema. Sem contar questões de performance (campos do tipo numérico tem uma indexação muito mais rápida que tipos nativos como date ou timestamp). Eu procuro sempre adotar somente os tipos mais primitivos possíveis na persistência, o que acaba ficando restrito a strings, números e booleano, e colocar no espaço das regras de domínio a forma como eles podem ser criados, alterados, etc.

Mas como eu disse, não há um certo ou um errado, são apenas formas diferentes de abordar o problema!

Obrigado pela sua contribuição à discussão!!

2

De fato lidar corretamente com datas é bem mais complicado do que a maioria das pessoas acha que é - talvez por isso seja tão difícil elas sequer imaginarem o tipo de problemas que podem acontecer, quanto mais as formas de resolver. É verdade também que muitas API's de data existentes não ajudam, o que torna ainda mais importante estudar bem o assunto.

Sobre a solução de somar o offset, existem muitas ressalvas e tem que usar com cautela, pois dependendo do caso pode causar mais problemas ainda. Como estou sem tempo de elaborar um post mais completo, vou deixar alguns links onde eu explico com mais detalhes os poréns desta solução, e vários outros pontos de atenção:

Outro post que pode ajudar é Como trabalhar com timezones sem usar um timestamp?.

Sobre o uso de Date.parse, também existem alguns poréns, porque browsers diferentes podem ou não aceitar formatos diversos, então não é garantido que a mesma string sempre gere a mesma data em todos os casos (por exemplo, "10/02/2020" pode resultar em 2 de outubro ou 10 de fevereiro, dependendo da implementação).

Só pra complementar o post, já escrevi algo a respeito aqui no site, em Lidando com datas em JavaScript (ou "criei uma data, mas mostra um dia a menos").

Também tem muito conteúdo do meu blog que planejo adaptar para o TabNews, mas enquanto isso não acontece, vc pode ver aqui uma lista dos posts relacionados ao tema .

E já que é pra fazer propaganda, este é um livro que escrevi sobre o assunto. Apesar do título (Datas e horas -
Conceitos fundamentais e as APIs do Java
), não é só pra quem programa em Java, pois a primeira parte do livro é mais conceitual e vai servir para qualquer linguagem.

1

Que massa isso aqui!

Fico honrado em receber contribuições como a do maniero (que não o conhecia, mas que tem um currículo espetacular), e do kht (que conheço pessoalmente, pois trabalhamos na mesma organização, mas não sabia de seu incrível conhecimento sobre o assunto - enfim que mundo pequeno). Isto é um sinal que estou indo pelo caminho certo :)

No meu trabalho principal sou cientista de dados, mexo com banco de dados, BI, ETL, mas nos últimos 5 anos tenho feito muitos freelas em desenvolvimento Javascript/Typescript/React. Talvez minha experiência em desenvolvimento esteja enviezada com o baixo nível que o mercado de desenvolvimento apresentou nos últimos anos (com a pandemia) trazendo muito programador com pouca experiência para o combate.

Então hoje resolvi postar essa discussão sobre como enfrentei problemas de verdadeiras gambiarras, tanto no backend quanto no frontend, que já me apareceram sobre esse assunto de datas, mas observo que, ainda que estivesse com uma abordagem que está dando certo, certamente há algo melhor a se fazer.

Ah, sobre o uso de Date.parse(), ele está sendo usado no exemplo no lado servidor (Node.js) mas ali o objetivo é tão somente avaliar se a data enviada está no formato desejado (vamos dizer que a API Reference determina que o formato seja string 'YYYY-MM-DD' então é preciso somente garantir esse contrato).

Vou consumir todo esse conteúdo sobre este assunto que você colocou que com certeza será de grande valia!

Obrigado pela contribuição, Hugo!

3

sobre o uso de Date.parse(), ele está sendo usado no exemplo no lado servidor (Node.js) mas ali o objetivo é tão somente avaliar se a data enviada está no formato desejado (vamos dizer que a API Reference determina que o formato seja string 'YYYY-MM-DD' então é preciso somente garantir esse contrato)

Mas da forma que foi feito, o código não garante que a string está no formato YYYY-MM-DD. Por exemplo, se eu passar a string '10/02/2020', vai criar um data referente a 2 de outubro (e não 10 de fevereiro, veja).

Isso porque o Node.js usa a mesma engine do Chrome, e nesta implementação, o formato "XX/XX/XXXX" é interpretado como "mês/dia/ano". Se for para garantir o formato, teria que verificar isso explicitamente (por exemplo, fazendo o split pelo hífen, ou usando regex).


Caraca, onde vc trabalhou? É que conheço mais de um Jefferson :-)

1

Nossa, você tem total razão, e consultando o último projeto que trabalhei com isso, de fato tem uma regex que faz essa validação.

Sou o Jefferson Felix. Trabalho no TRT-SP há mais de 11 anos, já passei pela sustentação do PJe mas hoje estou no BI (apesar de sempre ter trabalho com isso mesmo) :)

Abraço!