Simplify C2 supporter paths and register C6 scale follow-ups.

Tighten import/create view models after review, and capture the deferred DRY/import-scale work as roadmap item C6.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-18 20:33:50 -03:00
parent b079daa9bb
commit a18e2f15f5
13 changed files with 202 additions and 88 deletions

View file

@ -35,6 +35,7 @@ alwaysApply: false
- **D1 PWA** — manifest + SW servidos por route handlers sob `/campanha` (script em `campaignPwa.ts`), cache `campanha-<buildId>` com `skipWaiting`/`claim`, navegação network-first com fallback `/campanha/offline`, RSC/Flight e `/campanha/convite/*` jamais cacheados; ícones em `public/campaign-icons/`; `InstallPwaToast` (Android `beforeinstallprompt`, iOS Drawer, dispensa em `sessionStorage`); logout limpa Cache API no cliente antes da action.
- **A3 Baseline TSE 2022 Fase 1** — collections `electionTally`/`electionCandidateVote`/`electionCandidate` (grupo `Dados Eleitorais`, leitura para `campaignUser`/`users` via `canReadElectionData`, escrita admin-only), migration `20260718_195854_add_election_results`, seed local `pnpm db:seed:tse` (idempotente por escopo year/office/turn, delete+bulk insert via drizzle, guard de banco local). Sem PII (sem CPF/título); identidade cross-ano por `identityKey` hash. UI fica no A4.
- **Em 2026-07-18, C2 Cadastro nominal de apoiadores — engenharia pronta (pendente merge/deploy + lote jurídico).** Collection `supporter` (join `Contact`↔campanha, núcleo opcional), migration `20260718_222656_add_supporter` com `UNIQUE NULLS NOT DISTINCT (contact_id, nucleus_id)`, consent por chaves `apoiador-cadastro` / `apoiador-intencao-voto` via `campaignConsent.ts` genérico, actions (create / intenção / import CSV só `geral` / remover dados), UI `/campanha/apoiadores` (lista+KPIs, ficha, wizard import, kit mínimo `wa.me`). Telefone obrigatório; `lideranca` sem acesso à área. Produção com dados reais espera Consent keys + aprovação jurídica (Onda 0).
- **Em 2026-07-18, C6 Escala e DRY pós-C2 registrado no roadmap** (após `/simplify` do C2): shells compartilhados com listas de núcleos, import bulk drizzle + skip phone-invariant, preview token, KPI agregado SQL, StrictCombobox/AlertDialog. Plano: [`docs/plans/escala-dry-pos-c2.md`](../../../docs/plans/escala-dry-pos-c2.md). Depende do merge de C2; fases 3–5 cortáveis se a base permanecer pequena.
## Decisões

View file

@ -144,6 +144,7 @@ Componentes:
- Uso da base no dia D — C5.
- Campo `supportLevel` — fora do v1.
- Mesclar `leadership` em `supporter` — `leadership` segue responsável pelo acesso ao app.
- **Escala/DRY pós-v1** (shells compartilhados com núcleos, import bulk drizzle, preview token, KPI agregado, StrictCombobox/AlertDialog) — item **C6**, plano [escala-dry-pos-c2.md](escala-dry-pos-c2.md).
## Bloqueador obrigatório de produção
@ -156,6 +157,7 @@ Antes de importar dados reais ou capturar intenção de voto: a assessoria jurí
## Revisões
- **2026-07-18:** auditoria pré-implementação C2. Corrigidas refs defasadas (`campaignConsent.ts` em vez de `campaignInvite.ts` / `supporterConsent.ts`; `contactPhoneInvariant.ts` em vez de `supporterDedup.ts`). Decisões fechadas: telefone obrigatório; kit mínimo no v1; enum de intenção; coexistência com leadership; import só `geral`; `supportLevel` fora do v1; `removeSupporterData` com anonimização condicionada a outros joins.
- **2026-07-18 (pós-simplify):** follow-ups de escala/DRY adiados do v1 registrados como **C6** ([escala-dry-pos-c2.md](escala-dry-pos-c2.md)).
## Referências

View file

@ -0,0 +1,122 @@
# Escala e DRY pós-C2 (apoiadores + listas)
Status: rascunho
Atualizado em: 2026-07-18
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C6)
Responsável: —
## Contexto
O C2 ([cadastro-nominal-apoiadores.md](cadastro-nominal-apoiadores.md)) entregou a engenharia do cadastro nominal: collection `supporter`, actions de create/intenção/import/remover, UI em `/campanha/apoiadores`. A terceira passagem de `/simplify` (2026-07-18) limpou dead code e churns baratos, mas **deixou de fora** refactors e ganhos de escala que os revisores marcaram como importantes e fora do escopo de “cleanup pontual”:
1. Extrair shells compartilhados com a lista de núcleos (`nucleusUi` / `NucleusFilters` / `NucleusPagination`).
2. Unificar `fieldError` / mapeador de erro de form actions (hoje clone de leadership).
3. Import CSV em massa com insert via drizzle + bypass do invariante de telefone quando o caller já segura os locks.
4. Preview/confirm sem round-trip de até 5k linhas na action.
5. KPIs da lista com um único agregado SQL em vez de 3× `count`.
6. UX alinhada: `StrictCombobox` no município do create e `AlertDialog` no “Remover dados”.
Este plano é o registro canônico desses follow-ups. Sem ele, a base nominal funciona para volume baixo, mas o import de 5k linhas e o DRY com núcleos ficam como dívida implícita.
## Objetivos
- Listas de núcleos e apoiadores compartilham primitivos de URL/paginação/filtro e helpers de form error, sem regressão nas rotas já entregues.
- Import CSV de até `MAX_IMPORT_ROWS` (5000) completa dentro do timeout Vercel/DB, com locks de telefone corretos e sem N× hooks redundantes em `Contact`.
- Preview/confirm não serializam o conjunto completo `ok` duas vezes pela fronteira da Server Action.
- Overview de apoiadores: um round-trip de agregação (ou reuso de `totalDocs` + um agregado) em vez de três `count` paralelos sem `req` compartilhado.
- Create de apoiador usa o mesmo `StrictCombobox` de município dos filtros/núcleos; remoção destrutiva usa `AlertDialog` (padrão `ArchiveNucleusDialog`).
- Guardrails: `overrideAccess: false` quando houver `user`; escrita multi-collection continua transacional; qualquer bypass de `enforceUniqueContactPhone` só sob locks já adquiridos na mesma txn; sem novo `Consent` / sem collection nova (salvo staging efêmero de import, se a Fase 4 exigir).
## Decisões travadas
- **Um item C6, cinco fases ordenadas.** Não criar seis itens de roadmap; o custo de coordenação supera o benefício. Fases podem ser PRs separados, mas a ordem abaixo é a recomendada (UX barata → DRY → KPI → import pesado → preview token).
- **Dependência dura de C2.** Este item só faz sentido com o código C2 mesclado; não reabre o escopo de v1 do cadastro nominal.
- **Cortável se a base permanecer pequena.** Se a campanha não importar milhares de linhas antes de 16/08, as fases 3–5 podem escorregar; as fases 1–2 (UX + DRY) continuam baratas e valem a pena cedo.
- **Bypass do invariante de telefone é opt-in por `context`, nunca global.** Precedente: locks em `contactPhoneInvariant.ts` / hook `enforceUniqueContactPhone` em `Contact.ts`. Caller que já rodou `acquireContactPhoneLocks` na mesma txn pode passar `context: { skipContactPhoneInvariant: true }`; o hook só respeita se a txn já segura os locks (falha fechado caso contrário — validar na implementação).
- **Bulk insert segue o padrão TSE seed.** `payload.db.drizzle` em txn (como `pnpm db:seed:tse` / `scripts/seed-tse-results.mjs`), com `ON CONFLICT` / tratamento de unique `supporter_contact_nucleus`, não milhares de `payload.create` sob um único `acquireContactPhoneLocks` de 5k phones.
- **Preview token: sem Redis novo no v1 do C6.** Preferir blob assinado curto (HMAC + expiry) ou staging em memória/`unstable_cache` com TTL curto no processo; se insuficiente na Vercel, staging em tabela efêmera com TTL + job de limpeza. Decisão final na Fase 4 com medição.
- **i18n e naming** (AGENTS.md): identificadores em inglês (`campaignListUrl`, `CampaignListPagination`, `mapCampaignFormActionError`, `skipContactPhoneInvariant`, `importToken`), strings visíveis em pt-BR.
## Questões em aberto
- **Onde vive o staging do preview?** Blob assinado vs tabela `supporterImportBatch` vs cache em processo. **Recomendação:** começar com blob assinado (sem migration); só promover a tabela se o payload + HMAC estourar limites práticos da action.
- **Chunk size do bulk import.** **Recomendação:** 200–500 rows por commit interno, com progresso retornado ao wizard; calibrar com teste de carga local contra `teqo_test`.
- **KPI: drizzle raw vs Payload `count` + group.** **Recomendação:** um `SELECT … COUNT(*) FILTER` (ou `GROUP BY vote_intention`) via drizzle com o mesmo `where` de acesso que `buildSupporterListWhere` + constraint de `canReadSupporter`; reusar `result.totalDocs` da listagem para o total quando a página já carregou o find.
- **Extrair filter shell agora ou só URL + pagination?** **Recomendação:** extrair URL helpers + pagination no primeiro PR; filter shell (`valuesRef` + `useTransition` + disclosure) no segundo — o shell é o mais arriscado para regressão de núcleos.
- **Migrar leadership forms para o mapper compartilhado no mesmo PR?** **Recomendação:** sim para o mapper/`fieldError` (poucas linhas); não obrigar leadership a mudar copy de mensagens.
## Abordagem proposta
```mermaid
flowchart TD
F1["Fase 1 — UX<br/>StrictCombobox + AlertDialog"]
F2["Fase 2 — DRY listas/forms<br/>campaignListUrl · pagination · fieldError"]
F3["Fase 3 — KPI agregado SQL"]
F4["Fase 4 — Import bulk<br/>drizzle + skipContactPhoneInvariant"]
F5["Fase 5 — Preview token<br/>sem round-trip 5k"]
C2["C2 apoiadores ✓ eng."] --> F1
F1 --> F2
F2 --> F3
F3 --> F4
F4 --> F5
```
### Fase 1 — UX alinhada (barata)
- **`SupporterForm`**: município via `StrictCombobox` + `municipalityComboboxOptions()` (já usado em `SupporterFilters` / `NucleusFilters`); validação continua em `resolveBahiaMunicipality` / `optionalBahiaCity`.
- **`RemoveSupporterDataButton`**: trocar `window.confirm` por `AlertDialog` no padrão de `ArchiveNucleusDialog.tsx`.
### Fase 2 — DRY com núcleos / leadership
- **`src/utilities/campaignListUrl.ts`** (novo): `firstValue`, `normalizedText`, `strictDecimalInteger`, `inspectRaw*ListParams`, shape de `resolve*ListUrl` — hoje clonados em `nucleusUi.ts` e `supporterUi.ts`. Domínio (`parse*ListParams`, `build*ListWhere`) permanece nos módulos de domínio.
- **`CampaignListPagination`**: `SupporterPagination` já reusa `getNucleusPaginationPages`; extrair o markup Pagination compartilhado com `hrefForPage`.
- **`fieldError` / `errorProps`**: módulo pequeno (ex. `src/utilities/campaignFormFields.ts`) usado por `LeadershipForm` e `SupporterForm`.
- **`mapCampaignFormActionError`**: consolidar o catch FormDataBoundaryError → Zod → safelist → genérico de `novo/formActions.ts`, `[id]/formActions.ts` e `leadershipFormActions.ts`.
- **Filter shell** (opcional neste PR ou PR seguinte): `CampaignListFiltersShell` a partir de `NucleusFilters` / `SupporterFilters`.
### Fase 3 — KPI da lista
- **`loadSupporterListOverviewData`**: substituir 3× `payload.count` por um agregado SQL (drizzle) com o mesmo escopo de acesso; alinhar total com `totalDocs` da listagem quando possível.
- Opcional: `createLocalReq` uma vez e passar `req` aos loaders da página para reutilizar cache de access em `req.context`.
### Fase 4 — Import em escala
- **`confirmSupporterImportRecord`**: após locks + lookup em batch, insert de contacts/supporters via `payload.db.drizzle` (espelhar seed TSE), com tratamento de conflito unique.
- **`Contact` hook**: honrar `context.skipContactPhoneInvariant` somente quando seguro.
- Chunking / `maxDuration` se um único txn de 5k ainda estourar timeout.
### Fase 5 — Preview sem round-trip
- **`previewSupporterImport`**: retorna `{ counts, sampleRows, importToken }` (amostra para UI; hoje `slice(0, 100)`).
- **`confirmSupporterImport`**: aceita token (ou id de batch) em vez do array completo de rows `ok`.
- **`SupporterImportWizard`**: para de guardar/reenviar todas as rows.
**Migration:** só se a Fase 5 escolher tabela de staging — `pnpm migrate:create add_supporter_import_batch` (TTL + cleanup). Fases 1–4: sem migration de schema de produto.
## Dependências
- **Dura:** C2 cadastro nominal (código em `supporter*` / `/campanha/apoiadores`).
- **Suave:** nenhuma. Produção com dados reais ainda espera Onda 0 (Consent keys) — C6 não cria chave nova.
- Reusa: `nucleusUi.ts`, `NucleusFilters.tsx`, `NucleusPagination.tsx`, `LeadershipForm.tsx`, `ArchiveNucleusDialog.tsx`, `contactPhoneInvariant.ts`, `Contact.ts`, padrão drizzle do seed TSE, `territoryComboboxOptions.ts`.
## Não escopo
- Reabrir decisões de v1 do C2 (telefone obrigatório, kit mínimo, import só `geral`, etc.) — [cadastro-nominal-apoiadores.md](cadastro-nominal-apoiadores.md).
- Agregado de apoiadores no overview de núcleos — follow-up de B1.
- GOTV / dia D — C5.
- Push/notificações — D2 / [notifications.md](notifications.md).
- Índices `pg_trgm` / FTS em `contact` (mencionado na review de perf; só se busca ILIKE virar gargalo medido).
## Referências
- `docs/roadmap.md` — Trilha C, item C6; sequência Janela 2
- [cadastro-nominal-apoiadores.md](cadastro-nominal-apoiadores.md) — C2 v1
- Review `/simplify` 2026-07-18 (quality / reuse / performance) — origem da lista de fases
- `src/app/(campaign)/campanha/actions/supporter.ts` — preview/confirm import
- `src/utilities/supporterUi.ts` / `supporterPageData.ts` — lista + KPIs
- `src/utilities/nucleusUi.ts` / `src/components/campaign/NucleusFilters.tsx` / `NucleusPagination.tsx` — padrões a extrair
- `src/components/campaign/LeadershipForm.tsx` / `ArchiveNucleusDialog.tsx` — fieldError / AlertDialog
- `src/collections/Contact.ts` — `enforceUniqueContactPhone`
- `scripts/seed-tse-results.mjs` — bulk drizzle em txn
- AGENTS.md — `overrideAccess: false`, transações, Contact + locks, naming

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-18 (MVP + Ciclo 2 deployados; A2 entregue: cadastro município↔ZE + chips opt-in; C2 engenharia pronta — merge/deploy + Consent keys jurídicos ainda pendentes)
Atualizado em: 2026-07-18 (MVP + Ciclo 2 deployados; A2 entregue; C2 engenharia pronta; C6 registrado — escala/DRY pós-C2 para depois do merge)
Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos.
@ -73,6 +73,7 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig
| A3/A4 Baseline TSE 2022 + Gap vs 2022 | `Baseline-Eleitoral-2022` (+ bloco "Baseline 2022" em `Lista-Nucleos-Overview`) | [baseline-eleitoral-tse.md](plans/baseline-eleitoral-tse.md) |
| A5 Insights (5 planos) | `Baseline-Eleitoral-2022` (card "Insights do território", uma linha por insight) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) |
| C2 Cadastro nominal de apoiadores | `Apoiadores-Lista`, `Apoiador-Ficha`, `Importar-CSV-Apoiadores` | [cadastro-nominal-apoiadores.md](plans/cadastro-nominal-apoiadores.md) |
| C6 Escala e DRY pós-C2 | — (sem design novo; reusa os de C2 + padrões de núcleos) | [escala-dry-pos-c2.md](plans/escala-dry-pos-c2.md) |
| C3 Eventos / agenda de mobilização | `Planos-de-Acao`, `Novo-Plano-de-Acao` | [eventos-agenda-mobilizacao.md](plans/eventos-agenda-mobilizacao.md) |
| D2 Notificações (sino) | `Notificacoes-PWA` (central de notificações) | [notifications.md](plans/notifications.md) |
| D1 PWA `/campanha` | `Notificacoes-PWA` (banner "Instale o app da campanha") | [pwa-campanha.md](plans/pwa-campanha.md) |
@ -110,6 +111,7 @@ flowchart TD
subgraph TrilhaC["Trilha C — operação de campo"]
C1["C1 Compartilhar página ✓"]
C2["C2 Cadastro nominal de apoiadores"]
C6["C6 Escala e DRY pós-C2<br/>(import/listas/forms)"]
C3["C3 Eventos / agenda de mobilização"]
C4["C4 Demandas"]
C5["C5 Operação dia D / GOTV<br/>(proposto, a validar)"]
@ -137,52 +139,55 @@ flowchart TD
C3 -.relação actionPlan opcional.-> C4
JUR --> C2prod["C2 em produção<br/>(dados reais)"]
C2 --> C2prod
C2 --> C6
C2 --> C5
C6 -.escala da base.-> C5
D1 --> D2
JUR -.chave de push.-> D2
```
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): B2, C2 (engenharia — produção espera o jurídico), além dos fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues em 2026-07-18** (✓ no grafo): A1, A2, A3, B1, C1 e D1 — ver "Ciclo 2" acima. Destravados por eles: A4 (A2/A3 + B1 prontos), C3/C4 (A1 pronto) e D2 push (D1 pronto; falta a chave jurídica).
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): B2, C2 (engenharia — produção espera o jurídico), além dos fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues em 2026-07-18** (✓ no grafo): A1, A2, A3, B1, C1 e D1 — ver "Ciclo 2" acima. Destravados por eles: A4 (A2/A3 + B1 prontos), C3/C4 (A1 pronto), C6 (após merge de C2) e D2 push (D1 pronto; falta a chave jurídica).
### Sequência de execução por janela do calendário
**Janela 1 — agora → 05/08 (convenções): colocar a vertical em produção e consertar a fundação de dados.**
| Ordem | Item | Plano | Depende de | Paralelizável com |
| ----- | ---------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------- | ----------------- |
| 1 | Onda 0 (jurídico em lote + deploy + lançamento) | — | externo | tudo |
| 2 | A1 Território multi-município/bairro **(entregue 2026-07-18)** | [detalhes](plans/territorio-multi-municipio-bairro.md) | — | B1, C1, A3 |
| Ordem | Item | Plano | Depende de | Paralelizável com |
| ----- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------- | ----------------- |
| 1 | Onda 0 (jurídico em lote + deploy + lançamento) | — | externo | tudo |
| 2 | A1 Território multi-município/bairro **(entregue 2026-07-18)** | [detalhes](plans/territorio-multi-municipio-bairro.md) | — | B1, C1, A3 |
| 3 | A2 Zonas TSE + sugestões cruzadas (chips opt-in território↔ZE) **(entregue 2026-07-18)** | [detalhes](plans/zonas-por-municipio.md) | A1 (nasce contra `cities[]`) | B1 ✓, C1 ✓, A3 ✓ |
| 4 | B1 Overview da lista de núcleos **(entregue 2026-07-18)** | [detalhes](plans/overview-lista-nucleos.md) | — | A1, A2, C1 |
| 5 | C1 Compartilhar página **(entregue 2026-07-18)** | [detalhes](plans/compartilhar-pagina.md) | — | tudo |
| 6 | A3 Baseline TSE 2022 — Fase 1 (import) **(entregue 2026-07-18)** | [detalhes](plans/baseline-eleitoral-tse.md) | — (dado público; sem bloqueador LGPD) | tudo |
| 4 | B1 Overview da lista de núcleos **(entregue 2026-07-18)** | [detalhes](plans/overview-lista-nucleos.md) | — | A1, A2, C1 |
| 5 | C1 Compartilhar página **(entregue 2026-07-18)** | [detalhes](plans/compartilhar-pagina.md) | — | tudo |
| 6 | A3 Baseline TSE 2022 — Fase 1 (import) **(entregue 2026-07-18)** | [detalhes](plans/baseline-eleitoral-tse.md) | — (dado público; sem bloqueador LGPD) | tudo |
**Janela 2 — 05/08 → 16/08 (pré-propaganda): base nominal + inteligência + agenda prontas para o arranque.**
| Ordem | Item | Plano | Depende de | Paralelizável com |
| ----- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -------------------------------- | ----------------- |
| 7 | C2 Cadastro nominal de apoiadores _(engenharia pronta 2026-07-18 — merge/deploy + Consent keys ainda pendentes)_ | [detalhes](plans/cadastro-nominal-apoiadores.md) | produção: lote jurídico (Onda 0) | A4, C3 |
| 8 | A4 Baseline no produto + insight Gap vs 2022 | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3 |
| 9 | C3 Eventos / agenda de mobilização (`actionPlan`) | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4 |
| Ordem | Item | Plano | Depende de | Paralelizável com |
| ----- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -------------------------------- | ----------------- |
| 7 | C2 Cadastro nominal de apoiadores _(engenharia pronta 2026-07-18 — merge/deploy + Consent keys ainda pendentes)_ | [detalhes](plans/cadastro-nominal-apoiadores.md) | produção: lote jurídico (Onda 0) | A4, C3, C6 |
| 8 | C6 Escala e DRY pós-C2 (import em massa, KPI, shells compartilhados com núcleos) | [detalhes](plans/escala-dry-pos-c2.md) | C2 (merge) | A4, C3 |
| 9 | A4 Baseline no produto + insight Gap vs 2022 | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3, C6 |
| 10 | C3 Eventos / agenda de mobilização (`actionPlan`) | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4, C6 |
**Janela 3 — 16/08 → set (campanha de rua): inteligência ampliada, visualização e engajamento.**
| Ordem | Item | Plano | Depende de | Paralelizável com |
| ----- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------- |
| 10 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | B2/B3, C4, D1 |
| 11 | B2 + B3 Mapa da Bahia (geometrias + Leaflet) | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1+B2 (suave: A4 para coroplético de baseline/classe) | A5, C4, D1 |
| 12 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, B3, D1 |
| 13 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo |
| 14 | D2 Notificações (push + sino) — sino não depende do PWA; push sim | [detalhes](plans/notifications.md) | D1 ✓ (push) + chave `campanha-notificacoes-push` do lote jurídico | A5, B3, C4 |
| 11 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | B2/B3, C4, D1 |
| 12 | B2 + B3 Mapa da Bahia (geometrias + Leaflet) | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1+B2 (suave: A4 para coroplético de baseline/classe) | A5, C4, D1 |
| 13 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, B3, D1 |
| 14 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo |
| 15 | D2 Notificações (push + sino) — sino não depende do PWA; push sim | [detalhes](plans/notifications.md) | D1 ✓ (push) + chave `campanha-notificacoes-push` do lote jurídico | A5, B3, C4 |
**Janela 4 — set → 04/10 (reta final): dobradinha, dia D e estabilização.**
| Ordem | Item | Plano | Depende de |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| 15 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) |
| 16 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 |
| 17 | C5 **Operação dia D / GOTV** _(item proposto nesta revisão — validar com produto)_: confirmação de comparecimento da base nominal em 04/10 (lista de apoiadores por núcleo/zona com marcação "confirmou que vai votar / votou", visão de cobertura para a coordenação). É o uso final da base construída em C2; a literatura de campanha trata a mobilização do dia como onde eleições apertadas se decidem. Sem plano ainda. | design: [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) | C2 |
| 18 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. | — | — |
| 16 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) |
| 17 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 |
| 18 | C5 **Operação dia D / GOTV** _(item proposto nesta revisão — validar com produto)_: confirmação de comparecimento da base nominal em 04/10 (lista de apoiadores por núcleo/zona com marcação "confirmou que vai votar / votou", visão de cobertura para a coordenação). É o uso final da base construída em C2; a literatura de campanha trata a mobilização do dia como onde eleições apertadas se decidem. Sem plano ainda. | design: [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) | C2 (suave: C6 para escala da base) |
| 19 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. | — | — |
**Fill-ins (qualquer janela, quando houver folga; nenhum bloqueia nada):**
@ -191,7 +196,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): B2, C2 (enge
- **Reset de senha self-service + foto de perfil** (UX adiada do ciclo 1). _(plano MVP)_
- **Higiene de código:** varredura PascalCase dos componentes legados. _(notebook Núcleos)_
**Cortes seguros se o prazo apertar** (nesta ordem): B4 camada de zonas, B3 mapa Leaflet, C4 demandas, D2 push (mantendo o sino), fill-ins (D1 PWA já entregue, saiu da lista de cortes). **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap — são respectivamente o risco legal, a base de dados, a operação da propaganda e o instrumento de alocação de esforço.
**Cortes seguros se o prazo apertar** (nesta ordem): B4 camada de zonas, B3 mapa Leaflet, C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2 (UX + DRY) se houver folga. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap — são respectivamente o risco legal, a base de dados, a operação da propaganda e o instrumento de alocação de esforço.
### Itens consolidados/removidos nesta revisão (2026-07-17)

View file

@ -1,12 +1,10 @@
import config from '@payload-config'
import { ArrowLeftIcon, ShieldCheckIcon } from 'lucide-react'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import config from '@payload-config'
import { getPayload } from 'payload'
import { ArrowLeftIcon, ShieldCheckIcon } from 'lucide-react'
import {
setSupporterVoteIntentionFormAction,
} from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions'
import { setSupporterVoteIntentionFormAction } from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions'
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { ConsentText } from '@/components/campaign/ConsentText'
import { RemoveSupporterDataButton } from '@/components/campaign/RemoveSupporterDataButton'
@ -22,8 +20,8 @@ import {
loadSupporterDetailPageData,
SupporterNotFoundError,
} from '@/utilities/supporterPageData'
import { parseSupporterId } from '@/utilities/supporterViewModels'
import { canAccessSupporterArea, supporterVoteIntentionLabels } from '@/utilities/supporterUi'
import { parseSupporterId } from '@/utilities/supporterViewModels'
type SupporterDetailPageProps = {
params: Promise<{ id: string }>
@ -59,7 +57,8 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag
? supporterVoteIntentionLabels[supporter.voteIntention]
: null
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, '') ?? 'https://jorgesolla.com.br'
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, '') ?? 'https://jorgesolla.com.br'
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">

View file

@ -1,6 +1,6 @@
import config from '@payload-config'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import config from '@payload-config'
import { getPayload } from 'payload'
import { createSupporterFormAction } from '@/app/(campaign)/campanha/(app)/apoiadores/novo/formActions'

View file

@ -187,11 +187,7 @@ const upsertContactByPhone = async ({
return { contactID: contact.id, reused: false }
}
const createValidatedSupporter = async (
payload: Payload,
actor: CampaignUser,
input: unknown,
) => {
const createValidatedSupporter = async (payload: Payload, actor: CampaignUser, input: unknown) => {
const data = supporterCreateSchema.parse(input)
try {
@ -271,11 +267,8 @@ const createValidatedSupporter = async (
}
}
export const createSupporterRecord = (
payload: Payload,
actor: CampaignUser,
input: unknown,
) => createValidatedSupporter(payload, actor, input)
export const createSupporterRecord = (payload: Payload, actor: CampaignUser, input: unknown) =>
createValidatedSupporter(payload, actor, input)
export const createSupporter = async (input: unknown) => {
const { payload, actor } = await getCampaignActionContext()
@ -350,9 +343,7 @@ const mapImportHeader = (header: string): string | null => {
return aliases[normalized] ?? null
}
const parseVoteIntentionCell = (
value: string,
): SupporterVoteIntention | undefined | 'invalid' => {
const parseVoteIntentionCell = (value: string): SupporterVoteIntention | undefined | 'invalid' => {
const trimmed = value.trim()
if (!trimmed) return undefined
const normalized = normalizeImportHeader(trimmed).replace(/-/g, '_')
@ -524,9 +515,8 @@ export const previewSupporterImportText = async (
const counts = {
ok: rows.filter(isSupporterImportOkRow).length,
duplicate: rows.filter((row) => row.status === 'duplicado_pelo_telefone').length,
error: rows.filter(
(row) => row.status !== 'ok' && row.status !== 'duplicado_pelo_telefone',
).length,
error: rows.filter((row) => row.status !== 'ok' && row.status !== 'duplicado_pelo_telefone')
.length,
}
return { rows, counts }

View file

@ -1,7 +1,7 @@
'use client'
import { useActionState, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useActionState, useEffect, useState } from 'react'
import { toast } from 'sonner'
import type { SupporterFormState } from '@/app/(campaign)/campanha/(app)/apoiadores/novo/formActions'
@ -21,10 +21,10 @@ import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone'
import type { SupporterNucleusOption } from '@/utilities/supporterViewModels'
import { supporterVoteIntentionLabels } from '@/utilities/supporterUi'
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone'
import { supporterVoteIntentionLabels } from '@/utilities/supporterUi'
import type { SupporterNucleusOption } from '@/utilities/supporterViewModels'
export type SupporterFormAction = (
state: SupporterFormState,
@ -218,9 +218,7 @@ export const SupporterForm = ({
</Field>
<Field data-invalid={nucleusField.invalid}>
<FieldLabel htmlFor="supporter-nucleus">
Núcleo{requireNucleus ? ' *' : ''}
</FieldLabel>
<FieldLabel htmlFor="supporter-nucleus">Núcleo{requireNucleus ? ' *' : ''}</FieldLabel>
<NativeSelect
id="supporter-nucleus"
name="nucleus"

View file

@ -1,9 +1,9 @@
'use client'
import { CheckCircle2Icon, FileUpIcon, UploadIcon } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useState, useTransition } from 'react'
import { CheckCircle2Icon, FileUpIcon, UploadIcon } from 'lucide-react'
import { toast } from 'sonner'
import {
@ -17,12 +17,7 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
} from '@/components/ui/field'
import { Field, FieldContent, FieldDescription, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import {
@ -95,9 +90,7 @@ export const SupporterImportWizard = () => {
setStep(1)
} catch (previewError) {
setError(
previewError instanceof Error
? previewError.message
: 'Não foi possível ler o arquivo.',
previewError instanceof Error ? previewError.message : 'Não foi possível ler o arquivo.',
)
}
})
@ -145,10 +138,16 @@ export const SupporterImportWizard = () => {
<span
className={cn(
'inline-flex size-7 items-center justify-center rounded-full border text-xs font-semibold',
index <= step ? 'border-primary bg-primary text-primary-foreground' : 'text-muted-foreground',
index <= step
? 'border-primary bg-primary text-primary-foreground'
: 'text-muted-foreground',
)}
>
{index < step ? <CheckCircle2Icon className="size-4" aria-hidden="true" /> : index + 1}
{index < step ? (
<CheckCircle2Icon className="size-4" aria-hidden="true" />
) : (
index + 1
)}
</span>
<span className={index <= step ? 'font-medium' : 'text-muted-foreground'}>{label}</span>
{index < steps.length - 1 ? <span className="text-muted-foreground">→</span> : null}
@ -235,9 +234,7 @@ export const SupporterImportWizard = () => {
<TableCell>{row.telefone}</TableCell>
<TableCell>{row.municipio || '—'}</TableCell>
<TableCell>
{row.voteIntention
? supporterVoteIntentionLabels[row.voteIntention]
: '—'}
{row.voteIntention ? supporterVoteIntentionLabels[row.voteIntention] : '—'}
</TableCell>
<TableCell>{importStatusLabels[row.status]}</TableCell>
</TableRow>
@ -282,7 +279,11 @@ export const SupporterImportWizard = () => {
disabled={!operatorAttested || preview.counts.ok === 0 || isPending}
onClick={confirmImport}
>
{isPending ? <Spinner data-icon="inline-start" /> : <UploadIcon data-icon="inline-start" />}
{isPending ? (
<Spinner data-icon="inline-start" />
) : (
<UploadIcon data-icon="inline-start" />
)}
{isPending ? 'Importando…' : `Importar ${preview.counts.ok} apoiadores`}
</Button>
</div>

View file

@ -1,5 +1,5 @@
import Link from 'next/link'
import { PhoneIcon } from 'lucide-react'
import Link from 'next/link'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
@ -13,8 +13,8 @@ import {
TableRow,
} from '@/components/ui/Table'
import { formatBrazilianPhoneInput } from '@/utilities/phone'
import type { SupporterListItemViewModel } from '@/utilities/supporterViewModels'
import { supporterVoteIntentionLabels } from '@/utilities/supporterUi'
import type { SupporterListItemViewModel } from '@/utilities/supporterViewModels'
export type SupporterListProps = {
supporters: SupporterListItemViewModel[]

View file

@ -9,18 +9,12 @@ import {
} from '@/lib/schemas/primitives'
import { normalizeSearchPhrase } from '@/utilities/wordStartFilter'
export const supporterVoteIntentions = [
'certo',
'tende_a_certo',
'indeciso',
'outro',
] as const
export const supporterVoteIntentions = ['certo', 'tende_a_certo', 'indeciso', 'outro'] as const
export type SupporterVoteIntention = (typeof supporterVoteIntentions)[number]
export const isSupporterVoteIntention = (value: unknown): value is SupporterVoteIntention =>
typeof value === 'string' &&
(supporterVoteIntentions as readonly string[]).includes(value)
typeof value === 'string' && (supporterVoteIntentions as readonly string[]).includes(value)
const canonicalMunicipalityBySearchValue = new Map(
bahiaMunicipalities.map((city) => [normalizeSearchPhrase(city), city]),

View file

@ -5,18 +5,18 @@ import {
getSupporterRegistrationConsent,
getSupporterVoteIntentionConsent,
} from '@/utilities/campaignConsent'
import {
toSupporterDetailViewModel,
type SupporterDetailViewModel,
type SupporterListOverviewViewModel,
type SupporterNucleusOption,
} from '@/utilities/supporterViewModels'
import {
buildSupporterListWhere,
resolveSupporterListUrl,
supporterPageSize,
type SupporterListState,
} from '@/utilities/supporterUi'
import {
toSupporterDetailViewModel,
type SupporterDetailViewModel,
type SupporterListOverviewViewModel,
type SupporterNucleusOption,
} from '@/utilities/supporterViewModels'
type SupporterListSearchParams = Record<string, string | string[] | undefined>

View file

@ -48,7 +48,9 @@ export type SupporterDetailViewModel = {
const contactFromSupporter = (supporter: { contact: Supporter['contact'] }): Contact | null =>
isPopulatedRelationship<Contact>(supporter.contact) ? supporter.contact : null
const nucleusFromSupporter = (supporter: { nucleus?: Supporter['nucleus'] }): ElectoralNucleus | null =>
const nucleusFromSupporter = (supporter: {
nucleus?: Supporter['nucleus']
}): ElectoralNucleus | null =>
isPopulatedRelationship<ElectoralNucleus>(supporter.nucleus) ? supporter.nucleus : null
export const toSupporterListItemViewModel = (