Bento 1+4 no desktop e carrossel 1/tela no mobile (~390px) lendo o post
collection com isPostVisible fail-closed; seção some inteira sem posts
visíveis. Zero migration/Consent; home vira ISR com tag posts +
revalidate=300 (label relativa não congela).
A primeira dobra da home pública agora acompanha as referências do Penpot
em 393, 768, 1024, 1280, 1366 e 1920 px. No desktop, a escala e a posição
horizontal das fotos dependem somente da largura: Solla, Lula e Wagner
ficam ancorados ao rodapé real do hero mesmo quando o conteúdo aumenta a
seção, e um palco centralizado de 1920 px preserva a proximidade entre
retrato e texto em monitores ultrawide. E2E fixa a geometria das seis
referências, transições de proporção, desktops baixos, ultrawide e os
overrides da WCAG 1.4.12; o caso de regressão também roda no Firefox.
Create/edit sheets (ContactFormSheet + ContactFormFields) e cards mobile
(ContactMobileCard/ContactMobileList), FAB de criar (ContactCreateFab,
z-40 sobre o bottom nav) e a ladder updateContactFullFormAction.
- Form sheets com campos controlados + dispatch manual: React 19 reseta
campos nao controlados apos qualquer action assentada, apagando o que
o usuario digitou em erro de validacao/conflito de nome; startTransition
mantem isPending correto e o state.message vira banner role=alert.
- state de acao da sessao anterior e descartado ao reabrir o sheet
(sessionId), evitando banner/conflito fantasma.
- CreateRow usa o mesmo dispatch manual (mesma classe de bug).
- updateContactFull: escrita atomica da ficha em transacao com gates de
escopo e invariante de nome (decisao F do plano).
Fase 3 (server) do plano aprovado:
- schemas/contact.ts: contactCreateSchema (nome único via invariante, state
obrigatório, phones opcionais C112); contactFieldUpdateSchema ganha variantes
city e postalCode ('' → clear); mensagens das actions client-safe aqui.
- schemas/personGenders.ts: enum de gênero em módulo folha (ciclo de imports
contact ↔ leadership detectado e quebrado).
- actions/contact.ts: createContact (gate staff + bypass justificado, lock
contact-name:{normalized}) e updateContactField (escopo 'edita o que vê'
via re-check user-threaded para advisor; reorder de telefone primário).
Apagar reusa as actions C100 existentes.
- collections Contact e Supporter: label de gênero 'Outro' → 'Não binário'
(sem migration; nenhum teste pina a label).
- 11 testes unit novos; lint, tsc e prettier limpos.
Fase 2 do plano aprovado. Três módulos em src/utilities/contacts/:
- contactListUrl: contrato de params (q|gender|state|city|ausencia|vinculo|sort|dir|page),
canonicalize, hrefs de sort e where de fonte com 'phones.value' like (ILIKE verificado
no Postgres local).
- contactListData: loader com UM find em contact (user threadado, overrideAccess false),
vínculos (liderancas/dobradinhas/assessores/equipe) como facets sobre o conjunto
legível, filtro/sort/paginação em memória, nulos no fim (precedente B15).
- contactNameInvariant: lock contact-name:{normalized} + prefilter ILIKE + veredito
exato em memória; mensagem de conflito pt-BR.
38 testes unit novos; format + tsc limpos.
- Assessora/StateDeputy: advisory lock por pessoa (person-assessora /
person-state-deputy) serializa enumerate→decide→write do exit destrutivo
(dois removes concorrentes de chips distintos deixavam zumbi com carteira
vazia — P1 do reviewer A); enumeração da carteira usa getAdvisorMunicipalityIds
canônico (F1).
- RelationChipCell: commitGuard retorna boolean | 'destructive'; o veredicto
destrutivo suprime o toast 'Desfazer' do lote (re-add só re-criaria a
entidade vazia — votos/conta já foram) — P2 A+B.
- PeopleMunicipalityCell: sentinela manifestPending (dois guards concorrentes
sobrescreviam o resolver e a 1ª remoção morria em silêncio — P2-1) + cleanup
no unmount resolve false (P2-2) + extraFormFields memoizado; minItems órfã
removida.
- personDelete: bloco batch redundante de autorias removido (o loop com
deleteCampaignUserAccount já cobria — 4N DELETEs no-op — F4).
- getPersonCapacityExitManifestAction: actor reloaded (papel rebaixado não lê
manifest que o write recusaria — P2-A).
- Revalidação: setPersonStateDeputyMunicipalities ganha
revalidateMunicipalityListPaths (paridade com o twin B37 — F2).
- Testes: int +2 (cascata de autorias no exit da Assessora; undo re-cria a
liderança)
Qualquer pessoa visível ganha/perde Assessora, Lidera e Aliada em pela
lista: a primeira cidade cria a entidade (conta staff sem credenciais
utilizáveis / liderança / dobradinha), a última remove da tabela com
confirmação destrutiva (manifest: votos+convites da liderança, conta+
autorias+assessorado da assessora; dobradinha sem diálogo). Saída
destrutiva por escopo: assessor encerra Lidera/Aliada com a capacidade
inteira na carteira; Assessora segue coordenação/candidato. Escritas
person-centric por contactId com extração dos deltas B34/B37 e da
cascata de conta (deleteCampaignUserAccount, nextFreeStubCampaignEmail,
matchers de conflito) — sem twin. commitGuard/commitWithNullOwner/
extraFormFields como opt-ins no RelationChipCell. Corrige bug latente
C116 (célula Assessora nunca salvava — contactId faltava no FormData).
Sem migration. Int: 28, unit: 6, e2e: +2.
O dropdown de adição de municípios (pessoas ×3, assessores, lideranças,
dobradinhas) oferece 'Salvador — Todas as zonas' como primeiro hit da busca:
um toque adiciona as 19 ZEs num lote, via máquina existente do RelationChipCell
(delta otimista, cap/floor, undo, colapso 'Salvador (19)' B178, exact-match C116).
- searchMunicipalityPortfolio ganha kind 'city' (NORMALIZED_ZONE_CITIES,
derivado do catálogo) com gate de escopo por contagem: o agregado só aparece
quando a cidade inteira está no escopo addableIds do ator — a label 'Todas as
zonas' nunca mente para assessor com carteira parcial.
- hitDescription ganha o branch 'Todas as zonas'; sem migration/access/schema.
- Testes: unit busca pura (19 ids, soma, escopo parcial, tudo atribuído,
posição), cell render, e2e em campaignPeople (agregado → chip → 19 zonas no
DB).
- lastMirroredChangeAt (activity, migration): clock rule compara contra a
última mudança espelhada, não updatedAt (task toggle não silencia edição
pendente do Google)
- snapshot exclui ids que o motor deletou (removedEventIds separado do
remoteEventIds): reopen pós-cancelamento não vira re-cancelamento fantasma
- cancelamento do Google respeita o relógio: cancel mais novo cancela; Teqo
mais novo (reopen) limpa o trashed e recria
- escrita reversa sem req.user: autoria do registro correta + sem gate 403
do deputado; hook de sync pula a própria escrita reversa (sem passada aninhada)
- canal: segredo rotaciona por criação; novo canal persistido antes do stop
do antigo; identity fields read:false; create admin-only; leitura prefere
linha configurada; pre-filter do webhook antes do bootstrap do Payload
- testes: all-day ida-e-volta, task-toggle, reopen, slug congelado, e2e dos
caminhos 200, render tests do dialog/feed; asserções herméticas a
atividades ambiente dos arquivos irmãos (DB compartilhado)
- runSyncPass bidirecional: regra do relógio decide direção (Google mais novo
aplica título/horário; Teqo re-asserta), cancelamento via showDeleted +
snapshot lastSeenEventIds, registro no updates feed
- webhook público /campanha/agenda/google-webhook/[secret] fail-closed
(secret + X-Goog headers constant-time), canal lazy sem cron (ensure em
toda passada, renovação com id novo + stop do antigo)
- migration hand-written (campos de canal/snapshot em googleCalendarSync);
mutationKind dedicado libera título via Google sem quebrar slug canônico
- UI: seção 'Edições pelo Google' no dialog + autor 'Google Calendar' no feed
- testes: unit (reverse mapping, validação webhook), int (motor bidirecional
com stub, 10 cenários), e2e (webhook 404 fail-closed)
- PEOPLE_ABSENCES ganha o 4º valor qualquer_ausencia ("Qualquer ausência"),
a união exata das três ausências em UM chip — o 3º clique dos específicos
deixa de colapsar o facet (3 de 4 membros) e o parse absorve o guarda-chuva
sobre os específicos, sem tocar no B18 genérico de capacity/status
- peopleListSortPrimaryOptions: seeds do grupo Ordenação viram as 7 direções
primárias com emptyQueryVisible, então o mobile descobre as 7 chaves ao
tocar no bar (e "ordenar" não corta aliada/assessorado/base no cap 8)
- CampaignListOmnibox reabre o popover no mousedown: clicar num input já
focado (logo após commitar busca com Enter) não disparava focus, deixando
a descoberta morta no mobile até digitar
- tests: contrato de URL (absorção + não-colapso), predicado + AND, novo spec
de omnibox, e2e do 3º clique e da descoberta mobile (390×844)
O renderer de links do chat decide interno vs externo (B187/B188); a decisão
pura de fechamento mora no owner src/lib/ai/markdownLinks.ts
(shouldCloseDrawerOnLinkClick: interno && mobile && clique simples) e o
markdownComponents estatico virou useMemo no corpo do CampaignAIChat para
alcancar o contexto. Desktop inalterado; links externos/modos de nova aba
mantem o drawer da aba de origem; sessao e restore intactos (close persiste
open:false).
- unit: 12 testes da decisao pura
- e2e (modo CI, E2E_PROD=1): interno fecha + navega sem load + reload nao
reabre; externo abre popup e mantem o drawer
- debito B199/#672 (depends #660): race de persistencia ao fechar mid-stream
Espelho oficial da agenda em calendário Google compartilhado via API
(service account, jose + fetch): reconciliação por passada com id
determinístico teqo+base32hex (upsert idempotente, sem estado por evento),
hooks afterChange/afterDelete na activity (só campos espelhados, nunca
lança), auto-resync quando calendarId muda, retry na view da agenda,
pill de estado no header + FAB + diálogo (4 estados). Cancelado no Teqo
deleta o evento no Google. Sem participantes no v1 (decisão de produto).
Sem migration nova de tabela (hand-written). Closes#635
- sort/dir/ausencia no contrato de URL de /campanha/pessoas (molde territórios/B15)
- sortPeopleRows sobre o conjunto filtrado, nulls no fim, tiebreak nome→contactID
- PeopleSortableHead (CampaignSortableHead) com aria-sort nas 7 colunas visíveis
- omnibox: grupo Ordenação (chave×direção) e facet Ausência (Sem assessor/base/contato)
- clear preserva o sort; tests unit + e2e; sem migration/collection
- Contact.phone (text único) → phones (array field Payload, subcampo value):
fonte única; principal = phones[0]; beforeValidate normaliza, filtra vazios,
rejeita inválido e duplicado DENTRO da ficha (C111 entre fichas preservado)
- migration add_contact_phones_array: backfill phone → phones[0] (gen_random_uuid)
antes do drop da coluna + índice em value (hand-edit documentado; snapshot .json
gerado mantém as colunas C99/C100 já existentes)
- primaryPhoneOf / phoneValuesOf / reorderWithPrimaryPhone em src/lib/phone.ts
(ordem = prioridade; inline edit = seta o principal preservando o resto)
- findOrCreateContactByPhone aceita phones[] (dedupe pelo primário; reuse faz
append dos faltantes; locks em todos os números digitados)
- sync conta→ficha (CampaignUser): phone da conta faz APPEND quando a ficha não
tem (nunca reordena nem remove); tombstone LGPD substitui TODOS os telefones
- convite redemption: telefone digitado vira principal, demais preservados
- import CSV: preview sinaliza compartilhado também por número secundário;
bulk insere contact_phones (JOIN nas leituras, uuid no insert)
- busca por telefone (apoiadores, pickers, combobox) casa qualquer número
- UI: PhonesFieldEditor compartilhado (repetível mascarado, remover, ↑/↓,
modo inline com Salvar) nas 5 superfícies — liderança (form/ficha/lista),
apoiador (form/ficha), dobradinha (ficha), assessor (ficha), wizard
- UI pública (petição/WhatsApp) intocada: zod e forms ficam com phone;
as ações mapeiam phone → phones[0]
- testes: spec novo campaignMultiPhones (aceite completo), specs C111/C99
atualizados, e2e wizard/apoiador ajustados
Spike no build de produção provou que o Next 15 não congela route handlers GET
(rota dinâmica por default); o freeze era o contrato de resposta
(public, max-age=3600 sem validadores) permitindo caches compartilhados
reterem o snapshot do primeiro fetch. Fix: force-dynamic explícito +
buildICalFeedResponse com public no-cache, ETag forte sha256, Last-Modified,
304 só por If-None-Match (nunca por data — blind spot de exclusão), dica
X-PUBLISHED-TTL no corpo do VCALENDAR (RFC 7986) e 404 no-store para
revogação fail-closed no CDN. Testes unit/int/e2e (dev + prod build).