From a18e2f15f504bc5bd379a89bc0f53ee6352d15fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sat, 18 Jul 2026 20:33:50 -0300 Subject: [PATCH] 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. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + docs/plans/cadastro-nominal-apoiadores.md | 2 + docs/plans/escala-dry-pos-c2.md | 122 ++++++++++++++++++ docs/roadmap.md | 53 ++++---- .../campanha/(app)/apoiadores/[id]/page.tsx | 13 +- .../campanha/(app)/apoiadores/novo/page.tsx | 2 +- .../(campaign)/campanha/actions/supporter.ts | 22 +--- src/components/campaign/SupporterForm.tsx | 12 +- .../campaign/SupporterImportWizard.tsx | 33 ++--- src/components/campaign/SupporterList.tsx | 4 +- src/lib/schemas/supporter.ts | 10 +- src/utilities/supporterPageData.ts | 12 +- src/utilities/supporterViewModels.ts | 4 +- 13 files changed, 202 insertions(+), 88 deletions(-) create mode 100644 docs/plans/escala-dry-pos-c2.md diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 0a3d351d..2cc02536 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -35,6 +35,7 @@ alwaysApply: false - **D1 PWA** — manifest + SW servidos por route handlers sob `/campanha` (script em `campaignPwa.ts`), cache `campanha-` 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 diff --git a/docs/plans/cadastro-nominal-apoiadores.md b/docs/plans/cadastro-nominal-apoiadores.md index b9e65095..2975fcac 100644 --- a/docs/plans/cadastro-nominal-apoiadores.md +++ b/docs/plans/cadastro-nominal-apoiadores.md @@ -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 diff --git a/docs/plans/escala-dry-pos-c2.md b/docs/plans/escala-dry-pos-c2.md new file mode 100644 index 00000000..99e0af65 --- /dev/null +++ b/docs/plans/escala-dry-pos-c2.md @@ -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
StrictCombobox + AlertDialog"] + F2["Fase 2 — DRY listas/forms
campaignListUrl · pagination · fieldError"] + F3["Fase 3 — KPI agregado SQL"] + F4["Fase 4 — Import bulk
drizzle + skipContactPhoneInvariant"] + F5["Fase 5 — Preview token
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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 021171e9..817ed62c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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
(import/listas/forms)"] C3["C3 Eventos / agenda de mobilização"] C4["C4 Demandas"] C5["C5 Operação dia D / GOTV
(proposto, a validar)"] @@ -137,52 +139,55 @@ flowchart TD C3 -.relação actionPlan opcional.-> C4 JUR --> C2prod["C2 em produção
(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) diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx index 278f18b8..52940858 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx @@ -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 (
diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx index a1bb34f2..5854786a 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx @@ -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' diff --git a/src/app/(campaign)/campanha/actions/supporter.ts b/src/app/(campaign)/campanha/actions/supporter.ts index dc0eead3..18778546 100644 --- a/src/app/(campaign)/campanha/actions/supporter.ts +++ b/src/app/(campaign)/campanha/actions/supporter.ts @@ -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 } diff --git a/src/components/campaign/SupporterForm.tsx b/src/components/campaign/SupporterForm.tsx index 364822e7..fb16c16d 100644 --- a/src/components/campaign/SupporterForm.tsx +++ b/src/components/campaign/SupporterForm.tsx @@ -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 = ({ - - Núcleo{requireNucleus ? ' *' : ''} - + Núcleo{requireNucleus ? ' *' : ''} { 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 = () => { - {index < step ? {label} {index < steps.length - 1 ? → : null} @@ -235,9 +234,7 @@ export const SupporterImportWizard = () => { {row.telefone} {row.municipio || '—'} - {row.voteIntention - ? supporterVoteIntentionLabels[row.voteIntention] - : '—'} + {row.voteIntention ? supporterVoteIntentionLabels[row.voteIntention] : '—'} {importStatusLabels[row.status]} @@ -282,7 +279,11 @@ export const SupporterImportWizard = () => { disabled={!operatorAttested || preview.counts.ok === 0 || isPending} onClick={confirmImport} > - {isPending ? : } + {isPending ? ( + + ) : ( + + )} {isPending ? 'Importando…' : `Importar ${preview.counts.ok} apoiadores`}
diff --git a/src/components/campaign/SupporterList.tsx b/src/components/campaign/SupporterList.tsx index c306c2d3..157a6ec3 100644 --- a/src/components/campaign/SupporterList.tsx +++ b/src/components/campaign/SupporterList.tsx @@ -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[] diff --git a/src/lib/schemas/supporter.ts b/src/lib/schemas/supporter.ts index 60672884..6661200b 100644 --- a/src/lib/schemas/supporter.ts +++ b/src/lib/schemas/supporter.ts @@ -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]), diff --git a/src/utilities/supporterPageData.ts b/src/utilities/supporterPageData.ts index 37668aba..ab6d7150 100644 --- a/src/utilities/supporterPageData.ts +++ b/src/utilities/supporterPageData.ts @@ -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 diff --git a/src/utilities/supporterViewModels.ts b/src/utilities/supporterViewModels.ts index d9d5ceb9..b21636a3 100644 --- a/src/utilities/supporterViewModels.ts +++ b/src/utilities/supporterViewModels.ts @@ -48,7 +48,9 @@ export type SupporterDetailViewModel = { const contactFromSupporter = (supporter: { contact: Supporter['contact'] }): Contact | null => isPopulatedRelationship(supporter.contact) ? supporter.contact : null -const nucleusFromSupporter = (supporter: { nucleus?: Supporter['nucleus'] }): ElectoralNucleus | null => +const nucleusFromSupporter = (supporter: { + nucleus?: Supporter['nucleus'] +}): ElectoralNucleus | null => isPopulatedRelationship(supporter.nucleus) ? supporter.nucleus : null export const toSupporterListItemViewModel = (