diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 6f9a4ac1..0fac6a70 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -15,7 +15,7 @@ alwaysApply: false - **C3 Planos de Ação implementado (2026-07-18):** collection `actionPlan` + vertical `/campanha/planos` (lista com tabs Próximos/Todos/Realizados/Rascunhos, detalhe com tabs Visão geral/Tarefas/Atualizações, forms novo/editar), blocos "Próximos eventos" no overview de núcleos e no dashboard; `startAt` opcional só em rascunho; access por `coordinators`/`leadership`; migration `20260718_222832_add_action_plan`. - **B2 Mapa Fase 1 (geometrias) entregue (2026-07-18):** TopoJSON estático municípios IBGE (`bahia-municipalities.topo.json`, ~132 KB, object `municipalities`) + TIs por dissolução (`bahia-identity-territories.topo.json`, ~15 KB, object `territories`); tabela `bahiaMunicipalityCodes` + `codeForMunicipality` / `municipalityForCode`; helpers `bahiaGeometries` (`getMunicipalityFeature` / `getTerritoryFeature`); script re-executável `pnpm build:geometries` (cache `data/geometries/`, sem DB); testes int de bijeção/cobertura/teto de bytes. Sem migration, sem UI (Leaflet = B3). Plano as-built: [`docs/plans/mapa-bahia-geometrias.md`](../../../docs/plans/mapa-bahia-geometrias.md). - **B5 registrado (2026-07-18):** débitos de escala/DRY pós-B2 do `/simplify` que não entraram no cleanup (lazy load mun/TI + helper `ensureCachedDownload` para `build:geometries` / `db:seed:tse`) — plano [`docs/plans/escala-dry-pos-b2.md`](../../../docs/plans/escala-dry-pos-b2.md); F1 preferencialmente com B3; F2 cortável. -- **C7 registrado (2026-07-18) / entregue (2026-07-19):** débitos de escala/DRY pós-C3 do `/simplify` — plano [`docs/plans/escala-dry-pos-c3.md`](../../../docs/plans/escala-dry-pos-c3.md); composição territorial, shell assíncrono de contato, leituras por aba, short-circuit de hooks. +- **C7 registrado (2026-07-18) / entregue (2026-07-19):** débitos de escala/DRY pós-C3 do `/simplify` — plano [`docs/plans/escala-dry-pos-c3.md`](../../../docs/plans/escala-dry-pos-c3.md); composição territorial, shell assíncrono de contato, leituras por aba, short-circuit de hooks. Débitos pós-`/simplify` → **C11** ([`docs/plans/escala-dry-pos-c7.md`](../../../docs/plans/escala-dry-pos-c7.md)). - **Trilha E registrada (2026-07-19) — mapa de projeção por município:** plano E1–E5 para equiparar e superar as planilhas "Mapa de projeção de votos Solla 2026" (`docs/sheets/`): E1 metas em cenários (`voteGoals` Bom/Regular/Mínimo) + `priority` no núcleo (migration compartilhada com E3), E2 série histórica TSE 2014/2018 + `computeVoteTrend` derivado (sem migration), E3 `dobradinhaNotes`/`nextSteps` staff-only na inteligência, E4 import único `pnpm db:seed:mapa` (sem criar `Contact`/`leadership`), E5 Salvador por bairro registrado como futuro. Somente documentação/plano — nada implementado. Plano: [`docs/plans/mapa-projecao-municipios.md`](../../../docs/plans/mapa-projecao-municipios.md). - Fluxos operacionais cobertos: login isolado, CRUD/arquivo de núcleos por slug, designação de coordenador (`geral`), CRM de lideranças, estimativas sugerir/confirmar com versão UUID, feed de atualizações, convites WhatsApp (`wa.me` + hash de uso único), dashboard com agregados só de núcleos ativos. - Pendência operacional (não de código): textos provisórios Onda 0 provisionados por migration ([`docs/plans/onda-0.md`](../../../docs/plans/onda-0.md)); substituição pela assessoria jurídica eleitoral + hold de PII real de lideranças/apoiadores até lá; smoke pós-deploy Vercel. Ver Onda 0 em [`docs/roadmap.md`](../../../docs/roadmap.md). @@ -45,6 +45,7 @@ alwaysApply: false - **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. - **Em 2026-07-19, C9 Escala e DRY pós-C8 entregue:** `supporterListFilters.ts` (filtro unificado Payload↔SQL + `contactSearchQuery`), `loadSupportersPageData` (1× lookup de núcleos do coordenador), `apoiadores/[id]/formActions` no mapper compartilhado, `fieldError` nos 8 componentes de form restantes. Plano: [`docs/plans/escala-dry-pos-c8.md`](../../../docs/plans/escala-dry-pos-c8.md). - **Em 2026-07-19, C10 Escala e DRY pós-C9 registrado** (após `/simplify` do C9): dedup de access na lista (`req.context`), query única de núcleos do coordenador, `errorProps` nos forms grandes. Plano: [`docs/plans/escala-dry-pos-c9.md`](../../../docs/plans/escala-dry-pos-c9.md). +- **Em 2026-07-19, C11 Escala e DRY pós-C7 registrado** (após `/simplify` do C7): feed O(n) condicional, loaders do detalhe + `loadCampaignUserNamesById`, selects por aba, hot paths mutação/form, utilities de busca/label. Plano: [`docs/plans/escala-dry-pos-c7.md`](../../../docs/plans/escala-dry-pos-c7.md). ## Decisões diff --git a/docs/plans/escala-dry-pos-c3.md b/docs/plans/escala-dry-pos-c3.md index b306816e..56439545 100644 --- a/docs/plans/escala-dry-pos-c3.md +++ b/docs/plans/escala-dry-pos-c3.md @@ -129,6 +129,7 @@ flowchart TD - [escala-dry-pos-c2.md](escala-dry-pos-c2.md) — precedente C6 + `campaignListUrl` - Review `/simplify` 2026-07-18 (quality / reuse / performance) — origem das fases - Review `/simplify` 2026-07-19 — débitos registrados nas fases pendentes acima +- Review `/simplify` 2026-07-19 (pós-entrega C7) — débitos maiores → **C11** / [escala-dry-pos-c7.md](escala-dry-pos-c7.md) - `src/lib/contactSearchQuery.ts` — guard compartilhado client/server (entregue) - `src/migrations/20260719_014906_action_plan_list_perf.ts` — contadores + índice parcial (entregue) - `src/components/campaign/AsyncSearchCombobox.tsx` — shell assíncrono compartilhado (entregue) @@ -138,3 +139,7 @@ flowchart TD - `src/app/(campaign)/campanha/actions/actionPlan.ts` — toggle/append RMW - `src/migrations/20260718_222832_add_action_plan.ts` — índices atuais `status` / `start_at` separados - AGENTS.md — `overrideAccess: false`, transações, naming, território Bahia + +## Simplify (2026-07-19) + +Passagens `/simplify` sobre o diff do C7 (incl. pós-rebase com C8–C9/Onda 0) aplicaram limpezas pontuais (`fieldError`, `LeadershipCombobox`, `searchRef`, depth overview, exports mortos, skip `buildTerritorySuggestions` com override). Débitos maiores registrados no item **C11** do roadmap — [escala-dry-pos-c7.md](escala-dry-pos-c7.md). diff --git a/docs/plans/escala-dry-pos-c7.md b/docs/plans/escala-dry-pos-c7.md new file mode 100644 index 00000000..f783b325 --- /dev/null +++ b/docs/plans/escala-dry-pos-c7.md @@ -0,0 +1,137 @@ +# Escala e DRY pós-C7 (planos de ação / agenda) + +Status: registrado no roadmap (fases pendentes) +Atualizado em: 2026-07-19 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C11) +Responsável: — + +## Contexto + +O C7 ([escala-dry-pos-c3.md](escala-dry-pos-c3.md)) entregou composição territorial (`useCampaignTerritoryFieldsState` + `CampaignTerritoryCoreFields`), shell `AsyncSearchCombobox` (+ `ContactCombobox` / `PrimaryContactCombobox` / `LeadershipCombobox`), leituras por aba no detalhe (`getActionPlanDetailSelect` + `taskProgress` + batch de autores na aba Atualizações), typeahead de liderança (`searchActionPlanLeadershipOptions`), short-circuit de hooks em toggle/append (`context.mutationKind`) e cleanup do `/simplify` (depth overview/tasks, `searchRef`, `fieldError`, remoção de exports mortos). + +Duas passagens `/simplify` (pré- e pós-rebase com `main`/C8–C9) marcaram como **importantes e maiores que cleanup** os follow-ups abaixo. Sem registro, planos com feed longo degradam leitura/append; loaders do detalhe ficam fragmentados em relação ao padrão de núcleos; e hot paths de edição continuam O(n) no array embutido. + +1. **Feed de atualizações O(n) (leitura + escrita).** A aba Atualizações carrega o array `updates` inteiro (corpos até 4 000 chars) sem limite/cursor; `appendActionPlanUpdateRecord` faz read-modify-write do array completo. Escala linear com o tamanho do feed — principal cliff de performance da vertical de agenda. +2. **Toggle de tarefa O(n).** `toggleActionPlanTaskRecord` ainda faz `findByID` + `update` com o array `tasks` inteiro (locks + short-circuit de hooks ajudam, mas o RMW permanece). +3. **Selects do detalhe over-fetch em tasks/updates.** `actionPlanDetailContextSelect` inclui `coordinators`, `responsible`, `createdBy`, `description`, etc. nas abas que só precisam de header + conteúdo da aba — hidratação extra em `depth: 1`. +4. **Loader do detalhe fragmentado.** `planos/[slug]/page.tsx` encadeia `resolveAccessibleActionPlanContext` + `getActionPlanDetailPageData`; núcleos já usam orquestrador único (`loadNucleusDetailPageData` / `loadNucleusActiveTabPageData`). +5. **Batch de nomes de autor duplicado.** `loadActionPlanUpdateAuthorNames` em `actionPlanDetailPageData.ts` repete o padrão de `nucleusUpdatePageData` / overview de núcleos (`campaignUser` `id in` → `Map`). +6. **Form de edição hidrata todas as tarefas.** `getActionPlanEditPageData` usa `actionPlanFormSelect` + `depth: 1` sempre — latência cresce com contagem de tarefas/responsáveis. +7. **`serializeTasks` no hidden input a cada keystroke.** `ActionPlanTaskFields` chama `JSON.stringify` do array completo em todo render enquanto o usuário edita tarefas. +8. **Typeahead sem índice dedicado na busca de liderança.** `searchActionPlanLeadershipOptions` usa `contains` em `contact.*` aninhado; `pg_trgm` em `contact` já existe (C8) mas a query de liderança ainda não reutiliza o padrão otimizado de apoiadores. +9. **Lista de coordenadores sem paginação no form.** `getEligibleNucleusCoordinatorOptions` com `pagination: false` em novo/editar — O(staff) por page view (aceitável no MVP, débito se o time crescer). +10. **`mutationKind` sem tipo compartilhado.** Magic strings `'taskToggle' | 'appendUpdate'` em actions e `ActionPlan.ts` — risco de drift. +11. **DRY menor:** `searchContactComboboxOptions` ainda inline em `contactSearchActions.ts` (liderança já em `utilities/`); label de liderança duplicada entre `actionPlanLeadershipOptions` e `toActionPlanFormViewModel`; `firstValue` local em `actionPlanDetailTabUi.ts` (já existe em `campaignListUrl.ts`); loader combinado da lista `/campanha/planos` (precedente: `loadSupportersPageData` no C9). + +**Explicitamente fora (revisores pediram skip no simplify / já coberto em outro item):** + +- **`errorProps` nos forms** — C10 / [escala-dry-pos-c9.md](escala-dry-pos-c9.md) Fase 3 (inclui `ActionPlanForm`, `NucleusTerritoryFields`, `NucleusTerritoryAndZonesFields`). +- **`pg_trgm` em `contact` (migration)** — C8 entregue; este plano só cobre adoção do padrão de busca na query de liderança se necessário. +- **Unificar `validateCampaignTerritoryFormData` ↔ `createCampaignTerritoryValidationHook`** — tipos de erro diferentes; permanece em [escala-dry-pos-c3.md](escala-dry-pos-c3.md) §Não escopo. +- **Renomear `NucleusTerritoryFields.tsx` → `CampaignTerritoryFields.tsx`** — fricção de descoberta; fill-in, não bloqueia escala. +- **View-model dedup form ↔ detalhe** — refactor grande; só se surgir terceiro consumidor. +- **Testes E2E da vertical** — fora do escopo deste item de escala/DRY. + +## Objetivos + +- Agenda (`/campanha/planos`) permanece responsiva com dezenas de atualizações e tarefas por plano — medir antes de migration; feed append-only só se o volume justificar. +- Detalhe e edição seguem o mesmo padrão de loaders que núcleos (orquestrador + tab data). +- Hot paths de mutação (toggle tarefa, append update) minimizam bytes lidos/escritos sem quebrar locks transacionais nem RBAC. +- Guardrails: `overrideAccess: false` com `user`; sem novo `Consent`; migrations só na Fase 1 condicional (`actionPlanUpdate`). + +## Decisões travadas + +- **Um item C11, cinco fases ordenadas.** Mesmo racional de A7/B5/C8–C10: um ID de roadmap, PRs por fase. Ordem: feed condicional (maior cliff) → loaders/selects (I/O) → mutações/form hot path → utilities DRY → fill-ins. +- **Dependência dura de C7** mergeado em `main` (código de abas, combobox, hooks). Não reabre Fases 1–5 do C7. +- **Fase 1 (feed) é condicional.** Só migration + collection `actionPlanUpdate` após evidência (ex.: planos com >N updates ou latência medida em produção/staging). Até lá: documentar limite soft e monitorar. +- **Fase 1 condicional respeita congelamento ~20/09** — se entrar perto da reta final, adiar para pós-eleição; Fases 2–4 não exigem migration. +- **Cortável** se a agenda permanecer pequena (poucos planos, feeds curtos). Fase 1 é a mais cortável (e a única com migration). Fases 2–3 são as mais valiosas se houver uso real da agenda antes de 16/08. +- **i18n e naming** (AGENTS.md): identificadores em inglês (`loadActionPlanDetailPageData`, `loadCampaignUserNamesById`, `ACTION_PLAN_MUTATION_KIND`), strings visíveis em pt-BR inalteradas. + +## Questões em aberto + +- **Fase 1: threshold para extrair `actionPlanUpdate`?** **Recomendação:** 50 updates por plano ou p95 de append >500 ms em staging; até lá manter array embutido e só paginar leitura (limit + cursor) sem nova collection se bastar. +- **Fase 1: paginação só na leitura vs collection append-only?** **Recomendação:** primeiro limitar/paginar leitura na aba Atualizações (sem migration); collection só se append RMW virar gargalo medido. +- **Fase 3: SQL direto no toggle de tarefa?** **Recomendação:** não na primeira iteração; manter Local API + locks; reavaliar com drizzle na txn só se profiling mostrar necessidade (mesmo trade-off do C8). +- **Fase 2: `loadActionPlanDetailPageData` retorna `{ context, view }` ou só `view`?** **Recomendação:** `{ context, view }` espelhando núcleos; página deixa de encadear dois awaits públicos. + +## Abordagem proposta + +```mermaid +flowchart TD + C7["C7 Fases 1–5 ✓"] --> F2 + F2["Fase 2 — Loaders + selects por aba
+ loadCampaignUserNamesById"] + F2 --> F3["Fase 3 — Hot path form/mutação
(serializeTasks, mutationKind)"] + F2 --> F4["Fase 4 — Utilities DRY
(contact search, labels, firstValue)"] + F1["Fase 1 — Feed escala
(paginação / actionPlanUpdate)"] + C7 --> F1 + F1 -.condicional volume.-> F5["Fase 5 — Loader lista planos"] + F3 -.opcional.-> F5 +``` + +### Fase 1 — Escala do feed de atualizações _(condicional)_ + +- **1a (sem migration):** limitar leitura na aba Atualizações (`limit` + ordenação por `createdAt` desc); UI “carregar mais”; `getActionPlanDetailSelect` pode omitir corpos antigos até expandir. +- **1b (com migration, se medido):** collection `actionPlanUpdate` append-only + relação `actionPlan` ↔ updates; migrar dados existentes; `appendActionPlanUpdateRecord` vira `create` na collection filha; manter snapshot/embed mínimo no pai se necessário para contadores. +- Teste int: append concorrente + leitura paginada; não regressar locks advisory. + +### Fase 2 — Loaders, batching e selects por aba + +- `loadCampaignUserNamesById(payload, user, ids)` em `src/utilities/` — reutilizar em `actionPlanDetailPageData.ts`, `nucleusUpdatePageData.ts` e overview de núcleos. +- `loadActionPlanDetailPageData(payload, user, slug, activeTab)` → `{ context, view }` unificando `resolveAccessibleActionPlanContext` + `getActionPlanDetailPageData`. +- Refinar `getActionPlanDetailSelect`: header mínimo compartilhado + campos por aba (tasks: `tasks` + responsáveis das tarefas; updates: `updates` apenas; overview: relações + `taskDoneCount`/`taskTotal`). +- Opcional: `getActionPlanEditPageData` com `select`/`depth` por seção (tarefas lazy) — só se edição ficar lenta em staging. + +### Fase 3 — Hot paths de mutação e form de tarefas + +- Constante tipada `ACTION_PLAN_MUTATION_KIND` compartilhada entre `actionPlan.ts` actions e `ActionPlan.ts` hooks. +- `ActionPlanTaskFields`: evitar `JSON.stringify` em todo render — atualizar hidden `tasksJson` só no submit ou com debounce/`useDeferredValue`. +- Documentar limite soft de tarefas por plano se toggle continuar RMW. + +### Fase 4 — Utilities e DRY menor + +- `searchContactComboboxOptions(payload, user, query)` em `utilities/`; `contactSearchActions.ts` fica auth wrapper (paridade com liderança). +- `actionPlanLeadershipLabel(leadership)` único para search + `toActionPlanFormViewModel`. +- `actionPlanDetailTabUi.ts` importa `firstValue` de `campaignListUrl.ts`. +- Avaliar índice/query de liderança alinhada ao padrão C8/C9 (`contactSearchQuery` + `pg_trgm` onde aplicável). + +### Fase 5 (opcional) — Loader da lista de planos + +- `loadActionPlansPageData` combinando canonical URL + list (precedente `loadSupportersPageData`). +- Entra só se a lista ganhar filtros/overview pesados; cortável. + +**Migration:** somente Fase 1b (condicional). Demais fases sem schema. + +## Dependências + +- **Dura:** C7 Escala e DRY pós-C3 — entregue (merge em `main` pendente do PR atual). +- **Suave:** C8 `pg_trgm` em `contact` — já em `main`; acelera typeahead se Fase 4 adotar o padrão. +- **Suave:** C10 `errorProps` — paralelo; não bloqueia C11 (escopos distintos). +- Reuso: `src/utilities/actionPlanPageData.ts`, `actionPlanDetailPageData.ts`, `actionPlanViewModels.ts`, `src/app/(campaign)/campanha/actions/actionPlan.ts`, `src/collections/ActionPlan.ts`, padrão `loadNucleusDetailPageData`, `campaignListUrl.ts`, `contactSearchQuery.ts`. + +## Não escopo + +- **`errorProps` nos forms** — C10 / [escala-dry-pos-c9.md](escala-dry-pos-c9.md). +- **Demandas / relação `actionPlan` opcional** — C4. +- **Notificações de agenda** — D2. +- **Calendário visual / mapa de eventos** — fora do horizonte pré-16/08. +- **Unificação FormData ↔ hook de território Payload** — [escala-dry-pos-c3.md](escala-dry-pos-c3.md) §Não escopo. +- **Coordinator list paginada** — só entra se staff > ~50; até lá fill-in. + +## Referências + +- `docs/roadmap.md` — Trilha C, item C11; sequência Janela 2 +- [escala-dry-pos-c3.md](escala-dry-pos-c3.md) — C7 entregue; feed O(n) registrado aqui +- [escala-dry-pos-c6.md](escala-dry-pos-c6.md) — precedente C8 (`pg_trgm`) +- [escala-dry-pos-c8.md](escala-dry-pos-c8.md) — precedente C9/C10 +- `src/utilities/actionPlanDetailPageData.ts` — batch autores + view por aba +- `src/utilities/actionPlanPageData.ts` — depth/select por aba +- `src/app/(campaign)/campanha/actions/actionPlan.ts` — toggle/append RMW +- `src/components/campaign/ActionPlanTaskFields.tsx` — `serializeTasks` +- `src/utilities/actionPlanLeadershipOptions.ts` — typeahead +- `src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts` — server actions de busca +- AGENTS.md — transações, `overrideAccess: false`, naming + +## Simplify (2026-07-19) + +Passagens `/simplify` sobre o diff do C7 (incl. pós-rebase com C8–C9/Onda 0) aplicaram limpezas pontuais (`fieldError`, `LeadershipCombobox`, `searchRef`, depth overview, exports mortos, skip `buildTerritorySuggestions` com override). Débitos maiores registrados neste plano como item **C11** do roadmap. diff --git a/docs/roadmap.md b/docs/roadmap.md index 8e9eefc6..0007f706 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md)) +Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md)) 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. @@ -76,11 +76,12 @@ MVP de território + reporte implementado e enviado (ondas 1–8 + refactors de - **C2 Cadastro nominal de apoiadores** — 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 estáveis `apoiador-cadastro` / `apoiador-intencao-voto` via `campaignConsent.ts` genérico (`getConsentByKey` / `requireConsentByKey`, falha fechada), actions (create / intenção de voto / import CSV só `geral` / `removeSupporterData`), UI `/campanha/apoiadores` (lista+KPIs, ficha, wizard de import, kit mínimo `wa.me`). Telefone obrigatório no v1; `lideranca` sem acesso à área. Engenharia pronta e mesclada — produção com dados reais espera deploy (build Vercel aplica a migration) + Consent keys + aprovação jurídica (Onda 0). [Plano](plans/cadastro-nominal-apoiadores.md). - **C3 Eventos / agenda de mobilização** — collection `actionPlan` + vertical `/campanha/planos` (lista com tabs Próximos/Todos/Realizados/Rascunhos, detalhe com tabs Visão geral/Tarefas/Atualizações, forms novo/editar), blocos "Próximos eventos" no overview de núcleos e no dashboard; `startAt` opcional só em rascunho (obrigatório ao sair de `rascunho`); access por `coordinators`/`leadership` (escopo `lideranca` só toggle `tasks.done` + append `updates`); transações via `withPayloadTransaction`; migration `20260718_222832_add_action_plan`. Sem `Consent` (dado interno de staff). [Plano](plans/eventos-agenda-mobilizacao.md). -- **C7 Escala e DRY pós-C3 — entregue (2026-07-19)** — composição territorial (`useCampaignTerritoryFieldsState` + `CampaignTerritoryCoreFields`), `AsyncSearchCombobox`, leituras por aba + `taskProgress` + typeahead de liderança, short-circuit de hooks em toggle/append; primeira fatia anterior: FormData território, contadores lista, locks, `contactSearchQuery`. Feed O(n) condicional fora de escopo. [Plano](plans/escala-dry-pos-c3.md). +- **C7 Escala e DRY pós-C3 — entregue (2026-07-19)** — composição territorial (`useCampaignTerritoryFieldsState` + `CampaignTerritoryCoreFields`), `AsyncSearchCombobox`, leituras por aba + `taskProgress` + typeahead de liderança, short-circuit de hooks em toggle/append; primeira fatia anterior: FormData território, contadores lista, locks, `contactSearchQuery`. Débitos pós-`/simplify` → C11. [Plano](plans/escala-dry-pos-c3.md). - **C6 Escala e DRY pós-C2 — implementado e mesclado em `main` (2026-07-19)** — import bulk drizzle, token HMAC + `supporterImportBatch`, KPI aggregate, shells compartilhados (`campaignListUrl`, `CampaignListPagination`, `campaignFormFields`, `mapCampaignFormActionError`). Duas passagens `/simplify`; débitos maiores → C8. [Plano](plans/escala-dry-pos-c2.md). - **C8 Escala e DRY pós-C6 — implementado e mesclado em `main` (2026-07-19)** — locks advisory em 1 RT, bulk com `.returning()` + leituras drizzle na txn, overview sem `COUNT(*)` redundante, migration `pg_trgm` em `contact`, `drizzleBulk.ts`, `getCoordinatorNucleusIds`, migração da maioria dos `formActions` para `mapCampaignFormActionError`. Duas passagens `/simplify` (pré- e pós-rebase com C7); débitos maiores → C9. [Plano](plans/escala-dry-pos-c6.md). - **C9 Escala e DRY pós-C8 — implementado (2026-07-19)** — filtro unificado Payload↔SQL (`supporterListFilters`), `contactSearchQuery` no aggregate/lista, `loadSupportersPageData` (1× núcleos do coordenador), `apoiadores/[id]/formActions` no mapper compartilhado, `fieldError` nos componentes de form restantes. Passagem `/simplify`; débitos maiores → C10. [Plano](plans/escala-dry-pos-c8.md). - **C10 Escala e DRY pós-C9 registrado (2026-07-19)** — dedup de access na lista de apoiadores (`req.context`), query única de núcleos do coordenador (`loadCoordinatorNucleusScope`), `errorProps` nos forms grandes, prefetch opcional na criação. [Plano](plans/escala-dry-pos-c9.md). +- **C11 Escala e DRY pós-C7 registrado (2026-07-19)** — feed de atualizações O(n) (paginação / `actionPlanUpdate` condicional), loaders do detalhe + `loadCampaignUserNamesById`, selects por aba, hot paths de mutação/form (`serializeTasks`, `mutationKind` tipado), utilities de busca/label. [Plano](plans/escala-dry-pos-c7.md). ### Ciclo 3 — Trilha E: mapa de projeção por município (registrado 2026-07-19) @@ -112,6 +113,7 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig | C8 Escala e DRY pós-C6 | — (sem design novo; reusa os de C2/C6) | [escala-dry-pos-c6.md](plans/escala-dry-pos-c6.md) | | C9 Escala e DRY pós-C8 | — (sem design novo; reusa os de C2/C6/C8) | [escala-dry-pos-c8.md](plans/escala-dry-pos-c8.md) | | C10 Escala e DRY pós-C9 | — (sem design novo; reusa os de C2/C6/C8/C9) | [escala-dry-pos-c9.md](plans/escala-dry-pos-c9.md) | +| C11 Escala e DRY pós-C7 | — (sem design novo; reusa os de C3 + padrões de núcleos/C6/C9) | [escala-dry-pos-c7.md](plans/escala-dry-pos-c7.md) | | O0+ Escala e DRY pós-Onda 0 | — (sem design novo; reusa `/privacidade` + padrão revalidate `posts`) | [escala-dry-pos-onda0.md](plans/escala-dry-pos-onda0.md) | | B5 Escala e DRY pós-B2 | — (sem design novo; reusa geometrias B2 + padrões de scripts CLI) | [escala-dry-pos-b2.md](plans/escala-dry-pos-b2.md) | | D2 Notificações (sino) | `Notificacoes-PWA` (central de notificações) | [notifications.md](plans/notifications.md) | @@ -159,6 +161,7 @@ flowchart TD C8["C8 Escala e DRY pós-C6
(locks bulk / leituras / forms DRY) ✓"] C9["C9 Escala e DRY pós-C8
(filtros unificados / loader / forms) ✓"] C10["C10 Escala e DRY pós-C9
(access dedup / scope / errorProps)"] + C11["C11 Escala e DRY pós-C7
(feed escala / loaders / hot paths)"] C4["C4 Demandas"] C5["C5 Operação dia D / GOTV
(proposto, a validar)"] end @@ -205,6 +208,8 @@ flowchart TD C6 --> C8 C8 --> C9 C9 --> C10 + C7 --> C11 + C10 -.forms DRY.-> C11 D1 --> D2 JUR -.chave de push.-> D2 DEPLOY -.engenharia entregue.-> O0DRY @@ -218,7 +223,7 @@ flowchart TD A7 -.mesmo loader do baseline.-> E2 ``` -Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0+** (após engenharia Onda 0 ✓) e fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6, C7, C8, C9 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), E2 (após A4 ✓), B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C10 (após C9 ✓; cortável se a base nominal permanecer pequena) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import). +Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0+** (após engenharia Onda 0 ✓) e fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6, C7, C8, C9 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), E2 (após A4 ✓), B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C10 (após C9 ✓; cortável se a base nominal permanecer pequena), C11 (após C7 ✓; cortável se a agenda permanecer pequena; Fase 1 feed condicional) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import). ### Sequência de execução por janela do calendário @@ -244,31 +249,32 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0 | 11 | C7 Escala e DRY pós-C3 **(entregue 2026-07-19 — Fases 1–5; feed O(n) condicional fora de escopo)** | [detalhes](plans/escala-dry-pos-c3.md) | C3 (suave: C6 F2) | A4, C6, C8, E1+E3, E2 | | 12 | C8 Escala e DRY pós-C6 (locks bulk em 1 RT, leituras/`pg_trgm`, helpers drizzle, forms DRY) **(implementado e mesclado em `main` 2026-07-19 — Fases 1–4 + simplify)** _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c6.md) | C6 (merge) | A4, C3, C7, E1+E3, E2, C9 | | 13 | C9 Escala e DRY pós-C8 (filtro unificado Payload↔SQL, `contactSearchQuery` no aggregate, loader único `/apoiadores`, forms restantes) **(implementado e mesclado em `main` 2026-07-19 — Fases 1–4 + simplify)** _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c8.md) | C8 (merge) | A4, C3, C7, E1+E3, E2, C10 | -| 14 | C10 Escala e DRY pós-C9 (dedup access lista, scope único de núcleos do coordenador, `errorProps` nos forms) _(cortável se a base nominal permanecer pequena; Fase 3 DRY pode entrar isolada)_ | [detalhes](plans/escala-dry-pos-c9.md) | C9 (merge) | A4, C3, C7, E1+E3, E2 | -| 15 | E1 Metas em cenários + prioridade + E3 dobradinhas/encaminhamentos manuais (migration compartilhada em `electoralNucleus`) | [detalhes](plans/mapa-projecao-municipios.md) | — | tudo da janela | -| 16 | E2 Série histórica TSE 2014/2018 + tendência automática (seed por ano; sem migration) | [detalhes](plans/mapa-projecao-municipios.md) | A4 ✓ (suave: A7 F1 — mesmo loader) | E1+E3, C7, C8, C9, C10 | +| 14 | C10 Escala e DRY pós-C9 (dedup access lista, scope único de núcleos do coordenador, `errorProps` nos forms) _(cortável se a base nominal permanecer pequena; Fase 3 DRY pode entrar isolada)_ | [detalhes](plans/escala-dry-pos-c9.md) | C9 (merge) | A4, C3, C7, E1+E3, E2, C11 | +| 15 | C11 Escala e DRY pós-C7 (feed O(n) condicional, loaders do detalhe, selects por aba, hot paths mutação/form, utilities de busca) _(cortável se a agenda permanecer pequena; Fase 1 migration só com volume medido)_ | [detalhes](plans/escala-dry-pos-c7.md) | C7 (merge) | A4, C3, C10, E1+E3, E2 | +| 16 | E1 Metas em cenários + prioridade + E3 dobradinhas/encaminhamentos manuais (migration compartilhada em `electoralNucleus`) | [detalhes](plans/mapa-projecao-municipios.md) | — | tudo da janela | +| 17 | E2 Série histórica TSE 2014/2018 + tendência automática (seed por ano; sem migration) | [detalhes](plans/mapa-projecao-municipios.md) | A4 ✓ (suave: A7 F1 — mesmo loader) | E1+E3, C7, C8, C9, C10, C11 | **Janela 3 — 16/08 → set (campanha de rua): inteligência ampliada, visualização e engajamento.** | Ordem | Item | Plano | Depende de | Paralelizável com | | ----- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ---------------------- | -| 17 | E4 Import único da planilha (`pnpm db:seed:mapa`; sem criar `Contact`/`leadership`) | [detalhes](plans/mapa-projecao-municipios.md) | E1 + E3 (suave: E2 — tendência visível na revisão) | A5, A7, B3, B5, C4, D2 | -| 18 | 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 | A7, B3, B5, C4, E4, D1 | -| 19 | A7 Escala e DRY pós-A4 (agregar federal no detalhe, `cityCode` TSE, Alert `confirmed` + Progress) | [detalhes](plans/escala-dry-pos-a4.md) | A4 ✓ (suave: A5/B3/E2 amplificam o custo) | A5, B3, B5, C4, E4, D1 | -| 20 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 coroplético; E1/E2 métricas; B5 F1 lazy geometrias) | A5, A7, B5, C4, E4, D1 | -| 21 | B5 Escala e DRY pós-B2 (lazy geometrias + cache CLI) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 preferencialmente com B3) | A5, A7, B3, C4, E4, D1 | -| 22 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, A7, B3, B5, E4, D1 | -| 23 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo | -| 24 | 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, A7, B3, B5, C4, E4 | +| 18 | E4 Import único da planilha (`pnpm db:seed:mapa`; sem criar `Contact`/`leadership`) | [detalhes](plans/mapa-projecao-municipios.md) | E1 + E3 (suave: E2 — tendência visível na revisão) | A5, A7, B3, B5, C4, D2 | +| 19 | 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 | A7, B3, B5, C4, E4, D1 | +| 20 | A7 Escala e DRY pós-A4 (agregar federal no detalhe, `cityCode` TSE, Alert `confirmed` + Progress) | [detalhes](plans/escala-dry-pos-a4.md) | A4 ✓ (suave: A5/B3/E2 amplificam o custo) | A5, B3, B5, C4, E4, D1 | +| 21 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 coroplético; E1/E2 métricas; B5 F1 lazy geometrias) | A5, A7, B5, C4, E4, D1 | +| 22 | B5 Escala e DRY pós-B2 (lazy geometrias + cache CLI) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 preferencialmente com B3) | A5, A7, B3, C4, E4, D1 | +| 23 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, A7, B3, B5, E4, D1 | +| 24 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo | +| 25 | 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, A7, B3, B5, C4, E4 | **Janela 4 — set → 04/10 (reta final): dobradinha, dia D e estabilização.** | Ordem | Item | Plano | Depende de | | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| 25 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) | -| 26 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 | -| 27 | 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) | -| 28 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. E5 (Salvador por bairro) fica registrado para depois da eleição, condicionado a decisão de produto. | — | — | +| 26 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) | +| 27 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 | +| 28 | 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) | +| 29 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. E5 (Salvador por bairro) fica registrado para depois da eleição, condicionado a decisão de produto. | — | — | **Fill-ins (qualquer janela, quando houver folga; nenhum bloqueia nada):** @@ -278,7 +284,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0 - **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): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C7 fases 4–5 restantes (leituras por aba / short-circuit de hooks / feed O(n) — só se a agenda permanecer pequena; contadores + locks já entregues), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 fases 1–2, C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), **O0+ Fase 1** (revalidate globals pós-migration) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa. +**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa. ### Itens consolidados/removidos nesta revisão (2026-07-17) diff --git a/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts b/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts index e408d9cd..172a9306 100644 --- a/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts +++ b/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts @@ -9,7 +9,10 @@ import { normalizeContactSearchQuery, } from '@/lib/contactSearchQuery' import { getCampaignUser } from '@/utilities/campaignAuth' -import { searchActionPlanLeadershipOptions } from '@/utilities/actionPlanLeadershipOptions' +import { + searchActionPlanLeadershipOptions, + type ActionPlanLeadershipOption, +} from '@/utilities/actionPlanLeadershipOptions' const CONTACT_OPTION_LIMIT = 20 @@ -43,7 +46,7 @@ export const searchActionPlanContactOptions = async ( export const searchActionPlanLeadershipOptionsAction = async ( query: string, -): Promise> => { +): Promise => { const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()]) if (!user) throw new Error('Autenticação necessária.') diff --git a/src/components/campaign/ActionPlanForm.tsx b/src/components/campaign/ActionPlanForm.tsx index a0125168..72cd4a55 100644 --- a/src/components/campaign/ActionPlanForm.tsx +++ b/src/components/campaign/ActionPlanForm.tsx @@ -4,7 +4,7 @@ import { useActionState, useState } from 'react' import Link from 'next/link' import { ContactCombobox, type ContactComboboxOption } from '@/components/campaign/ContactCombobox' -import { AsyncSearchCombobox } from '@/components/campaign/AsyncSearchCombobox' +import { LeadershipCombobox } from '@/components/campaign/LeadershipCombobox' import { ActionPlanTaskFields } from '@/components/campaign/ActionPlanTaskFields' import { CampaignTerritoryFields } from '@/components/campaign/NucleusTerritoryFields' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert' @@ -26,7 +26,6 @@ import { Textarea } from '@/components/ui/textarea' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup' import { Spinner } from '@/components/ui/Spinner' import { actionPlanKindLabels, actionPlanStatusLabels } from '@/lib/schemas/actionPlan' -import { isContactSearchQueryReady } from '@/lib/contactSearchQuery' import type { ActionPlanLeadershipOption } from '@/utilities/actionPlanLeadershipOptions' import type { ActionPlanFormViewModel } from '@/utilities/actionPlanViewModels' import { fieldError } from '@/utilities/campaignFormFields' @@ -79,7 +78,7 @@ export const ActionPlanFormFields = ({ ? { id: plan.responsible.id, name: plan.responsible.name, phone: plan.responsible.phone } : null, ) - const [leadership, setLeadership] = useState<{ id: number; label: string } | null>( + const [leadership, setLeadership] = useState( plan?.leadership ? { id: plan.leadership.id, label: plan.leadership.label } : null, ) @@ -304,19 +303,12 @@ export const ActionPlanFormFields = ({ Liderança vinculada - { - const results = await searchLeaderships(query) - return results.map((option) => ({ id: option.id, label: option.label })) - }} - emptyOptionLabel="Nenhuma" - dialogDescription="Busque lideranças engajadas por nome ou celular." - isQueryReady={isContactSearchQueryReady} - queryTooShortMessage="Digite ao menos dois caracteres para buscar." + search={searchLeaderships} /> Quando vinculada, a liderança engajada vê o plano e pode marcar tarefas e registrar diff --git a/src/components/campaign/AsyncSearchCombobox.tsx b/src/components/campaign/AsyncSearchCombobox.tsx new file mode 100644 index 00000000..2b6a0f49 --- /dev/null +++ b/src/components/campaign/AsyncSearchCombobox.tsx @@ -0,0 +1,198 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { ChevronsUpDownIcon } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { + Command, + CommandDialog, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/Command' +import { Spinner } from '@/components/ui/Spinner' + +export type AsyncSearchOption = { + id: number + label: string +} + +type AsyncSearchComboboxProps = { + name?: string + label: string + value: AsyncSearchOption | null + onChange?: (option: AsyncSearchOption | null) => void + search: (query: string) => Promise + emptyOptionLabel?: string + dialogDescription?: string + isQueryReady?: (query: string) => boolean + queryTooShortMessage?: string + pinnedOptions?: AsyncSearchOption[] + pinnedGroupHeading?: string +} + +export const AsyncSearchCombobox = ({ + name, + label, + value, + onChange, + search, + emptyOptionLabel = 'Nenhum', + dialogDescription = 'Digite para buscar.', + isQueryReady = () => true, + queryTooShortMessage = 'Digite para buscar.', + pinnedOptions = [], + pinnedGroupHeading = 'Sugestões', +}: AsyncSearchComboboxProps) => { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [selected, setSelected] = useState(value) + const [options, setOptions] = useState([]) + const [loading, setLoading] = useState(false) + const [failed, setFailed] = useState(false) + const requestId = useRef(0) + const searchRef = useRef(search) + const isQueryReadyRef = useRef(isQueryReady) + + searchRef.current = search + isQueryReadyRef.current = isQueryReady + + useEffect(() => { + setSelected(value) + }, [value]) + + useEffect(() => { + if (!open) return + if (!isQueryReadyRef.current(query)) { + setOptions([]) + setLoading(false) + setFailed(false) + return + } + + const currentRequestId = ++requestId.current + const timeout = window.setTimeout(() => { + setLoading(true) + setFailed(false) + void searchRef.current(query.trim()) + .then((nextOptions) => { + if (requestId.current !== currentRequestId) return + setOptions(nextOptions) + }) + .catch(() => { + if (requestId.current !== currentRequestId) return + setFailed(true) + }) + .finally(() => { + if (requestId.current === currentRequestId) setLoading(false) + }) + }, 250) + + return () => window.clearTimeout(timeout) + }, [open, query]) + + const choose = (option: AsyncSearchOption | null) => { + setSelected(option) + setOpen(false) + setQuery('') + onChange?.(option) + } + + const visibleOptions = options.filter((option) => option.id !== selected?.id) + const visiblePinned = pinnedOptions.filter((option) => option.id !== selected?.id) + + return ( + <> + {name ? : null} + + + + + + {loading ? ( +
+
+ ) : failed ? ( +

+ Não foi possível concluir a busca. +

+ ) : ( + <> + + choose(null)}> + {emptyOptionLabel} + + + {selected ? ( + + choose(selected)} + > + {selected.label} + + + ) : null} + {visiblePinned.length ? ( + + {visiblePinned.map((option) => ( + choose(option)} + > + {option.label} + + ))} + + ) : null} + {visibleOptions.length ? ( + + {visibleOptions.map((option) => ( + choose(option)} + > + {option.label} + + ))} + + ) : ( +

+ {!isQueryReady(query) ? queryTooShortMessage : 'Nenhum resultado encontrado.'} +

+ )} + + )} +
+
+
+ + ) +} diff --git a/src/components/campaign/CampaignTerritoryCoreFields.tsx b/src/components/campaign/CampaignTerritoryCoreFields.tsx new file mode 100644 index 00000000..cdb566ee --- /dev/null +++ b/src/components/campaign/CampaignTerritoryCoreFields.tsx @@ -0,0 +1,244 @@ +'use client' + +import { useMemo } from 'react' +import { XIcon } from 'lucide-react' + +import { StrictCombobox } from '@/components/campaign/StrictCombobox' +import { TerritorySuggestionChips } from '@/components/campaign/TerritorySuggestionChips' +import type { CampaignTerritoryFieldsState } from '@/components/campaign/useCampaignTerritoryFieldsState' +import { Badge } from '@/components/ui/Badge' +import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field' +import { InputGroup, InputGroupInput } from '@/components/ui/input-group' +import { buildTerritorySuggestions } from '@/lib/territorySuggestions' +import { fieldError } from '@/utilities/campaignFormFields' + +const ChipList = ({ + values, + onRemove, + removeLabel, +}: { + values: string[] + onRemove: (value: string) => void + removeLabel: (value: string) => string +}) => ( +
+ {values.map((value) => ( + + + + ))} +
+) + +export const CampaignTerritoryCoreFields = ({ + state, + idPrefix, + fieldErrors = {}, + citySuggestions: citySuggestionsOverride, +}: { + state: CampaignTerritoryFieldsState + idPrefix: string + fieldErrors?: Record + citySuggestions?: ReturnType['citySuggestions'] +}) => { + const errorFor = (name: string) => fieldError(fieldErrors, name) + const { + regions, + cities, + neighborhoods, + cityDraft, + regionDraft, + neighborhoodDraft, + regionError, + cityError, + neighborhoodError, + setCityDraft, + setRegionDraft, + setNeighborhoodDraft, + setRegionError, + setCityError, + setNeighborhoodError, + displayRegions, + regionsAreDerived, + neighborhoodsEnabled, + availableCityOptions, + availableRegionOptions, + addCity, + removeCity, + addRegion, + removeRegion, + commitNeighborhood, + removeNeighborhood, + } = state + + const citySuggestions = useMemo(() => { + if (citySuggestionsOverride) return citySuggestionsOverride + return buildTerritorySuggestions({ + cities, + regions: displayRegions, + tseZones: [], + }).citySuggestions + }, [citySuggestionsOverride, cities, displayRegions]) + + const visibleRegionError = regionError ?? errorFor('regions') + const visibleCityError = cityError ?? errorFor('cities') + const visibleNeighborhoodError = neighborhoodError ?? errorFor('neighborhoods') + + const regionFieldId = `${idPrefix}-lookup-a` + const cityFieldId = `${idPrefix}-lookup-b` + const neighborhoodFieldId = `${idPrefix}-neighborhoodDraft` + + return ( + <> + {cities.map((city) => ( + + ))} + {!regionsAreDerived + ? regions.map((region) => ( + + )) + : null} + {neighborhoodsEnabled + ? neighborhoods.map((neighborhood) => ( + + )) + : null} + + + Territórios de identidade + {displayRegions.length ? ( + `Remover território ${region}`} + /> + ) : null} + {regionsAreDerived ? ( + + Derivados automaticamente dos municípios selecionados. Remova municípios para editar + territórios manualmente. + + ) : ( + { + if (nextRegion) addRegion(nextRegion) + else setRegionDraft('') + }} + onInvalid={() => { + setRegionError('Selecione um território de identidade válido da Bahia.') + }} + error={visibleRegionError} + /> + )} + {visibleRegionError ? ( + {visibleRegionError} + ) : null} + + + + Municípios + addCity(suggestion.city)} + /> + {cities.length ? ( + `Remover município ${city}`} + /> + ) : null} + { + if (nextCity) addCity(nextCity) + else setCityDraft('') + }} + onInvalid={() => { + setCityError('Selecione um município válido da Bahia.') + }} + error={visibleCityError} + /> + + Selecione um ou mais municípios. Territórios são derivados automaticamente; bairros só + ficam disponíveis com exatamente um município. + + {visibleCityError ? ( + {visibleCityError} + ) : null} + + + + Bairros + {neighborhoodsEnabled && neighborhoods.length ? ( + `Remover bairro ${neighborhood}`} + /> + ) : null} + + { + setNeighborhoodDraft(event.target.value) + setNeighborhoodError(undefined) + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + commitNeighborhood(neighborhoodDraft) + } else if ( + event.key === 'Backspace' && + !neighborhoodDraft && + neighborhoods.length > 0 + ) { + removeNeighborhood(neighborhoods[neighborhoods.length - 1]!) + } + }} + onBlur={() => commitNeighborhood(neighborhoodDraft)} + /> + + + Use Enter para adicionar. Disponível somente quando há exatamente um município. + + {visibleNeighborhoodError ? ( + {visibleNeighborhoodError} + ) : null} + + + ) +} diff --git a/src/components/campaign/LeadershipCombobox.tsx b/src/components/campaign/LeadershipCombobox.tsx new file mode 100644 index 00000000..8ab3f78b --- /dev/null +++ b/src/components/campaign/LeadershipCombobox.tsx @@ -0,0 +1,40 @@ +'use client' + +import { AsyncSearchCombobox } from '@/components/campaign/AsyncSearchCombobox' +import { isContactSearchQueryReady } from '@/lib/contactSearchQuery' +import type { ActionPlanLeadershipOption } from '@/utilities/actionPlanLeadershipOptions' + +type LeadershipComboboxProps = { + name?: string + label: string + value: ActionPlanLeadershipOption | null + search: (query: string) => Promise + emptyOptionLabel?: string + onChange?: (option: ActionPlanLeadershipOption | null) => void +} + +export const LeadershipCombobox = ({ + name, + label, + value, + search, + emptyOptionLabel = 'Nenhuma', + onChange, +}: LeadershipComboboxProps) => ( + { + const results = await search(query) + return results.map((option) => ({ id: option.id, label: option.label })) + }} + onChange={(option) => + onChange?.(option ? { id: option.id, label: option.label } : null) + } + /> +) diff --git a/src/components/campaign/PrimaryContactCombobox.tsx b/src/components/campaign/PrimaryContactCombobox.tsx index 92f2bd1b..09eb394e 100644 --- a/src/components/campaign/PrimaryContactCombobox.tsx +++ b/src/components/campaign/PrimaryContactCombobox.tsx @@ -1,7 +1,5 @@ 'use client' -import { useRef } from 'react' - import { AsyncSearchCombobox } from '@/components/campaign/AsyncSearchCombobox' import type { NucleusPrimaryContactPageData, @@ -22,9 +20,6 @@ export const PrimaryContactCombobox = ({ current, search, }: PrimaryContactComboboxProps) => { - const contactsById = useRef(new Map()) - if (current) contactsById.current.set(current.id, current) - return ( { const result = await search(query) - if (result.current) contactsById.current.set(result.current.id, result.current) - for (const contact of result.options) contactsById.current.set(contact.id, contact) const limit = result.current ? 99 : 100 return result.options.slice(0, limit).map((contact) => ({ id: contact.id, diff --git a/src/components/campaign/useCampaignTerritoryFieldsState.ts b/src/components/campaign/useCampaignTerritoryFieldsState.ts new file mode 100644 index 00000000..f0a74f6f --- /dev/null +++ b/src/components/campaign/useCampaignTerritoryFieldsState.ts @@ -0,0 +1,148 @@ +'use client' + +import { useMemo, useState } from 'react' + +import { territoriesForCities } from '@/lib/bahiaTerritories' +import { + dedupeTrimmedStrings, + MAX_NUCLEUS_CITIES, + MAX_NUCLEUS_NEIGHBORHOODS, + MAX_NUCLEUS_REGIONS, +} from '@/lib/schemas/nucleus' +import { + allMunicipalityComboboxOptions, + territoryComboboxOptions, +} from '@/utilities/territoryComboboxOptions' + +export type CampaignTerritoryValues = { + regions?: string[] | null + cities?: string[] | null + neighborhoods?: string[] | null +} + +export const useCampaignTerritoryFieldsState = (values?: CampaignTerritoryValues) => { + const [regions, setRegions] = useState(() => dedupeTrimmedStrings(values?.regions ?? [])) + const [cities, setCities] = useState(() => dedupeTrimmedStrings(values?.cities ?? [])) + const [neighborhoods, setNeighborhoods] = useState(() => + dedupeTrimmedStrings(values?.neighborhoods ?? []), + ) + const [cityDraft, setCityDraft] = useState('') + const [regionDraft, setRegionDraft] = useState('') + const [neighborhoodDraft, setNeighborhoodDraft] = useState('') + const [regionError, setRegionError] = useState() + const [cityError, setCityError] = useState() + const [neighborhoodError, setNeighborhoodError] = useState() + + const derivedRegions = useMemo(() => territoriesForCities(cities), [cities]) + const displayRegions = cities.length > 0 ? derivedRegions : regions + const regionsAreDerived = cities.length > 0 + const neighborhoodsEnabled = cities.length === 1 + + const availableCityOptions = useMemo(() => { + const selected = new Set(cities) + return allMunicipalityComboboxOptions.filter((option) => !selected.has(option.value)) + }, [cities]) + const availableRegionOptions = useMemo(() => { + const selected = new Set(regions) + return territoryComboboxOptions.filter((option) => !selected.has(option.value)) + }, [regions]) + + const addCity = (nextCity: string) => { + if (!nextCity) return + if (cities.length >= MAX_NUCLEUS_CITIES) { + setCityError(`Informe no máximo ${MAX_NUCLEUS_CITIES} municípios.`) + setCityDraft('') + return + } + setCities((current) => { + const next = dedupeTrimmedStrings([...current, nextCity]) + if (next.length !== 1) setNeighborhoods([]) + return next + }) + setCityDraft('') + setCityError(undefined) + setRegionError(undefined) + } + + const removeCity = (city: string) => { + setCities((current) => { + const next = current.filter((item) => item !== city) + if (next.length !== 1) setNeighborhoods([]) + return next + }) + setCityError(undefined) + } + + const addRegion = (nextRegion: string) => { + if (!nextRegion || regionsAreDerived) return + if (regions.length >= MAX_NUCLEUS_REGIONS) { + setRegionError(`Informe no máximo ${MAX_NUCLEUS_REGIONS} territórios.`) + setRegionDraft('') + return + } + setRegions((current) => dedupeTrimmedStrings([...current, nextRegion])) + setRegionDraft('') + setRegionError(undefined) + } + + const removeRegion = (region: string) => { + if (regionsAreDerived) return + setRegions((current) => current.filter((item) => item !== region)) + setRegionError(undefined) + } + + const commitNeighborhood = (value: string) => { + if (!neighborhoodsEnabled) { + setNeighborhoodDraft('') + return + } + const trimmed = value.trim() + if (!trimmed) { + setNeighborhoodDraft('') + return + } + if (neighborhoods.length >= MAX_NUCLEUS_NEIGHBORHOODS) { + setNeighborhoodError(`Informe no máximo ${MAX_NUCLEUS_NEIGHBORHOODS} bairros.`) + setNeighborhoodDraft('') + return + } + setNeighborhoods((current) => dedupeTrimmedStrings([...current, trimmed])) + setNeighborhoodDraft('') + setNeighborhoodError(undefined) + } + + const removeNeighborhood = (neighborhood: string) => { + setNeighborhoods((current) => current.filter((item) => item !== neighborhood)) + } + + return { + regions, + cities, + neighborhoods, + cityDraft, + regionDraft, + neighborhoodDraft, + regionError, + cityError, + neighborhoodError, + setCityDraft, + setRegionDraft, + setNeighborhoodDraft, + setRegionError, + setCityError, + setNeighborhoodError, + displayRegions, + regionsAreDerived, + neighborhoodsEnabled, + availableCityOptions, + availableRegionOptions, + addCity, + removeCity, + addRegion, + removeRegion, + commitNeighborhood, + removeNeighborhood, + } +} + +export type CampaignTerritoryFieldsState = ReturnType diff --git a/src/utilities/actionPlanDetailPageData.ts b/src/utilities/actionPlanDetailPageData.ts new file mode 100644 index 00000000..60bf07fd --- /dev/null +++ b/src/utilities/actionPlanDetailPageData.ts @@ -0,0 +1,56 @@ +import type { Payload } from 'payload' + +import type { ActionPlan, CampaignUser } from '@/payload-types' +import type { ActionPlanDetailTab } from '@/utilities/actionPlanDetailTabUi' +import { + toActionPlanDetailViewModel, + type ActionPlanDetailViewModel, +} from '@/utilities/actionPlanViewModels' +import { relationshipId } from '@/utilities/relationship' +import type { AccessibleActionPlanContext } from '@/utilities/actionPlanPageData' + +const loadActionPlanUpdateAuthorNames = async ( + payload: Pick, + user: CampaignUser, + updates: NonNullable, +): Promise> => { + const authorIds = [ + ...new Set( + updates + .map((update) => relationshipId(update.author)) + .filter((id): id is number => id !== null), + ), + ] + + if (authorIds.length === 0) return new Map() + + const result = await payload.find({ + collection: 'campaignUser', + where: { id: { in: authorIds } }, + depth: 0, + pagination: false, + select: { name: true }, + user, + overrideAccess: false, + }) + + return new Map(result.docs.map((author) => [author.id, author.name])) +} + +export const getActionPlanDetailPageData = async ( + payload: Pick, + user: CampaignUser, + context: AccessibleActionPlanContext, + activeTab: ActionPlanDetailTab, +): Promise => { + if (activeTab === 'updates' && context.document.updates?.length) { + const authorNames = await loadActionPlanUpdateAuthorNames( + payload, + user, + context.document.updates, + ) + return toActionPlanDetailViewModel(context.document, activeTab, authorNames) + } + + return toActionPlanDetailViewModel(context.document, activeTab) +} diff --git a/src/utilities/actionPlanLeadershipOptions.ts b/src/utilities/actionPlanLeadershipOptions.ts index 22584258..95f1bbcb 100644 --- a/src/utilities/actionPlanLeadershipOptions.ts +++ b/src/utilities/actionPlanLeadershipOptions.ts @@ -52,7 +52,6 @@ export const searchActionPlanLeadershipOptions = async ( }, depth: 1, limit: LEADERSHIP_SEARCH_LIMIT, - sort: 'id', select: { contact: true, supportStatus: true, diff --git a/src/utilities/actionPlanViewModels.ts b/src/utilities/actionPlanViewModels.ts index 21b5ccb4..fd605988 100644 --- a/src/utilities/actionPlanViewModels.ts +++ b/src/utilities/actionPlanViewModels.ts @@ -272,7 +272,7 @@ const mapActionPlanTasks = (plan: ActionPlan): ActionPlanTaskViewModel[] => doneAt: task.doneAt ?? null, })) -export const mapActionPlanUpdates = ( +const mapActionPlanUpdates = ( plan: ActionPlan, authorNamesById: ReadonlyMap = new Map(), ): ActionPlanUpdateViewModel[] =>