Skip to content

ADR-0045: Dia civil — o dia compartilhado é o da igreja, a idade é a de quem olha ​

Status: accepted Date: 2026-09-13 Deciders: @barateza + sessão de diagnóstico (defeito de produção no check-in noturno) Related: #727 (telão / childAge), ADR-0044 §3 (portável; não introduz port de Clock) Tags: [datas, fuso-horário, utc, dia-civil, intl, ecma-262, temporal, eca, checkin, session-date, segredo-diario, adr-0043]

Context ​

Um defeito de produção revelou que o produto tinha duas noções de "hoje" e nenhuma delas declarada. A cadeia, verificada no código:

SaltoOndeCalendário
O app deriva a data da sessãoapp/src/modules/classSessions/classSessionService.ts (getFullYear/getMonth/getDate)local
O servidor grava o valor do payloadservices/classSlotService.ts → projeção classSession.tsverbatim
O "hoje" do servidorservices/checkinService.ts — new Date().toISOString().slice(0, 10)UTC

Em America/Sao_Paulo (UTC-3, o fuso de produção) o dia UTC vira às 21:00 locais. Entre 21:00 e 23:59:

  • validateSessionToday comparava session_date (D) com o "hoje" UTC (D+1) e respondia 400 INVALID_INPUT "Sessão não é para hoje" — recusando todo check-in de um culto da noite;
  • findActiveCheckInByCode procurava cs.session_date = D + 1 e não encontrava nada;
  • todayDateString() alimentava deriveDailySecret, então o segredo diário — e o código de 6 dígitos que a recepção lê — girava às 21:00, não à meia-noite;
  • o próprio app pedia as sessões de amanhã (classSessionsClient.listByDate), então a tela da recepção ficava sem sessão nenhuma.

A janela é exatamente a dos cultos de quarta/sexta/domingo à noite. O defeito sobreviveu muito tempo porque as duas pontas erravam "juntas" na maior parte do dia, e porque um CI em UTC não vê diferença nenhuma (era o mesmo motivo pelo qual o defeito de idade na véspera do aniversário também passava).

Junto veio o mesmo erro em outra roupa: a idade. A derivação original fazia new Date("YYYY-MM-DD") — que por ECMA-262 é meia-noite UTC para a forma date-only — e comparava com getMonth()/getDate(), que são locais. Na véspera do aniversário a criança aparecia um ano mais velha e a faixa etária sugerida por findAlternativeClasses podia ser a seguinte. (A documentação anterior registrava a direção ao contrário — "um dia mais nova"; a direção medida é um ano mais velha, e é a que importa: empurra para a faixa etária seguinte.)

Decision ​

1. Duas convenções explícitas, com donos diferentes.

Tipo de dataConvençãoOnde
Dia compartilhado (segredo diário, session_date, guard de sessão do dia)zona da igreja — UM dia para todos os aparelhoschurchToday()
Decisão pessoal/de exibição (idade de uma criança)zona do aparelho (o usuário)civilDateInZone(at) / calculateAge

O dia compartilhado não pode ser o do visitante: um código mostrado no telão tem de valer em qualquer celular, e session_date precisa de uma data única — se cada aparelho escrevesse o seu dia, a mesma sessão existiria em duas datas.

O servidor não tem "visitante": os dois chamadores de calculateAge (check-in da recepção e telão do salão) são aparelhos da igreja, então a zona da igreja é o default lá. Onde existe um usuário — a tela do aparelho — a zona é a dele.

2. Uma definição de "dia civil" em cada lado, espelhada e documentada.

workers/src/modules/dates/civilDay.ts e app/src/modules/dates/civilDay.ts, com DEFAULT_CHURCH_TIME_ZONE = "America/Sao_Paulo".

3. A zona da igreja é uma constante, não uma coluna.

O produto é de um só país; churchToday() é o único ponto a mudar se um dia houver multi-região (aí a zona vem de church_settings). Uma coluna agora seria migration + ordem de deploy (migration antes do worker) para representar um valor que ainda é constante.

4. O dia vem de Intl.DateTimeFormat com timeZone explícito.

Nunca de parse de string. new Date("YYYY-MM-DD") é meia-noite UTC por ECMA-262, e combiná-lo com getters locais é a armadilha exata que gerou os dois defeitos de idade.

Consequences ​

Positivas

  • O check-in noturno volta a funcionar, o código do dia gira à meia-noite local e a recepção enxerga as sessões do dia. O defeito está travado por teste de integração (checkin-day-boundary.test.ts, SQLite real + relógio injetado) nos dois lados da meia-noite.
  • A classe de bug "dia UTC × dia local" deixa de ser representável por acidente: há uma função com nome claro para cada semântica, e o grep por toISOString().slice(0, 10) passa a ser a auditoria.
  • A referência jurídica e a técnica coincidem: no ECA a idade "se completa às 0h00 do dia do aniversário" — meia-noite local — e é isso que a comparação em calendário civil implementa.

Negativas

  • Duas definições (app + workers) que precisam mudar juntas. É a fronteira de import (app/ → workers/ é PROIBIDO) e a ausência de um pacote de utilidades puras, não uma escolha. Se aparecer uma terceira necessidade, o lugar é packages/, não outra cópia.
  • Chamar Intl custa ICU; por isso o formatter é cacheado por zona.

Neutras / em aberto

  • Ainda derivam o dia em UTC: os defaults de janela dos relatórios (app/src/modules/reports/*) e o carimbo de data nos nomes de arquivo de exportação. São chaves/labels de exibição, degradam sem bloquear — registrados no CHANGELOG.
  • classSessionService.toDateString segue local (não UTC): para aparelhos brasileiros local == dia da igreja, e mudá-lo mexeria no gerador de sessões sem ganho comportamental.
  • A troca da chave do dia move a rotação do segredo diário: um código emitido nos últimos minutos antes da mudança deixa de casar. As duas pontas recalculam da mesma fonte, então se recompõe em um dia.

Alternatives considered ​

  • Manter UTC (status quo). Rejeitado: é a causa do defeito. UTC não é "neutro" — é uma escolha de que o dia da igreja é o de Greenwich, e essa escolha contradiz a referência jurídica do produto e quebra as chaves de dia compartilhadas.
  • Zona do usuário para tudo. Rejeitado: fragmenta as chaves de dia compartilhadas. O código do dia giraria por aparelho e nenhum código seria válido em dois celulares.
  • Zona da igreja para tudo. Rejeitado: perde a informação onde ela existe. A idade de uma criança é uma pergunta do observador; hoje isso coincide, mas o modelo não deve afirmar que coincide.
  • Hardcode do offset -03:00 em vez do nome da zona IANA. Rejeitado: o Temporal cookbook usa justamente o Brasil (que aboliu o horário de verão em 2019) como exemplo de por que uma regra de fuso muda e um offset congelado não. Use o nome da zona.
  • Adotar Temporal (polyfill) ou date-fns/luxon. Adiado: uma comparação de calendário não justifica uma dependência nova, e Temporal ainda não está em todos os runtimes. Intl com timeZone já resolve, e foi verificado em workerd pelo teste de integração (o formatter com timeZone explícito funciona no runtime de produção).
  • Modelar como Temporal.PlainDate desde já. Adiado junto; a convenção (dia civil, não instante) é a decisão que importa, e ela já está tomada — o tipo pode evoluir depois.

Enforcement ​

  • workers/src/__integration__/checkin-day-boundary.test.ts — 23:30 local aceito, meio-dia local aceito, dia seguinte ainda recusado.
  • workers/src/modules/students/__tests__/age.test.ts — fronteira na meia-noite local e a zona como parâmetro (mesmo instante, dois calendários).
  • app/src/modules/students/__tests__/age.test.ts — a zona do aparelho quando nenhuma é passada.
  • Auditoria por grep: toISOString().slice(0, 10) só é legítimo para carimbo de timestamp, nunca para chave de dia.
  • packages/dates/__tests__/ — o dia civil por zona, a fronteira das 21:00, a virada do ano no calendário da igreja e a superfície pública do pacote.

Amendment (2026-09-13) — o espelho virou pacote, e a varredura fechou ​

A decisão acima foi implementada no mesmo dia com dois ajustes que substituem partes deste registro (o texto original fica como histórico, não foi reescrito):

1. "Duas definições espelhadas" deixou de existir. A seção Negativas acima aceitava dois arquivos idênticos (app/ e workers/) porque app/ → workers/ é import proibido — e o espelho não tinha enforcement nenhum: a próxima edição em um dos lados recriaria a divergência silenciosa, exatamente a classe de defeito que este ADR existe para matar. A regra passou para packages/dates (@neemias/dates), que packages/* permite ser importado por app/ e workers/. Os dois arquivos espelhados foram removidos: a divergência deixa de ser representável, não apenas verificada.

  • Superfície: civilDateInZone(at, timeZone) (zona obrigatória — um default implícito é como o defeito original se escondeu), localCivilDate(at) (o calendário do runtime: no cliente é o do aparelho; no worker seria UTC por acidente, então lá passe a zona) e churchToday(at).
  • Alternativa rejeitada: manter as duas cópias e travar a igualdade com um teste de contrato sobre o texto dos arquivos. Detecta a deriva depois que aconteceu e só nos invariantes que alguém lembrou de afirmar; o pacote torna a deriva impossível.

2. A varredura fechou — os itens de Neutras / em aberto acima estão resolvidos. Todos os pontos que ainda derivavam o dia em UTC passam por churchToday():

  • as janelas dos relatórios (app/src/modules/reports/{sessions,trend,summary,classEngagement});
  • o carimbo de data dos nomes de exportação (importExport/formatters.ts, ImportTab, MigrationScreen) — um arquivo exportado às 22:00 não é mais carimbado com amanhã;
  • classSessionService.toDateString, que gera o session_date: criação e comparação passam a usar a MESMA definição. Era o resíduo mais relevante — a outra metade do defeito original.

Segue em aberto deste registro: apenas o efeito de transição da chave do dia sobre a rotação do segredo diário (inalterado) e a adoção de Temporal.PlainDate (adiada).

Distribuído sob licença MIT.