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:
| Salto | Onde | Calendário |
|---|---|---|
| O app deriva a data da sessão | app/src/modules/classSessions/classSessionService.ts (getFullYear/getMonth/getDate) | local |
| O servidor grava o valor do payload | services/classSlotService.ts → projeção classSession.ts | verbatim |
| O "hoje" do servidor | services/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:
validateSessionTodaycomparavasession_date(D) com o "hoje" UTC (D+1) e respondia400 INVALID_INPUT "Sessão não é para hoje"— recusando todo check-in de um culto da noite;findActiveCheckInByCodeprocuravacs.session_date = D + 1e não encontrava nada;todayDateString()alimentavaderiveDailySecret, 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 data | Convenção | Onde |
|---|---|---|
Dia compartilhado (segredo diário, session_date, guard de sessão do dia) | zona da igreja — UM dia para todos os aparelhos | churchToday() |
| 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
Intlcusta 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.toDateStringsegue 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:00em 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) oudate-fns/luxon. Adiado: uma comparação de calendário não justifica uma dependência nova, eTemporalainda não está em todos os runtimes.IntlcomtimeZonejá resolve, e foi verificado em workerd pelo teste de integração (o formatter comtimeZoneexplícito funciona no runtime de produção). - Modelar como
Temporal.PlainDatedesde 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) echurchToday(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 osession_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).