From c07969f4a7e790aa483050bb6267dfbfae0fd9a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Fri, 24 Jul 2026 14:04:44 -0300 Subject: [PATCH] Remove duplicated voteGoals; seed projection sheet into expectedVotes. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Meta Bom/Regular/Mínimo duplicated the estimate scenarios — keep a single municipality series (pessimista/média/otimista), backfill then drop the old columns, and map the spreadsheet onto expectedVotes. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 5 +- AGENTS.md | 2 +- docs/plans/conta-da-cadeira.md | 14 +-- docs/plans/fila-de-alocacao.md | 2 +- docs/plans/import-planilha-projecao.md | 18 ++-- docs/plans/inteligencia-campanha.md | 4 +- docs/roadmap.md | 2 +- scripts/import-projecao.mjs | 95 ++++++++++--------- .../municipios/[slug]/editar/formActions.ts | 11 +-- .../campanha/actions/municipality.ts | 2 +- src/collections/Municipality.ts | 88 ++--------------- .../campaign/MunicipalityStrategyCard.tsx | 36 ++----- .../campaign/MunicipalityStrategyForm.tsx | 28 +----- src/lib/projectionSheetParse.ts | 43 ++++----- src/lib/schemas/municipality.ts | 9 -- ...724_133600_drop_municipality_vote_goals.ts | 42 ++++++++ src/migrations/index.ts | 6 ++ src/payload-types.ts | 14 +-- src/utilities/municipalityViewModels.ts | 3 - src/utilities/voteGoals.ts | 93 ------------------ tests/e2e/campaignMunicipalities.e2e.spec.ts | 10 +- tests/e2e/fixtures/campaignE2EFixtures.ts | 2 +- tests/helpers/campaignFixtures.ts | 2 +- tests/int/campaignFixtures.int.spec.ts | 8 +- .../campaignMunicipalityAccess.int.spec.ts | 9 +- tests/unit/projectionSheetParse.unit.spec.ts | 56 +++++------ tests/unit/voteGoals.unit.spec.ts | 54 ----------- 27 files changed, 206 insertions(+), 452 deletions(-) create mode 100644 src/migrations/20260724_133600_drop_municipality_vote_goals.ts delete mode 100644 src/utilities/voteGoals.ts delete mode 100644 tests/unit/voteGoals.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 4422d321..b70e4946 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,7 +7,7 @@ alwaysApply: false ## Status -- **E4R entregue em código (2026-07-24):** `pnpm db:seed:projecao` (`scripts/import-projecao.mjs` + `src/lib/projectionSheetParse.ts`) importa MAPA GERAL canônico → `voteGoals`/`priority` com **overwrite-always** (re-seed quando chegar planilha nova); Salvador pulado; zero PII; dry-run + runbook. Seed local: arquivo `docs/sheets/Mapa projeção de votos Solla 2026.xlsx`, **189** municípios com metas, **50** `priority=alta`, re-run delta=0. Produção: após smoke, `ALLOW_REMOTE_DB=true … pnpm db:seed:projecao -- --dry-run` → apply. Plano: [`import-planilha-projecao.md`](../../../docs/plans/import-planilha-projecao.md). +- **E4R entregue em código (2026-07-24):** `pnpm db:seed:projecao` (`scripts/import-projecao.mjs` + `src/lib/projectionSheetParse.ts`) importa MAPA GERAL canônico → `expectedVotes` (Bom→otimista, Regular→média, Mínimo→pessimista) + `priority` com **overwrite-always** (re-seed quando chegar planilha nova); Salvador pulado; zero PII; dry-run + runbook. No mesmo dia o grupo duplicado `voteGoals` foi removido do app (ver Decisões). Seed local: arquivo `docs/sheets/Mapa projeção de votos Solla 2026.xlsx`, **189** municípios com estimativas, **50** `priority=alta`, re-run delta=0. Produção: após smoke, `ALLOW_REMOTE_DB=true … pnpm db:seed:projecao -- --dry-run` → apply. Plano: [`import-planilha-projecao.md`](../../../docs/plans/import-planilha-projecao.md). - **Sync roadmap × sessão de campo (2026-07-24):** os achados da sessão real do CG (2026-07-23, `docs/CUSTOMER.md`) foram incorporados ao roadmap e aos planos de inteligência — âncora da mesa = **% da própria votação** (A11 entrega a lente; E9/E10/B13 expõem ao lado de LQ/captura); piso 150 mil resolve a meta inicial de E8; O6 vira **E16 dossiê do município** + gap **G11 emendas por município**; O7 confirma C12 não cortável; E13 rebaixado na fila de corte ("perna" é a restrição dominante); gatilho de D3 reformulado (deltas do ZAP não absorvidos pelo fluxo sede-digita/C12); FD2 absorvido por R6; O8 "ilhas isoladas" em A validar. **Decisão de produto: E4 reaberto como E4R** — seed único via script das planilhas de `docs/sheets/` (arquivo com espaços = canônico de estratégia; underscore = referência Salvador; v1 só `voteGoals`/`priority`, sem PII; Salvador pulado com relatório). Novos itens: **E4R** (janela 1, não cortável), **A11**, **E17 tabela TI no Início** (pedido do candidato; primeira fatia de E12), **B14 município mais próximo** (geolocalização → atalho no Início staff; prompt 1×/sessão; client-only). Planos novos: `import-planilha-projecao.md`, `ranking-votos-municipio.md`, `tabela-ti-inicio.md`, `dossie-municipio.md`, `municipio-mais-proximo.md`. - **DEPLOY EM PRODUÇÃO REALIZADO (2026-07-23).** A cadeia destrutiva da remodelagem (`20260721_020109_remodel_municipalities` → `20260723_200000_remodel_municipalities` + `20260723_202000_reconcile_municipality_remodel`) foi aplicada em produção pelos builds Vercel de 2026-07-23 (deploys production READY dos commits `a7b8f1b`/`c7a1b74`/`59868a9`). **Este item supersede toda menção "deploy pendente" nas entradas abaixo.** Pendências operacionais: smoke pós-deploy + onboarding do time (Onda 0 §2/§4 em [`docs/roadmap.md`](../../../docs/roadmap.md)); hold de PII real segue no lote jurídico. - **Hardening de engenharia entregue e em produção (2026-07-23, Fases 0–6):** (0) access lockdown — `users`/`signature`/`subscription`/`consent` admin-only, escrita de CMS (`post`/`tag`/`media`/`petition`) admin-only, gates de rota do leader em `demandas*`/`planos*`, `createLeaderSupporter` com escopo correto, `candidate` tratado como staff irrestrito em todas as superfícies; (1) tooling gate — knip no CI (`.github/workflows/ci.yml`, mirror GitHub), `pnpm lint --max-warnings=0`, ban `as never` (helpers `hookFilledCreateData`/`stub`), tsconfig consolidado + `noUnusedLocals`, ~12 módulos mortos deletados; (2) caching — artefato TSE commitado (`pnpm build:election-aggregates` → `src/lib/electionAggregates/`, loader `bahiaElectionAggregates.ts`), `unstable_cache` com tag `election-tse` nos loaders de eleições, `loadMunicipalityScope` (React `cache()`) dedupe dashboard+mapa+lista; (3) pending honesto — bug de revalidação do toggle de tarefa, `CampaignListPendingBoundary` esmaecendo resultados, transição no compare do mapa, mapa do dashboard + abas do detalhe em Suspense; (4) state scoping — hover do mapa isolado em `MunicipalityMapSelection`, provider de cenário estreitado, `LeaderContactForm` extraído como ilha cliente; (5) estrutura — `campaignAccess` dividido em `src/utilities/access/*` (re-export preservado), `supporterImport.ts` separado, `runStaffEntityMutation` + `runCampaignRedirectFormAction` dedupando twins e 4 ladders de formActions, rename `PLAZA_*`→`MUNICIPALITY_*` concluído; (6) docs + regra `.cursor/rules/engineering-standards.mdc`. Tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](../../../docs/IMPROVE-CODE-QUALITY-PLAN.md) · ledger: [`docs/TECH-DEBT.md`](../../../docs/TECH-DEBT.md) · mapa de testes: [`docs/TESTING.md`](../../../docs/TESTING.md). @@ -75,7 +75,8 @@ alwaysApply: false ## Decisões -- Em 2026-07-24, **E4R overwrite-always:** `pnpm db:seed:projecao` sempre sobrescreve `voteGoals` + `priority` nas linhas casadas (planilha nova sobre DB já populado). **Rejeitado:** empty-only / `--overwrite` opt-in (quebraria o re-seed). Dry-run continua obrigatório antes de produção. +- Em 2026-07-24, **uma única série por cenário — "Metas" eliminadas:** o grupo `municipality.voteGoals` ("Meta Bom/Regular/Mínimo") duplicava o conceito de votos estimados e foi removido do app (collection, zod, view models, form/card, `utilities/voteGoals.ts`); a série do município é `expectedVotes` (pessimista/média/otimista). Planilha EXPECTATIVA mapeia **Bom→otimista, Regular→média, Mínimo→pessimista**. Migration `20260724_133600_drop_municipality_vote_goals` com backfill metas→estimativas (estimativa existente vence). **Supersede** o "não fundir com `voteGoals`" da decisão A10 abaixo. **Rejeitado:** manter os dois grupos com semânticas distintas (meta de planejamento × estimativa) — a mesa trabalha com um número por cenário. +- Em 2026-07-24, **E4R overwrite-always:** `pnpm db:seed:projecao` sempre sobrescreve `expectedVotes` + `priority` nas linhas casadas (planilha nova sobre DB já populado). **Rejeitado:** empty-only / `--overwrite` opt-in (quebraria o re-seed). Dry-run continua obrigatório antes de produção. - Em 2026-07-23, **access explícito em toda collection (hardening Fase 0):** o default do Payload ("qualquer autenticado pode CRUD") é tratado como bug; toda collection declara `access` explícito — `users`/`signature`/`subscription`/`consent` só Payload admin (`payloadAdminOnly`), escrita de CMS admin-only com leitura pública preservada. JWTs de `campaignUser` não operam mais essas collections via `/api`. **Rejeitado:** confiar no default do Payload; RBAC só na camada de UI. - Em 2026-07-23, **padrões de engenharia vigentes (hardening Fases 1–6):** zero lint warnings (`--max-warnings=0`); `as never` banido (helpers tipados `hookFilledCreateData` / `stub` nos testes); knip no CI com files/dependencies em nível de erro; dead code se apaga imediatamente após qualquer mudança; convenção `lib/` (dado estático + lógica pura) vs `utilities/` (runtime Payload/Next); caching ladder (React `cache()` → `unstable_cache`+tag → artefato commitado). Regra sempre aplicada: [`.cursor/rules/engineering-standards.mdc`](../engineering-standards.mdc); débitos: [`docs/TECH-DEBT.md`](../../../docs/TECH-DEBT.md). - Em 2026-07-23, **auto-save sem botão Salvar:** campos de edição devem persistir automaticamente sempre que for seguro (change de select, blur ou debounce curto); sem botão “Salvar” em edição in-context / campo único. Submit explícito só para formulários multi-campo atômicos, nota/confirmação ou consentimento. Princípio 4 de [`PRODUCT.md`](../../../PRODUCT.md); regra Cursor [`.cursor/rules/campanha-edit-where-you-see.mdc`](../campanha-edit-where-you-see.mdc). **Rejeitado / supersede:** preferência B9 por Enter + botão Salvar em quick-edit; Save “por precaução” em Popover. diff --git a/AGENTS.md b/AGENTS.md index 4b518d19..aaa9fd9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,7 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap. **Recently resolved (2026-07-23):** **Engineering-standards hardening** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md); ledger: [`docs/TECH-DEBT.md`](docs/TECH-DEBT.md); safety net: [`docs/TESTING.md`](docs/TESTING.md); standards rule: `.cursor/rules/engineering-standards.mdc` — READ IT before coding). Phases: (0) explicit `access` on every collection — `users`/`signature`/`subscription`/`consent` admin-only, CMS writes admin-only, leader route gates on `demandas*`/`planos*`, `createLeaderSupporter` scope fixed, `candidate` treated as unrestricted staff consistently; (1) tooling gate — knip in CI (`.github/workflows/ci.yml` on the GitHub mirror), `pnpm lint --max-warnings=0`, `as never` banned (typed helpers `hookFilledCreateData` / tests' `stub`), tsconfig include consolidated + `noUnusedLocals`, ~12 dead modules deleted; (2) caching — committed TSE aggregate artifact (`pnpm build:election-aggregates` → `src/lib/electionAggregates/`, loader `bahiaElectionAggregates.ts`), `loadMunicipalityScope` React `cache()` dedup, `unstable_cache` + `election-tse` tag on elections-tab loaders; (3) "Feel the action" gaps closed — task-toggle revalidation bug, shared `CampaignListPendingBoundary` dimming results, map-compare transition, dashboard map + detail tabs stream behind Suspense; (4) state scoping — map hover isolated in `MunicipalityMapSelection`, scenario provider narrowed, `LeaderContactsPanel` server-rendered with a client form island; (5) structure — `campaignAccess` split into `src/utilities/access/*` (re-exported), supporter actions split (`supporterImport.ts`), `runStaffEntityMutation` + `runCampaignRedirectFormAction` dedupe the org/dobradinha twins and 4 formActions ladders, Plaza→Municipality identifier rename finished. -**Recently resolved (2026-07-24):** **E4R** Import único da planilha de projeção — `pnpm db:seed:projecao` (`scripts/import-projecao.mjs`) lê o xlsx canônico em `docs/sheets/`, faz match via `canonicalizeMunicipalityName` + `municipalityCatalog`, e **sempre sobrescreve** `municipality.voteGoals` + `priority` (re-seed quando a mesa manda planilha nova); Salvador pulado; zero PII; `--dry-run` + guard `ALLOW_REMOTE_DB`; parser puro em `src/lib/projectionSheetParse.ts`. Plano: [`docs/plans/import-planilha-projecao.md`](docs/plans/import-planilha-projecao.md). +**Recently resolved (2026-07-24):** **E4R** Import único da planilha de projeção — `pnpm db:seed:projecao` (`scripts/import-projecao.mjs`) lê o xlsx canônico em `docs/sheets/`, faz match via `canonicalizeMunicipalityName` + `municipalityCatalog`, e **sempre sobrescreve** `municipality.expectedVotes` + `priority` (re-seed quando a mesa manda planilha nova); Salvador pulado; zero PII; `--dry-run` + guard `ALLOW_REMOTE_DB`; parser puro em `src/lib/projectionSheetParse.ts` (Bom→`optimistic`, Regular→`central`, Mínimo→`pessimistic`). No mesmo dia, o grupo duplicado `municipality.voteGoals` ("Meta Bom/Regular/Mínimo") foi **removido** — collection, zod, view models, form/card e utilitário `voteGoals.ts` — via migration `20260724_133600_drop_municipality_vote_goals` (backfill metas→estimativas onde a estimativa estava vazia): `expectedVotes` (pessimista/média/otimista) é a única série por cenário do município. Plano: [`docs/plans/import-planilha-projecao.md`](docs/plans/import-planilha-projecao.md). **Recently resolved (2026-07-23):** **A10** Cenários de estimativa (pessimista/média/otimista) — `votePledge.estimatedVotes` e `municipality.expectedVotes` viram groups (`pessimistic|central|optimistic`); migration `20260723_124200_add_vote_estimate_scenarios` (backfill scalar→`central`); agregação por cenário em `votePledgeData` com default `central`; mapa 2026 com `values2026ByScenario` + seletor local; lista/overview/dashboard com média + faixa secundária; liderança continua com um `declaredVotes` sem ver estimativas. Plano: [`docs/plans/cenarios-estimativa-votos.md`](docs/plans/cenarios-estimativa-votos.md). Desbloqueia **E8**. diff --git a/docs/plans/conta-da-cadeira.md b/docs/plans/conta-da-cadeira.md index b2dc4741..0bf894df 100644 --- a/docs/plans/conta-da-cadeira.md +++ b/docs/plans/conta-da-cadeira.md @@ -1,7 +1,7 @@ # E8 — Conta da cadeira (metas derivadas, potencial por município, cobertura) Status: rascunho -Atualizado em: 2026-07-24 (refs sincronizadas pós-remodelagem Municípios + hardening; **A10 entregue e remodelagem em produção — dependências duras satisfeitas**; meta inicial ancorada na sessão de campo de 2026-07-23; `voteGoals` seedados por E4R) +Atualizado em: 2026-07-24 (refs sincronizadas pós-remodelagem Municípios + hardening; **A10 entregue e remodelagem em produção — dependências duras satisfeitas**; meta inicial ancorada na sessão de campo de 2026-07-23; `expectedVotes` seedados por E4R — o grupo `voteGoals` foi removido do app em 2026-07-24, a série manual por cenário é só `expectedVotes`) Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Inteligência de campanha", E8; plano-mestre [inteligencia-campanha.md](inteligencia-campanha.md); A10 ✓) Impeccable: B — cobertura/metas encaixadas no dashboard, na lista de municípios e no detalhe (sem rota nova) Appetite: ~2 dias eng; migration pequena (global `campaignGoals`) + utilities derivadas + encaixes de UI @@ -21,7 +21,7 @@ Na implementação (`implement-roadmap-item`): craft compacto → critique → p ## Contexto -O relatório de discovery ([§5.1](../research/relatorio-entrevista-persona-campanha.md)) fixou a OMTM da campanha até 16/08: **cobertura da meta por compromissos auditáveis** (Σ pledges ÷ meta, com delta semanal como número da reunião), decomposta por município. Hoje `municipality.voteGoals` (Bom/Regular/Mínimo) e `municipality.expectedVotes` existem (E1/A9), mas: não há meta estadual nem decomposição de cima para baixo; não há noção de potencial derivado (válidos projetados, captura do campo, roll-off); denominadores baseados em `aptos` inflariam municípios de abstenção estrutural (relatório §6.6/I-B); e não existe "campo" definido por ano para share intracampo. Com **E4R** ([import-planilha-projecao.md](import-planilha-projecao.md)), `voteGoals`/`priority` chegam seedados da planilha da coordenação (~240 municípios com metas em 3 cenários): a decomposição meta→município nasce **reconciliando** a sugestão derivada com as metas reais da mesa — a meta manual continua vencendo a sugerida, e divergências grandes viram aviso, não sobrescrita. Os dados TSE necessários já estão em `electionTally` (válidos/brancos/nulos/comparecimento por cargo×ano×cidade×zona) e `electionCandidateVote`; desde o hardening 2026-07-23, a série de válidos federais T1 + votos Solla por município já existe pré-agregada no artefato commitado `src/lib/bahiaElectionAggregates.ts` — preferir o artefato para válidos projetados; teto do campo, share intracampo e roll-off continuam derivando das collections (envolver em `unstable_cache` com a tag `election-tse`, padrão dos loaders de eleições). +O relatório de discovery ([§5.1](../research/relatorio-entrevista-persona-campanha.md)) fixou a OMTM da campanha até 16/08: **cobertura da meta por compromissos auditáveis** (Σ pledges ÷ meta, com delta semanal como número da reunião), decomposta por município. Hoje `municipality.expectedVotes` (pessimista/média/otimista) é a única série manual por cenário (o grupo `voteGoals` foi removido em 2026-07-24), mas: não há meta estadual nem decomposição de cima para baixo; não há noção de potencial derivado (válidos projetados, captura do campo, roll-off); denominadores baseados em `aptos` inflariam municípios de abstenção estrutural (relatório §6.6/I-B); e não existe "campo" definido por ano para share intracampo. Com **E4R** ([import-planilha-projecao.md](import-planilha-projecao.md)), `expectedVotes`/`priority` chegam seedados da planilha da coordenação (~189 municípios com estimativas em 3 cenários): a decomposição meta→município nasce **reconciliando** a sugestão derivada com as estimativas reais da mesa — o valor manual continua vencendo o sugerido, e divergências grandes viram aviso, não sobrescrita. Os dados TSE necessários já estão em `electionTally` (válidos/brancos/nulos/comparecimento por cargo×ano×cidade×zona) e `electionCandidateVote`; desde o hardening 2026-07-23, a série de válidos federais T1 + votos Solla por município já existe pré-agregada no artefato commitado `src/lib/bahiaElectionAggregates.ts` — preferir o artefato para válidos projetados; teto do campo, share intracampo e roll-off continuam derivando das collections (envolver em `unstable_cache` com a tag `election-tse`, padrão dos loaders de eleições). ## Objetivos @@ -29,13 +29,13 @@ O relatório de discovery ([§5.1](../research/relatorio-entrevista-persona-camp - Utilities derivadas por município (sem persistir o derivado): válidos projetados do cargo (série 2014/2018/2022), teto do campo (majoritário 1º turno), taxa de captura, share intracampo (via `campoParties.ts`), roll-off DF×majoritária, potencial e meta sugerida (decomposição proporcional ao potencial). - Cobertura: Σ (estimativa do cenário ativo — default **média/`central`** via A10 — `?? declaredVotes`) ÷ meta por município e agregada; delta semanal (exige C12 para série; até lá, delta vs. snapshot em memória do último load é aceitável mostrar como "—"). - Encaixes: linha de cobertura no dashboard (`campaignDashboardData.ts`), colunas meta/cobertura na lista (`MunicipalityList`/`MunicipalityListOverview`), card no detalhe do município. -- Access: tudo staff-only (`coordinator`/`advisor`); `leader` não vê metas — mesmo padrão de `voteGoals`. +- Access: tudo staff-only (`coordinator`/`advisor`); `leader` não vê metas — mesmo padrão de `expectedVotes`. - Sanity check por TI: Σ metas dos municípios do TI vs. participação histórica do TI no voto (aviso, não bloqueio). ## Decisões travadas - **Denominadores usam válidos projetados do cargo, nunca `aptos`** (relatório §6.6; abstenção estrutural >35% em municípios pequenos infla potencial). **Rejeitado:** `aptos` (viés pró-município-vazio); válidos da majoritária (cargo errado); eleitorado IBGE (não é cadastro eleitoral). -- **Meta estadual mora num global editável, decomposição é derivada e a meta por município continua editável** (`voteGoals` manual vence a sugerida quando preenchida). Mantém julgamento humano no loop e evita reescrever E1. **Rejeitado:** meta por município 100% automática (K-C: "o erro é a conta" precisa de dono humano); collection de metas versionadas (C12 cobre auditoria via decisões). +- **Meta estadual mora num global editável, decomposição é derivada e a estimativa por município continua editável** (`expectedVotes` manual vence a sugerida quando preenchida). Mantém julgamento humano no loop e evita reescrever E1. **Rejeitado:** meta por município 100% automática (K-C: "o erro é a conta" precisa de dono humano); collection de metas versionadas (C12 cobre auditoria via decisões). - **"Campo" por ano é lib estática curada** `src/lib/campoParties.ts` (ano → partidos/números). **Rejeitado:** collection administrável (curadoria política rara, não CRUD); inferir por coligação do TSE (federações ≠ campo político real). - **i18n e naming:** `campaignGoals`, `computeMunicipalityPotential`, `computeGoalCoverage`, `projectedValidVotes`, `captureRate`, `intraFieldShare`, `rollOff`; labels pt-BR. @@ -67,7 +67,7 @@ Componentes: - **`src/lib/campoParties.ts`**: mapa ano→partidos do campo (2014/2018/2022/2026), com fixture de teste. - **`src/utilities/municipalityPotential.ts`**: deriva por município (reusa `municipalityElectionGeography.ts` para células cidade×zona e `municipalityElectoralBaseline.ts` para a série); expõe `projectedValidVotes`, `fieldCeiling`, `captureRate`, `intraFieldShare`, `rollOff`, `suggestedGoal`. -- **`src/utilities/goalCoverage.ts`**: cobertura por município/agregado consumindo `aggregatePledgesByMunicipality` (regra por cenário de [A10](cenarios-estimativa-votos.md) / `votePledgeData.ts`, default `central`) + `voteGoals`/meta sugerida; sanity por TI via `bahiaTerritories.ts`. +- **`src/utilities/goalCoverage.ts`**: cobertura por município/agregado consumindo `aggregatePledgesByMunicipality` (regra por cenário de [A10](cenarios-estimativa-votos.md) / `votePledgeData.ts`, default `central`) + `expectedVotes`/meta sugerida; sanity por TI via `bahiaTerritories.ts`. - **Global `CampaignGoals`** (`src/globals/CampaignGoals.ts`): campos `stateGoal`, `margin`, `baseYear`, `note`; access staff-read/coordinator-write; hook `revalidateGlobal`. - **UI:** linha nova em `CampaignMetricStrip` do dashboard; coluna/ordenar por cobertura em `MunicipalityList` (reusa padrão B9 para editar meta via `municipalityStaffFormActions`); card "Conta da cadeira" no detalhe. - **Migration**: `pnpm migrate:create add_campaign_goals_global` (tabela do global). Sem mudança em collections. @@ -84,7 +84,7 @@ Componentes: ## Rabbit holes - **Projeção virar modelo preditivo.** Se alguém "melhorar" com regressão/ML: viola decisão de escopo do ciclo. **Mitigação:** média ponderada fixa, documentada como não-previsão. -- **Decomposição automática sobrescrever `voteGoals`.** **Mitigação:** meta sugerida é coluna separada; manual sempre vence; nunca escrever em `voteGoals` por job. +- **Decomposição automática sobrescrever `expectedVotes`.** **Mitigação:** meta sugerida é coluna separada; manual sempre vence; nunca escrever em `expectedVotes` por job. - **Roll-off por zona de Salvador sem tally do majoritário na mesma malha.** Verificar cobertura de `electionTally` para presidente/governador por zona antes de expor; se faltar, roll-off só municipal (nota na UI). ## Adiado com gatilho @@ -96,6 +96,6 @@ Componentes: - `docs/roadmap.md` (seção Inteligência de campanha) · [plano-mestre](inteligencia-campanha.md) (gaps G5/G6) - `docs/research/relatorio-entrevista-persona-campanha.md` §5.1 (OMTM), §6.6 (denominadores/roll-off), FU2 (numerador/denominador) -- `src/collections/Municipality.ts` (voteGoals/expectedVotes/access), `src/collections/ElectionTally.ts` (campos por cargo) +- `src/collections/Municipality.ts` (expectedVotes/access), `src/collections/ElectionTally.ts` (campos por cargo) - `src/utilities/municipalityElectoralBaseline.ts`, `src/utilities/municipalityElectionGeography.ts`, `src/utilities/votePledgeData.ts`, `src/utilities/campaignDashboardData.ts` - AGENTS.md — migrations, access staff-only, naming, transações diff --git a/docs/plans/fila-de-alocacao.md b/docs/plans/fila-de-alocacao.md index fc424179..8275d032 100644 --- a/docs/plans/fila-de-alocacao.md +++ b/docs/plans/fila-de-alocacao.md @@ -35,7 +35,7 @@ O relatório definiu a "fila de trabalho" com 7 colunas ranqueadas (FU3): votos - **A fila é a própria lista de municípios com ordenações/colunas novas — não uma rota nova.** Evita segundo sistema de listas e mantém filtros/URL/paginação existentes (C6 shells). **Rejeitado:** rota `/campanha/fila` dedicada (duplicaria filtros e navegação; a fila de _sugestões_ do E11 é outra superfície e outro item); tabela full-row editável (anti-goal PRODUCT.md). - **Default de ordenação para staff = `deficit` quando houver metas** (revisão 2026-07-24). A regra de adoção do discovery é "registro no fluxo de poder — a reunião de recurso começa pela lista"; fila opt-in atrás de um select esconde a inteligência. Sem metas (pré-E8/E4R), default continua nome. **Rejeitado:** fila opt-in permanente (contradiz E1/O-B do relatório); forçar o default também para ordenações de risco (essas seguem opt-in). -- **Déficit usa meta efetiva = `voteGoals.regular ?? suggestedGoal`** (mesma regra de E8), e municípios N0/N1 (pós-E14) saem do topo por meta mínima — até E14, municípios sem meta ficam no fim, não no topo. **Rejeitado:** ordenar por déficit bruto sem tratamento (município perdido com meta lixo polui o topo — FU3). +- **Déficit usa meta efetiva = `expectedVotes.central ?? suggestedGoal`** (mesma regra de E8; `voteGoals` foi removido em 2026-07-24), e municípios N0/N1 (pós-E14) saem do topo por meta mínima — até E14, municípios sem meta ficam no fim, não no topo. **Rejeitado:** ordenar por déficit bruto sem tratamento (município perdido com meta lixo polui o topo — FU3). - **i18n e naming:** `municipalityQueueSort`, `deficit`, `freshnessDays`, `unassignedPriorityCount`; labels pt-BR ("Déficit", "Frescor", "Sem responsável"). ## Questões em aberto diff --git a/docs/plans/import-planilha-projecao.md b/docs/plans/import-planilha-projecao.md index 9d6dd029..9d687b85 100644 --- a/docs/plans/import-planilha-projecao.md +++ b/docs/plans/import-planilha-projecao.md @@ -1,13 +1,15 @@ # E4R — Import único da planilha de projeção (seed de estratégia, local e produção) Status: entregue em código (2026-07-24) -Atualizado em: 2026-07-24 (overwrite-always; SheetJS; seed local 189 metas / 50 alta) +Atualizado em: 2026-07-24 (alvo mudou para `expectedVotes` — "Metas" removidas do app; overwrite-always; SheetJS; seed local 189 estimativas / 50 alta) Item do roadmap: [docs/roadmap.md](../roadmap.md) (E4R) Impeccable: n/a — script/CLI sem superfície de UI (relatório em stdout) Appetite: ~1 dia eng (parser + mapeamento + dry-run + runbook de produção) Responsável: — -Revisão 2026-07-24: idempotência mudou de “só campos vazios” para **sempre sobrescrever** `voteGoals`/`priority` nas linhas casadas — a planilha sobe de novo quando a mesa manda versão mais nova sobre DB já populado. Entrega: `pnpm db:seed:projecao`, parser em `src/lib/projectionSheetParse.ts`, script `scripts/import-projecao.mjs`. Seed local verificado: 189 municípios com metas parseáveis, 50 `priority=alta`, Salvador pulado, re-run delta=0. +Revisão 2026-07-24 (a): idempotência mudou de “só campos vazios” para **sempre sobrescrever** nas linhas casadas — a planilha sobe de novo quando a mesa manda versão mais nova sobre DB já populado. Entrega: `pnpm db:seed:projecao`, parser em `src/lib/projectionSheetParse.ts`, script `scripts/import-projecao.mjs`. Seed local verificado: 189 municípios com estimativas parseáveis, 50 `priority=alta`, Salvador pulado, re-run delta=0. + +Revisão 2026-07-24 (b) — **alvo é `expectedVotes`, não `voteGoals`:** decisão de produto — "Meta Bom/Regular/Mínimo" duplicava o conceito de votos estimados; a campanha trabalha com UMA série por cenário. O grupo `municipality.voteGoals` foi **removido do app** (migration `20260724_133600_drop_municipality_vote_goals`, com backfill metas→estimativas onde estimativa estava vazia) e o seed passou a gravar `municipality.expectedVotes` com o mapeamento **Bom → otimista, Regular → média (`central`), Mínimo → pessimista**. A ordem é validada com `getVoteEstimateOrderViolation` (pessimista ≤ média ≤ otimista) e a UI exibe tudo no card "Votos estimados". ## Contexto @@ -26,22 +28,22 @@ Divergências A×B no MAPA GERAL (9 linhas: Brejões, Catu, Iaçu, Ipiaú, Jagua ## Objetivos -- Script `scripts/import-projecao.mjs` que lê o xlsx canônico e escreve nos `municipality` do catálogo: `voteGoals.good/regular/minimum` (coluna EXPECTATIVA 2026) e `priority` (coluna PRIORIDADE) — **v1 só números/enums, zero PII**. +- Script `scripts/import-projecao.mjs` que lê o xlsx canônico e escreve nos `municipality` do catálogo: `expectedVotes.optimistic/central/pessimistic` (coluna EXPECTATIVA 2026; Bom→otimista, Regular→média, Mínimo→pessimista) e `priority` (coluna PRIORIDADE) — **v1 só números/enums, zero PII**. - Parser tolerante aos 3 formatos observados de EXPECTATIVA: `Bom: 350 | Regular: 250 | Minimo: 150` (com/sem espaços), `Bom:350`, `800/500/400`; separador de milhar `10.000`. - Matching de nome via `canonicalizeMunicipalityName` + catálogo (`municipalityCatalog.ts`); sanity check duplo: VOTOS 2014/18/22 da planilha × `bahiaElectionAggregates` (valida o match), REGIÃO × `bahiaTerritories` (diverge = warning, não bloqueia). - `--dry-run` com relatório completo: linhas casadas/não casadas, campos que seriam escritos, 9 divergências A×B, Salvador pulado, totais. -- **Sempre sobrescreve** `voteGoals` + `priority` em toda linha casada (a planilha é a fonte da estratégia naquele momento; re-run com arquivo novo ou `--file` substitui o que já estiver no banco). Não toca outros campos nem `lastUpdateAt`. +- **Sempre sobrescreve** `expectedVotes` + `priority` em toda linha casada (a planilha é a fonte da estratégia naquele momento; re-run com arquivo novo ou `--file` substitui o que já estiver no banco). Não toca outros campos nem `lastUpdateAt`. - Runbook de produção documentado no próprio script (header) e neste plano. ## Decisões travadas -- **v1 importa apenas números/enums (`voteGoals`, `priority`).** Colunas com nomes (LIDERANÇAS, ASSESSOR RESPONSÁVEL, DOBRADINHAS, ENCAMINHAMENTOS, OBSERVAÇÃO) ficam para fase 2, pós-lote jurídico — nome de ator político é dado pessoal que revela opinião política (LGPD art. 11); destino (CRM `leadership` × nota staff-only) se decide com a assessoria. **Rejeitado:** importar tudo com access staff-only (antecipa exatamente o risco que a Onda 0 segura); campo novo `networkNotes` (schema por dado que ainda não pode entrar). +- **v1 importa apenas números/enums (`expectedVotes`, `priority`).** Colunas com nomes (LIDERANÇAS, ASSESSOR RESPONSÁVEL, DOBRADINHAS, ENCAMINHAMENTOS, OBSERVAÇÃO) ficam para fase 2, pós-lote jurídico — nome de ator político é dado pessoal que revela opinião política (LGPD art. 11); destino (CRM `leadership` × nota staff-only) se decide com a assessoria. **Rejeitado:** importar tudo com access staff-only (antecipa exatamente o risco que a Onda 0 segura); campo novo `networkNotes` (schema por dado que ainda não pode entrar). - **SITUAÇÃO e VOTOS não são importados.** Tendência é derivada do TSE (`computeVoteTrend` — decisão E2 mantida); votos históricos vêm de `electionTally`/artefato. A planilha só manda no que é julgamento humano: metas e prioridade. **Rejeitado:** persistir a SITUAÇÃO da planilha (duplicaria a derivação com dado defasado). - **Salvador (linha única, metas 30k/27k/25k) é pulado com relatório explícito.** O catálogo tem 19 Municípios-zona e a planilha não distribui; inventar rateio viola "não estimar o que não tem dado" (falsa precisão). Metas de zona entram via UI pelo coordenador. **Rejeitado:** rateio proporcional ao voto por zona (número inventado com cara de número). - **Arquivo A é o canônico de estratégia; B é referência.** Mais recente nos 9 conflitos e corroborado pela entrevista (Jaguaquara). O script aceita `--file` para apontar planilha nova se chegar versão mais atual. - **`priority`: `alta` → `alta`; `Baixa`/vazio → `normal`.** Cross-check com a aba PRIORITÁRIAS (conjunto alfabético de 50, não ranking): prioritária sem `alta` no MAPA GERAL = warning no relatório. - **Escrita via Payload Local API em transação única** (`withPayloadTransaction`), `overrideAccess: true` (processo CLI, mesmo padrão dos seeds); guarda de banco da família dos seeds (`assertLocalDatabase`; produção só com `ALLOW_REMOTE_DB=true`). -- **Overwrite sempre na escrita:** cada linha casada do MAPA GERAL grava `voteGoals` + `priority` da planilha por cima do valor atual. **Rejeitado:** empty-only (quebraria o re-seed quando a mesa manda planilha atualizada). **Rejeitado:** merge por célula / “só se diferente” como gate — o dry-run já mostra o delta; a escrita é a declaração consciente após revisão. +- **Overwrite sempre na escrita:** cada linha casada do MAPA GERAL grava `expectedVotes` + `priority` da planilha por cima do valor atual. **Rejeitado:** empty-only (quebraria o re-seed quando a mesa manda planilha atualizada). **Rejeitado:** merge por célula / “só se diferente” como gate — o dry-run já mostra o delta; a escrita é a declaração consciente após revisão. - **Não atualizar `lastUpdateAt`.** Seed/re-seed não é sinal de campo; frescor (E9/G8) não pode nascer inflado. - **Parser xlsx:** SheetJS CE (`xlsx`) como devDependency (knip entry `scripts/*.mjs`). - **i18n e naming:** identificadores em inglês (`importProjectionSheet`, `parseExpectationCell`, `--dry-run`/`--file`); relatório em pt-BR. @@ -58,7 +60,7 @@ flowchart LR Parse["parseExpectationCell
(3 formatos + milhar)"] Match["canonicalizeMunicipalityName
+ municipalityCatalog"] Sanity["sanity: votos × artefato
REGIÃO × bahiaTerritories"] - Write["Payload Local API
(voteGoals, priority; overwrite)"] + Write["Payload Local API
(expectedVotes, priority; overwrite)"] Report["relatório stdout
(dry-run = só relatório)"] XLSX --> Parse --> Match --> Sanity --> Write --> Report Sanity -.warnings.-> Report @@ -72,7 +74,7 @@ flowchart LR ## Dependências -- Nenhuma dura (schema `municipality` em produção desde 2026-07-23; `voteGoals`/`priority` existem desde E1). Consome: `municipalityCatalog.ts`, `bahiaElectionAggregates.ts`, `bahiaTerritories.ts`, guard family dos seeds. +- `expectedVotes` (grupo por cenário) existe desde A10; a migration `20260724_133600_drop_municipality_vote_goals` precisa estar aplicada (remove o grupo antigo). Consome: `municipalityCatalog.ts`, `bahiaElectionAggregates.ts`, `bahiaTerritories.ts`, guard family dos seeds. - Alimenta: **E8** (metas iniciais da decomposição), **A11/E17** (quadro com prioridade real), onboarding (Onda 0 §4). ## Não escopo diff --git a/docs/plans/inteligencia-campanha.md b/docs/plans/inteligencia-campanha.md index b5e5d1be..73cbff8e 100644 --- a/docs/plans/inteligencia-campanha.md +++ b/docs/plans/inteligencia-campanha.md @@ -17,7 +17,7 @@ O discovery (literatura → persona → entrevista sintética, 2026-07-21) fixou ## Gaps de estrutura de dados (o que não estamos preparados para coletar/usar) -Auditoria relatório × schema atual (2026-07-21). O que **já existe e cobre**: `electionTally` tem `aptos/comparecimento/abstencoes/votosValidos/votosBranco/votosNulo` por cargo×ano×cidade×zona (roll-off e válidos projetados são deriváveis); `electionCandidate` tem `party`/`coalition`/`runningAgain2026` (P3/herança aberta); `municipality` tem `voteGoals` (bom/regular/mínimo), `expectedVotes`, `priority`, `politicalTrend`, `dobradinhaNotes`, `advisors`, `lastUpdateAt`; `votePledge` tem declarado×estimado com autores/datas; `municipalityUpdate` é imutável; `bahiaTerritories.ts` já mapeia município→Território de Identidade; `actionPlan` registra eventos com município/status/presença do deputado. Desde o hardening 2026-07-23 existe também o artefato commitado `src/lib/electionAggregates/bahia-federal-baseline.json` (loader `bahiaElectionAggregates.ts`): série Solla + válidos federais 2014/2018/2022 por município, já cortada na geografia do catálogo — insumo direto para válidos projetados/captura sem re-query de `electionTally`. +Auditoria relatório × schema atual (2026-07-21). O que **já existe e cobre**: `electionTally` tem `aptos/comparecimento/abstencoes/votosValidos/votosBranco/votosNulo` por cargo×ano×cidade×zona (roll-off e válidos projetados são deriváveis); `electionCandidate` tem `party`/`coalition`/`runningAgain2026` (P3/herança aberta); `municipality` tem `expectedVotes` (pessimista/média/otimista — única série por cenário desde 2026-07-24, quando `voteGoals` foi removido), `priority`, `politicalTrend`, `dobradinhaNotes`, `advisors`, `lastUpdateAt`; `votePledge` tem declarado×estimado com autores/datas; `municipalityUpdate` é imutável; `bahiaTerritories.ts` já mapeia município→Território de Identidade; `actionPlan` registra eventos com município/status/presença do deputado. Desde o hardening 2026-07-23 existe também o artefato commitado `src/lib/electionAggregates/bahia-federal-baseline.json` (loader `bahiaElectionAggregates.ts`): série Solla + válidos federais 2014/2018/2022 por município, já cortada na geografia do catálogo — insumo direto para válidos projetados/captura sem re-query de `electionTally`. | # | Gap | Hoje | Como fechar | Fatia | | --- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | @@ -25,7 +25,7 @@ Auditoria relatório × schema atual (2026-07-21). O que **já existe e cobre**: | G2 | **Sinal qualitativo de rede tipado** (invasão, esfriamento, visita de adversário, proposta a broker; fonte; triangulação) | `municipalityUpdate.kind` = semanal/urgente/nota, texto livre | Novo kind `sinal` + campos `signalType`, `signalSource`, `triangulated` (staff-only) | C12 | | G3 | **Esforço/agenda por município** (o lado privado que evapora: dias de candidato, origem da visita, recurso) | `actionPlan` tem evento+status; sem origem nem custo | `actionPlan.origin` (`dado \| pedido_broker \| obrigacao_politica` — J-B) + `resourceCost` staff-only opcional | C12 | | G4 | **Decisão registrada ex-ante** (sugestão dada, aceita/descartada, leitura alternativa, foto da classificação no momento) | Nada | Nova collection `allocationDecision` (municipality, patternId, suggestion, decision `aceita\|descartada\|adiada`, alternativeReading, snapshot, decidedBy/At) — também registra mudanças de nível N0–N4 | C12 | -| G5 | **Meta estadual e decomposição derivada** (QE projetado, faixa da cadeira, potencial por município) | `municipality.voteGoals` manual, sem conta de cima para baixo nem cobertura | Global `campaignGoals` (meta estadual, margem, ano-base) + utilities de potencial (válidos projetados, captura, headroom) e cobertura Σpledges÷meta | E8 | +| G5 | **Meta estadual e decomposição derivada** (QE projetado, faixa da cadeira, potencial por município) | `municipality.expectedVotes` manual, sem conta de cima para baixo nem cobertura | Global `campaignGoals` (meta estadual, margem, ano-base) + utilities de potencial (válidos projetados, captura, headroom) e cobertura Σpledges÷meta | E8 | | G6 | **Definição de "campo" por ano** (share intracampo, teto do campo) | `electionCandidate.party` existe; não há lista de partidos do campo por eleição | Lib estática curada `src/lib/campoParties.ts` (ano → siglas/números) — dado de configuração, não collection | E8 | | G7 | **Nível de envolvimento N0–N4** com regras de movimento e sinais de reversão | `municipality.priority` = alta/normal | `municipality.engagementLevel` (n0…n4) + `levelNote`/`levelChangedAt`; mudança de nível registrada como `allocationDecision`; `priority` mapeado e aposentado | E14 | | G8 | **Frescor como sinal de 1ª classe** | derivável (`declaredAt`, `lastUpdateAt`) mas não exposto/normatizado | Utilities de frescor + exposição na fila/lista ("coluna da vergonha": município priorizado sem responsável/sem sinal) — sem migration | E9 | diff --git a/docs/roadmap.md b/docs/roadmap.md index 76c7f407..70556be7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -45,7 +45,7 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md]( - **A10 (2026-07-23)** — cenários pessimista/média/otimista em `votePledge.estimatedVotes` e `municipality.expectedVotes`; agregação por cenário (default média); seletor no mapa/overview; liderança segue com um `declaredVotes` ([plano](plans/cenarios-estimativa-votos.md)). Desbloqueou **E8**. - **Admin Payload (2026-07-23)** — export CSV de assinaturas e contatos ([plano](plans/exportar-csv-assinaturas.md)). - **Hardening de engenharia (2026-07-23, Fases 0–6)** — access lockdown (PII/CMS admin-only + gates de rota do leader), tooling gate (knip no CI, `--max-warnings=0`, ban `as never`), artefato TSE commitado + cache (`bahiaElectionAggregates`, `election-tse`, `loadMunicipalityScope`), pending honesto + Suspense streaming, state scoping, splits (`src/utilities/access/*`, `supporterImport.ts`, shells DRY). Tracker: [IMPROVE-CODE-QUALITY-PLAN.md](IMPROVE-CODE-QUALITY-PLAN.md) · ledger: [TECH-DEBT.md](TECH-DEBT.md) · mapa de testes: [TESTING.md](TESTING.md). -- **E4R (2026-07-24)** — import único da planilha de projeção → `municipality.voteGoals` + `priority` (`pnpm db:seed:projecao`, always-overwrite, dry-run + runbook; Salvador pulado; zero PII). Plano: [import-planilha-projecao.md](plans/import-planilha-projecao.md). Seed local verificado (189 metas / 50 alta); produção após smoke. +- **E4R (2026-07-24)** — import único da planilha de projeção → `municipality.expectedVotes` (Bom→otimista, Regular→média, Mínimo→pessimista) + `priority` (`pnpm db:seed:projecao`, always-overwrite, dry-run + runbook; Salvador pulado; zero PII). No mesmo dia o grupo duplicado `voteGoals` ("Meta Bom/Regular/Mínimo") foi **removido do app** (migration `20260724_133600` com backfill metas→estimativas) — a única série por cenário é `expectedVotes`. Plano: [import-planilha-projecao.md](plans/import-planilha-projecao.md). Seed local verificado (189 estimativas / 50 alta); produção após smoke. ## Próximos — Campanha (`/campanha`) diff --git a/scripts/import-projecao.mjs b/scripts/import-projecao.mjs index fb03f322..a811be7b 100644 --- a/scripts/import-projecao.mjs +++ b/scripts/import-projecao.mjs @@ -1,11 +1,12 @@ /** - * E4R — Import único da planilha de projeção → municipality.voteGoals + priority. + * E4R — Import único da planilha de projeção → municipality.expectedVotes + priority. * * Provenance: * - Canonical strategy file: docs/sheets/Mapa projeção de votos Solla 2026.xlsx (MAPA GERAL) * - Reference (Salvador / A×B diff): docs/sheets/Mapa_projecao_votos_Solla_2026.xlsx + * - Sheet scenarios map Bom → otimista, Regular → média (central), Mínimo → pessimista. * - Writes only numbers/enums (zero PII). Never creates Contact/leadership. - * - Always overwrites voteGoals + priority on matched rows (re-seed when the mesa + * - Always overwrites expectedVotes + priority on matched rows (re-seed when the mesa * sends a newer sheet over an already-populated DB). Does not touch lastUpdateAt. * * Safety: refuses non-local DATABASE_URL unless ALLOW_REMOTE_DB=true. @@ -48,7 +49,7 @@ const PRIORITARIAS = 'PRIORITÁRIAS' const SALVADOR_CITY = 'Salvador' /** - * @typedef {{ good: number, regular: number, minimum: number }} ProjectionVoteGoals + * @typedef {{ optimistic: number, central: number, pessimistic: number }} ProjectionVoteEstimates * @typedef {{ * municipalityLabel: string * regionLabel: string | null @@ -58,7 +59,7 @@ const SALVADOR_CITY = 'Salvador' * expectationRaw: string | null * priorityRaw: string | null * }} ProjectionSheetRow - * @typedef {{ good?: number | null, regular?: number | null, minimum?: number | null } | null | undefined} DbVoteGoals + * @typedef {{ optimistic?: number | null, central?: number | null, pessimistic?: number | null } | null | undefined} DbExpectedVotes */ const parseArgs = (argv) => { @@ -213,30 +214,9 @@ const readPrioritariaNames = (workbook, normalizeKey) => { return names } -/** @param {DbVoteGoals} raw */ -const asCompleteGoals = (raw) => { - if ( - raw && - typeof raw.good === 'number' && - typeof raw.regular === 'number' && - typeof raw.minimum === 'number' - ) { - return { good: raw.good, regular: raw.regular, minimum: raw.minimum } - } - return null -} - -/** @param {ProjectionVoteGoals | null} a @param {ProjectionVoteGoals | null} b */ -const goalsEqual = (a, b) => - a != null && b != null && a.good === b.good && a.regular === b.regular && a.minimum === b.minimum - -/** @param {ProjectionVoteGoals | null} goals */ -const formatGoals = (goals) => - goals ? `Bom:${goals.good} | Regular:${goals.regular} | Minimo:${goals.minimum}` : '(vazio)' - assertLocalDatabase( 'seed:projecao', - 'This script overwrites municipality voteGoals/priority from the projection sheet.', + 'This script overwrites municipality expectedVotes/priority from the projection sheet.', ) const options = parseArgs(process.argv.slice(2)) @@ -255,9 +235,20 @@ const { territoryForCity } = await import('../src/lib/bahiaTerritories.ts') const { parseExpectationCell, mapSheetPriority, parseSheetNumber } = await import( '../src/lib/projectionSheetParse.ts' ) -const { getVoteGoalsOrderViolation } = await import('../src/utilities/voteGoals.ts') +const { + getVoteEstimateOrderViolation, + toVoteEstimateScenarioViewModel, + voteEstimatesEqual, + voteEstimateScenarioLabels, + hasAnyVoteEstimate, +} = await import('../src/utilities/voteEstimate.ts') const { withPayloadTransaction } = await import('../src/utilities/payloadTransaction.ts') +/** @param {import('../src/utilities/voteEstimate.ts').VoteEstimateScenarioViewModel} estimates */ +const formatEstimates = (estimates) => { + if (!hasAnyVoteEstimate(estimates)) return '(vazio)' + return `${voteEstimateScenarioLabels.optimistic}:${estimates.optimistic ?? '—'} | ${voteEstimateScenarioLabels.central}:${estimates.central ?? '—'} | ${voteEstimateScenarioLabels.pessimistic}:${estimates.pessimistic ?? '—'}` +} const canonicalBook = XLSX.readFile(options.file, { cellDates: false }) const canonicalRows = readMapaGeralRows(canonicalBook, parseSheetNumber, normalizeMunicipalityKey) const prioritarias = readPrioritariaNames(canonicalBook, normalizeMunicipalityKey) @@ -300,11 +291,11 @@ const municipalityDocs = await payload.find({ city: true, kind: true, priority: true, - voteGoals: true, + expectedVotes: true, }, }) -/** @type {Map} */ +/** @type {Map} */ const docsBySlug = new Map() for (const doc of municipalityDocs.docs) { if (typeof doc.slug === 'string') { @@ -312,7 +303,7 @@ for (const doc of municipalityDocs.docs) { id: doc.id, slug: doc.slug, priority: doc.priority ?? null, - voteGoals: doc.voteGoals ?? null, + expectedVotes: doc.expectedVotes ?? null, }) } } @@ -369,8 +360,8 @@ for (const sheetRow of canonicalRows.values()) { continue } - const voteGoals = parseExpectationCell(sheetRow.expectationRaw) - if (!voteGoals) { + const expectedVotes = parseExpectationCell(sheetRow.expectationRaw) + if (!expectedVotes) { if (sheetRow.expectationRaw) { parseErrors.push({ label: sheetRow.municipalityLabel, @@ -381,11 +372,11 @@ for (const sheetRow of canonicalRows.values()) { continue } - if (getVoteGoalsOrderViolation(voteGoals)) { + if (getVoteEstimateOrderViolation(expectedVotes)) { parseErrors.push({ label: sheetRow.municipalityLabel, slug: catalogEntry.slug, - raw: `${sheetRow.expectationRaw} (ordem Bom ≥ Regular ≥ Mínimo inválida)`, + raw: `${sheetRow.expectationRaw} (ordem Otimista ≥ Média ≥ Pessimista inválida)`, }) continue } @@ -430,25 +421,34 @@ for (const sheetRow of canonicalRows.values()) { if (referenceRows) { const ref = referenceRows.get(sheetKey) if (ref) { - const refGoals = parseExpectationCell(ref.expectationRaw) + const refEstimates = parseExpectationCell(ref.expectationRaw) const refPriority = mapSheetPriority(ref.priorityRaw) - if (refGoals && (!goalsEqual(voteGoals, refGoals) || priority !== refPriority)) { + if ( + refEstimates && + (!voteEstimatesEqual( + toVoteEstimateScenarioViewModel(expectedVotes), + toVoteEstimateScenarioViewModel(refEstimates), + ) || + priority !== refPriority) + ) { axbDiffs.push({ label: sheetRow.municipalityLabel, - a: { voteGoals, priority }, - b: { voteGoals: refGoals, priority: refPriority }, + a: { expectedVotes, priority }, + b: { expectedVotes: refEstimates, priority: refPriority }, }) } } } - matched.push({ doc, voteGoals, priority }) + matched.push({ doc, expectedVotes, priority }) } console.log('\n=== E4R import projeção — relatório ===') console.log(`Arquivo canônico: ${options.file}`) if (referenceRows) console.log(`Arquivo referência: ${options.reference}`) -console.log(`Modo: ${options.dryRun ? 'DRY-RUN (sem escrita)' : 'ESCRITA (overwrite voteGoals+priority)'}`) +console.log( + `Modo: ${options.dryRun ? 'DRY-RUN (sem escrita)' : 'ESCRITA (overwrite expectedVotes+priority)'}`, +) console.log(`Linhas MAPA GERAL (com município): ${canonicalRows.size}`) console.log(`Casadas com EXPECTATIVA parseável: ${matched.length}`) console.log(`Salvador pulado: ${skippedSalvador.length}`) @@ -459,7 +459,7 @@ console.log(`Divergências A×B: ${axbDiffs.length}`) console.log(`Prioritárias na aba: ${prioritarias.size}`) if (skippedSalvador.length > 0) { - console.log('\n— Salvador (pulado; metas por ZE via UI) —') + console.log('\n— Salvador (pulado; estimativas por ZE via UI) —') for (const row of skippedSalvador) { console.log( ` ${row.label}: ${row.expectationRaw ?? '(sem expectativa)'} · prioridade=${row.priorityRaw ?? '(vazia)'}`, @@ -487,7 +487,7 @@ if (axbDiffs.length > 0) { console.log('\n— Divergências A (canônico) × B (referência) — importa-se A —') for (const diff of axbDiffs) { console.log( - ` ${diff.label}: A=${formatGoals(diff.a.voteGoals)}/${diff.a.priority} · B=${formatGoals(diff.b.voteGoals)}/${diff.b.priority}`, + ` ${diff.label}: A=${formatEstimates(toVoteEstimateScenarioViewModel(diff.a.expectedVotes))}/${diff.a.priority} · B=${formatEstimates(toVoteEstimateScenarioViewModel(diff.b.expectedVotes))}/${diff.b.priority}`, ) } } @@ -501,17 +501,18 @@ if (warnings.length > 0) { } const changing = matched.filter((row) => { - const currentGoals = asCompleteGoals(row.doc.voteGoals) + const currentEstimates = toVoteEstimateScenarioViewModel(row.doc.expectedVotes) + const nextEstimates = toVoteEstimateScenarioViewModel(row.expectedVotes) const currentPriority = row.doc.priority === 'alta' ? 'alta' : 'normal' - return !goalsEqual(currentGoals, row.voteGoals) || currentPriority !== row.priority + return !voteEstimatesEqual(currentEstimates, nextEstimates) || currentPriority !== row.priority }) console.log(`\nEscritas planejadas: ${matched.length} (delta vs DB: ${changing.length})`) console.log('Amostra (até 8):') for (const row of matched.slice(0, 8)) { console.log( - ` ${row.doc.slug}: ${formatGoals(row.voteGoals)} · priority=${row.priority} (DB era ${formatGoals( - asCompleteGoals(row.doc.voteGoals), + ` ${row.doc.slug}: ${formatEstimates(toVoteEstimateScenarioViewModel(row.expectedVotes))} · priority=${row.priority} (DB era ${formatEstimates( + toVoteEstimateScenarioViewModel(row.doc.expectedVotes), )}/${row.doc.priority ?? 'normal'})`, ) } @@ -528,7 +529,7 @@ try { collection: 'municipality', id: row.doc.id, data: { - voteGoals: row.voteGoals, + expectedVotes: row.expectedVotes, priority: row.priority, }, depth: 0, diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/formActions.ts b/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/formActions.ts index 3a90a7c4..8e272157 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/formActions.ts @@ -4,7 +4,6 @@ import { updateMunicipalityStrategy } from '@/app/(campaign)/campanha/actions/mu import { nullableFormText, optionalFormText, - optionalIntegerFormValue, optionalMunicipalitySlugFromForm, repeatedFormTexts, repeatedRelationshipFormValues, @@ -28,11 +27,6 @@ export const updateMunicipalityStrategyFormAction = async ( await updateMunicipalityStrategy({ municipality, priority: priority === 'alta' ? 'alta' : 'normal', - voteGoals: { - good: optionalIntegerFormValue(formData, 'goalGood') ?? null, - regular: optionalIntegerFormValue(formData, 'goalRegular') ?? null, - minimum: optionalIntegerFormValue(formData, 'goalMinimum') ?? null, - }, strengths: repeatedFormTexts(formData, 'strengths'), risks: repeatedFormTexts(formData, 'risks'), stateDeputies: repeatedRelationshipFormValues(formData, 'stateDeputies'), @@ -44,10 +38,7 @@ export const updateMunicipalityStrategyFormAction = async ( } catch (error) { return mapCampaignFormActionError({ error, - safeMessages: [ - 'As metas devem seguir a ordem Bom ≥ Regular ≥ Mínimo quando informadas.', - ...municipalityStaffEditSafeMessages, - ], + safeMessages: municipalityStaffEditSafeMessages, genericMessage: 'Não foi possível salvar a estratégia. Verifique seu acesso e tente novamente.', }) diff --git a/src/app/(campaign)/campanha/actions/municipality.ts b/src/app/(campaign)/campanha/actions/municipality.ts index bb5c639c..36e34c69 100644 --- a/src/app/(campaign)/campanha/actions/municipality.ts +++ b/src/app/(campaign)/campanha/actions/municipality.ts @@ -38,7 +38,7 @@ const getFreshStaffActor = ( req, ) -/** Staff strategy fields: goals, priority, intel notes. Access enforced by row scope. */ +/** Staff strategy fields: priority and intel notes. Access enforced by row scope. */ export const updateMunicipalityStrategyRecord = async ( payload: Payload, actor: CampaignUser, diff --git a/src/collections/Municipality.ts b/src/collections/Municipality.ts index e2284567..faefe621 100644 --- a/src/collections/Municipality.ts +++ b/src/collections/Municipality.ts @@ -27,60 +27,15 @@ import { voteEstimateScenarioGroupAccess, voteEstimateScenarioGroupFields, } from '@/utilities/voteEstimateScenarioFields' -import { - getVoteGoalsOrderViolation, - VOTE_GOALS_ORDER_ERROR_MESSAGE, - type VoteGoalsFields, -} from '@/utilities/voteGoals' -const voteGoalNumber = (value: unknown): number | null | undefined => { +const voteEstimateNumber = (value: unknown): number | null | undefined => { if (value === null || value === undefined || value === '') return null if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { - throw new APIError('Cada meta de votos deve ser um número inteiro não negativo.', 400) + throw new APIError('Cada estimativa de votos deve ser um número inteiro não negativo.', 400) } return Math.trunc(value) } -const validateVoteGoals: CollectionBeforeValidateHook = ({ data, originalDoc, operation }) => { - if (!data) return data - - const mergedGoals = - data.voteGoals !== undefined - ? { ...(originalDoc?.voteGoals ?? {}), ...(data.voteGoals as object) } - : operation === 'update' - ? originalDoc?.voteGoals - : undefined - - if (!mergedGoals || typeof mergedGoals !== 'object') return data - - const good = voteGoalNumber((mergedGoals as VoteGoalsFields).good) - const regular = voteGoalNumber((mergedGoals as VoteGoalsFields).regular) - const minimum = voteGoalNumber((mergedGoals as VoteGoalsFields).minimum) - - if (getVoteGoalsOrderViolation({ good, regular, minimum })) { - throw new APIError(VOTE_GOALS_ORDER_ERROR_MESSAGE, 400) - } - - if (data.voteGoals && typeof data.voteGoals === 'object') { - data.voteGoals = { - good: - (data.voteGoals as { good?: unknown }).good === undefined - ? (originalDoc?.voteGoals?.good ?? null) - : good, - regular: - (data.voteGoals as { regular?: unknown }).regular === undefined - ? (originalDoc?.voteGoals?.regular ?? null) - : regular, - minimum: - (data.voteGoals as { minimum?: unknown }).minimum === undefined - ? (originalDoc?.voteGoals?.minimum ?? null) - : minimum, - } - } - - return data -} - const validateExpectedVotes: CollectionBeforeValidateHook = ({ data, originalDoc, operation }) => { if (!data) return data @@ -93,9 +48,9 @@ const validateExpectedVotes: CollectionBeforeValidateHook = ({ data, originalDoc if (!mergedExpected || typeof mergedExpected !== 'object') return data - const pessimistic = voteGoalNumber((mergedExpected as VoteEstimateScenarioFields).pessimistic) - const central = voteGoalNumber((mergedExpected as VoteEstimateScenarioFields).central) - const optimistic = voteGoalNumber((mergedExpected as VoteEstimateScenarioFields).optimistic) + const pessimistic = voteEstimateNumber((mergedExpected as VoteEstimateScenarioFields).pessimistic) + const central = voteEstimateNumber((mergedExpected as VoteEstimateScenarioFields).central) + const optimistic = voteEstimateNumber((mergedExpected as VoteEstimateScenarioFields).optimistic) if (getVoteEstimateOrderViolation({ pessimistic, central, optimistic })) { throw new APIError(VOTE_ESTIMATE_ORDER_ERROR_MESSAGE, 400) @@ -191,7 +146,7 @@ export const Municipality: CollectionConfig = { delete: canDeleteMunicipality, }, hooks: { - beforeValidate: [validateMunicipalityAdvisors, validateVoteGoals, validateExpectedVotes], + beforeValidate: [validateMunicipalityAdvisors, validateExpectedVotes], beforeChange: [derivePoliticalTrendAudit], }, fields: [ @@ -344,35 +299,6 @@ export const Municipality: CollectionConfig = { { label: 'Normal', value: 'normal' }, ], }, - { - name: 'voteGoals', - type: 'group', - label: 'Metas de votos 2026', - access: { - read: canReadCampaignStaffField, - update: canManageCampaignStaffField, - }, - fields: [ - { - name: 'good', - type: 'number', - label: 'Bom', - min: 0, - }, - { - name: 'regular', - type: 'number', - label: 'Regular', - min: 0, - }, - { - name: 'minimum', - type: 'number', - label: 'Mínimo', - min: 0, - }, - ], - }, { name: 'expectedVotes', type: 'group', @@ -380,7 +306,7 @@ export const Municipality: CollectionConfig = { access: voteEstimateScenarioGroupAccess, admin: { description: - 'Total esperado da Praça por cenário — distinto das metas de planejamento e da soma das lideranças.', + 'Total esperado do município por cenário (pessimista/média/otimista) — distinto da soma das lideranças.', }, fields: voteEstimateScenarioGroupFields(), }, diff --git a/src/components/campaign/MunicipalityStrategyCard.tsx b/src/components/campaign/MunicipalityStrategyCard.tsx index 3277495e..04774786 100644 --- a/src/components/campaign/MunicipalityStrategyCard.tsx +++ b/src/components/campaign/MunicipalityStrategyCard.tsx @@ -13,7 +13,6 @@ import { import type { MunicipalityDetailViewModel } from '@/utilities/municipalityViewModels' import type { MunicipalityPledgeCoverageView } from '@/utilities/votePledgeData' -const voteFormatter = new Intl.NumberFormat('pt-BR') const dateFormatter = new Intl.DateTimeFormat('pt-BR') export const MunicipalityStrategyCard = ({ @@ -27,7 +26,6 @@ export const MunicipalityStrategyCard = ({ canEdit: boolean pledgeCoverage: MunicipalityPledgeCoverageView | null }) => { - const goals = strategy.voteGoals const trend = strategy.politicalTrend return ( @@ -38,7 +36,7 @@ export const MunicipalityStrategyCard = ({

- Estratégia da Praça + Estratégia do município

{strategy.priority === 'alta' ? ( @@ -65,31 +63,13 @@ export const MunicipalityStrategyCard = ({ ) : null}
-
-
-
Votos estimados
-
- -
-
- {( - [ - ['Meta Bom', goals.good], - ['Meta Regular', goals.regular], - ['Meta Mínimo', goals.minimum], - ] as const - ).map(([label, value]) => ( -
-
{label}
-
- {value == null ? '—' : voteFormatter.format(value)} -
-
- ))} -
+
+

Votos estimados

+ +
{trend.status || trend.note ? (
diff --git a/src/components/campaign/MunicipalityStrategyForm.tsx b/src/components/campaign/MunicipalityStrategyForm.tsx index b5255880..5862409e 100644 --- a/src/components/campaign/MunicipalityStrategyForm.tsx +++ b/src/components/campaign/MunicipalityStrategyForm.tsx @@ -106,7 +106,7 @@ export const MunicipalityStrategyForm = ({ return (
-

Metas e inteligência

+

Estratégia e inteligência

@@ -121,28 +121,6 @@ export const MunicipalityStrategyForm = ({ Prioritária -
- {( - [ - ['goalGood', 'Meta Bom', strategy.voteGoals.good], - ['goalRegular', 'Meta Regular', strategy.voteGoals.regular], - ['goalMinimum', 'Meta Mínimo', strategy.voteGoals.minimum], - ] as const - ).map(([name, label, value]) => ( - - {label} - - - ))} -
{fieldError(strategyState.fieldErrors, 'form') ? ( {fieldError(strategyState.fieldErrors, 'form')} ) : null} @@ -202,8 +180,8 @@ export const MunicipalityStrategyForm = ({ Votos estimados

- Total esperado da Praça — distinto das metas de cenário e da soma das lideranças. Deixe - em branco para remover. + Total esperado do município por cenário — distinto da soma das lideranças. Deixe em + branco para remover.

diff --git a/src/lib/projectionSheetParse.ts b/src/lib/projectionSheetParse.ts index f7b68f9b..bc11a43c 100644 --- a/src/lib/projectionSheetParse.ts +++ b/src/lib/projectionSheetParse.ts @@ -1,13 +1,17 @@ /** * Pure parsers for the campaign projection spreadsheet (E4R). - * Sheet cells → voteGoals / priority — no Payload, no I/O. + * Sheet cells → municipality expectedVotes / priority — no Payload, no I/O. + * + * Sheet vocabulary maps onto the estimate scenarios: + * Bom → optimistic, Regular → central (média), Mínimo → pessimistic. */ -export type ProjectionVoteGoals = { - good: number - regular: number - minimum: number -} +import type { VoteEstimateScenarioFields } from '@/utilities/voteEstimate' + +/** Parsed sheet triple — all three scenarios present (same keys as expectedVotes). */ +export type ProjectionVoteEstimates = Required< + Pick +> export type ProjectionPriority = 'alta' | 'normal' @@ -38,31 +42,20 @@ const SLASH_EXPECTATION = /^([\d.]+)\s*\/\s*([\d.]+)\s*\/\s*([\d.]+)\s*$/ export const parseExpectationCell = ( raw: string | null | undefined, -): ProjectionVoteGoals | null => { +): ProjectionVoteEstimates | null => { if (raw == null) return null const trimmed = raw.trim().replace(/\s+/g, ' ') if (!trimmed) return null - const labeled = trimmed.match(LABELED_EXPECTATION) - if (labeled) { - const good = parseSheetNumber(labeled[1]) - const regular = parseSheetNumber(labeled[2]) - const minimum = parseSheetNumber(labeled[3]) - if (good == null || regular == null || minimum == null) return null - return { good, regular, minimum } - } + const match = trimmed.match(LABELED_EXPECTATION) ?? trimmed.match(SLASH_EXPECTATION) + if (!match) return null - const slash = trimmed.match(SLASH_EXPECTATION) - if (slash) { - const good = parseSheetNumber(slash[1]) - const regular = parseSheetNumber(slash[2]) - const minimum = parseSheetNumber(slash[3]) - if (good == null || regular == null || minimum == null) return null - return { good, regular, minimum } - } - - return null + const optimistic = parseSheetNumber(match[1]) + const central = parseSheetNumber(match[2]) + const pessimistic = parseSheetNumber(match[3]) + if (optimistic == null || central == null || pessimistic == null) return null + return { optimistic, central, pessimistic } } /** `alta` → alta; anything else (incl. Baixa / empty) → normal. */ diff --git a/src/lib/schemas/municipality.ts b/src/lib/schemas/municipality.ts index 0ec0a25f..760807e9 100644 --- a/src/lib/schemas/municipality.ts +++ b/src/lib/schemas/municipality.ts @@ -6,8 +6,6 @@ import { voteEstimateScenarioFieldsSchema } from '@/lib/schemas/votePledge' export const politicalTrendStatuses = ['favoravel', 'neutra', 'desfavoravel'] as const export type PoliticalTrendStatusValue = (typeof politicalTrendStatuses)[number] -const optionalGoal = z.number().int().min(0).nullable().optional() - const stateDeputiesArraySchema = z .array(positiveRelationshipId) .max(20) @@ -16,13 +14,6 @@ const stateDeputiesArraySchema = z export const municipalityStrategyUpdateSchema = z.object({ municipality: positiveRelationshipId, priority: z.enum(['alta', 'normal']).optional(), - voteGoals: z - .object({ - good: optionalGoal, - regular: optionalGoal, - minimum: optionalGoal, - }) - .optional(), strengths: z.array(z.string().trim().min(1).max(1000)).max(20).optional(), risks: z.array(z.string().trim().min(1).max(1000)).max(20).optional(), stateDeputies: stateDeputiesArraySchema.optional(), diff --git a/src/migrations/20260724_133600_drop_municipality_vote_goals.ts b/src/migrations/20260724_133600_drop_municipality_vote_goals.ts new file mode 100644 index 00000000..0cef1ef3 --- /dev/null +++ b/src/migrations/20260724_133600_drop_municipality_vote_goals.ts @@ -0,0 +1,42 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +/** + * Removes the duplicated "Metas de votos" (voteGoals) group from municipality. + * The campaign works with a single scenario series — expectedVotes + * (pessimista/média/otimista) — so any goals already entered are preserved as + * estimates (Bom → otimista, Regular → média, Mínimo → pessimista); existing + * estimates win on conflict. Idempotent: guarded on the column existing. + */ +export async function up({ db }: MigrateUpArgs): Promise { + await db.execute(sql` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'municipality' + AND column_name = 'vote_goals_good' + ) THEN + UPDATE "municipality" SET + "expected_votes_optimistic" = COALESCE("expected_votes_optimistic", "vote_goals_good"), + "expected_votes_central" = COALESCE("expected_votes_central", "vote_goals_regular"), + "expected_votes_pessimistic" = COALESCE("expected_votes_pessimistic", "vote_goals_minimum") + WHERE "vote_goals_good" IS NOT NULL + OR "vote_goals_regular" IS NOT NULL + OR "vote_goals_minimum" IS NOT NULL; + + ALTER TABLE "municipality" DROP COLUMN "vote_goals_good"; + ALTER TABLE "municipality" DROP COLUMN "vote_goals_regular"; + ALTER TABLE "municipality" DROP COLUMN "vote_goals_minimum"; + END IF; + END $$; + `) +} + +export async function down({ db }: MigrateDownArgs): Promise { + await db.execute(sql` + ALTER TABLE "municipality" ADD COLUMN IF NOT EXISTS "vote_goals_good" numeric; + ALTER TABLE "municipality" ADD COLUMN IF NOT EXISTS "vote_goals_regular" numeric; + ALTER TABLE "municipality" ADD COLUMN IF NOT EXISTS "vote_goals_minimum" numeric; + `) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 1b1164ab..1043f870 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -23,6 +23,7 @@ import * as migration_20260723_124200_add_vote_estimate_scenarios from './202607 import * as migration_20260723_200000_remodel_municipalities from './20260723_200000_remodel_municipalities' import * as migration_20260723_201000_add_state_deputy from './20260723_201000_add_state_deputy' import * as migration_20260723_202000_reconcile_municipality_remodel from './20260723_202000_reconcile_municipality_remodel' +import * as migration_20260724_133600_drop_municipality_vote_goals from './20260724_133600_drop_municipality_vote_goals' export const migrations = [ { @@ -150,4 +151,9 @@ export const migrations = [ down: migration_20260723_202000_reconcile_municipality_remodel.down, name: '20260723_202000_reconcile_municipality_remodel', }, + { + up: migration_20260724_133600_drop_municipality_vote_goals.up, + down: migration_20260724_133600_drop_municipality_vote_goals.down, + name: '20260724_133600_drop_municipality_vote_goals', + }, ] diff --git a/src/payload-types.ts b/src/payload-types.ts index 61196a85..8b0c40eb 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -409,13 +409,8 @@ export interface Municipality { zoneNumber?: number | null; advisors?: (number | CampaignUser)[] | null; priority?: ('alta' | 'normal') | null; - voteGoals?: { - good?: number | null; - regular?: number | null; - minimum?: number | null; - }; /** - * Total esperado da Praça por cenário — distinto das metas de planejamento e da soma das lideranças. + * Total esperado do município por cenário (pessimista/média/otimista) — distinto da soma das lideranças. */ expectedVotes?: { pessimistic?: number | null; @@ -1343,13 +1338,6 @@ export interface MunicipalitySelect { zoneNumber?: T; advisors?: T; priority?: T; - voteGoals?: - | T - | { - good?: T; - regular?: T; - minimum?: T; - }; expectedVotes?: | T | { diff --git a/src/utilities/municipalityViewModels.ts b/src/utilities/municipalityViewModels.ts index 887dd3d8..ebfbda0a 100644 --- a/src/utilities/municipalityViewModels.ts +++ b/src/utilities/municipalityViewModels.ts @@ -9,7 +9,6 @@ import { toVoteEstimateScenarioViewModel, type VoteEstimateScenarioViewModel, } from '@/utilities/voteEstimate' -import { toVoteGoalsViewModel, type VoteGoalsViewModel } from '@/utilities/voteGoals' import type { MunicipalityPledgeAggregate } from '@/utilities/votePledgeData' import { createEmptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeData' @@ -100,7 +99,6 @@ export type MunicipalityDetailViewModel = { strategy: { priority: 'alta' | 'normal' expectedVotes: VoteEstimateScenarioViewModel - voteGoals: VoteGoalsViewModel politicalTrend: MunicipalityPoliticalTrendViewModel strengths: string[] risks: string[] @@ -136,7 +134,6 @@ export const toMunicipalityDetailViewModel = ( : { priority: municipality.priority === 'alta' ? 'alta' : 'normal', expectedVotes: toVoteEstimateScenarioViewModel(municipality.expectedVotes), - voteGoals: toVoteGoalsViewModel(municipality.voteGoals), politicalTrend: { status: municipality.politicalTrend?.status ?? null, note: municipality.politicalTrend?.note ?? null, diff --git a/src/utilities/voteGoals.ts b/src/utilities/voteGoals.ts deleted file mode 100644 index 0d2dd695..00000000 --- a/src/utilities/voteGoals.ts +++ /dev/null @@ -1,93 +0,0 @@ -export type VoteGoalsFields = { - good?: number | null - regular?: number | null - minimum?: number | null -} - -export type VoteGoalsViewModel = { - good: number | null - regular: number | null - minimum: number | null -} - -export type VoteGoalsSumViewModel = { - good: number - regular: number - minimum: number -} - -export type VoteGoalsAggregate = VoteGoalsSumViewModel & { - highPriorityCount: number -} - -export const VOTE_GOALS_ORDER_ERROR_MESSAGE = - 'As metas devem seguir a ordem Bom ≥ Regular ≥ Mínimo quando informadas.' - -export type VoteGoalScenarioKey = keyof VoteGoalsFields - -/** Returns the scenario key that breaks Bom ≥ Regular ≥ Mínimo, if any. */ -export const getVoteGoalsOrderViolation = ( - voteGoals: VoteGoalsFields | null | undefined, -): VoteGoalScenarioKey | null => { - if (!voteGoals) return null - - const ordered = ( - [ - ['good', voteGoals.good], - ['regular', voteGoals.regular], - ['minimum', voteGoals.minimum], - ] as const - ).filter((entry): entry is [VoteGoalScenarioKey, number] => entry[1] != null) - - for (let index = 1; index < ordered.length; index += 1) { - const [, previous] = ordered[index - 1]! - const [key, current] = ordered[index]! - if (previous < current) return key - } - - return null -} - -export const toVoteGoalsViewModel = (voteGoals?: VoteGoalsFields | null): VoteGoalsViewModel => ({ - good: voteGoals?.good ?? null, - regular: voteGoals?.regular ?? null, - minimum: voteGoals?.minimum ?? null, -}) - -export const voteGoalProgressPercent = ( - confirmed: number | null, - regular: number | null, -): number | null => { - if (confirmed == null || regular == null || regular <= 0) return null - return Math.min(100, Math.round((confirmed / regular) * 100)) -} - -export const aggregateVoteGoals = ( - entries: Array<{ - voteGoals: VoteGoalsViewModel - priority?: 'alta' | 'normal' | null - }>, -): VoteGoalsAggregate => { - const totals: VoteGoalsSumViewModel = { good: 0, regular: 0, minimum: 0 } - let highPriorityCount = 0 - - for (const entry of entries) { - totals.good += entry.voteGoals.good ?? 0 - totals.regular += entry.voteGoals.regular ?? 0 - totals.minimum += entry.voteGoals.minimum ?? 0 - if (entry.priority === 'alta') highPriorityCount += 1 - } - - return { ...totals, highPriorityCount } -} - -export const sumVoteGoals = ( - entries: Array<{ voteGoals: VoteGoalsViewModel }>, -): VoteGoalsSumViewModel => { - const { good, regular, minimum } = aggregateVoteGoals(entries) - return { good, regular, minimum } -} - -export const countHighPriorityEntries = ( - entries: Array<{ priority: 'alta' | 'normal' | null }>, -): number => entries.reduce((count, { priority }) => count + (priority === 'alta' ? 1 : 0), 0) diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index 694b44c5..10125874 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -93,11 +93,11 @@ test.describe('Municípios — jornadas por papel', () => { await page.getByRole('button', { name: 'Salvar assessores' }).click() await expect(page.getByText('Assessores atualizados.')).toBeVisible() - await page.getByLabel('Meta Bom').fill('5000') - await page.getByLabel('Meta Regular').fill('3000') - await page.getByLabel('Meta Mínimo').fill('1000') - await page.getByRole('button', { name: 'Salvar estratégia' }).click() - await expect(page.getByText('Estratégia atualizada.')).toBeVisible() + await page.getByLabel('Pessimista').fill('1000') + await page.getByLabel('Média').fill('3000') + await page.getByLabel('Otimista').fill('5000') + await page.getByRole('button', { name: 'Salvar votos estimados' }).click() + await expect(page.getByText('Votos estimados atualizados.')).toBeVisible() await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}`) await expect(page.getByText('5.000')).toBeVisible() diff --git a/tests/e2e/fixtures/campaignE2EFixtures.ts b/tests/e2e/fixtures/campaignE2EFixtures.ts index ef392726..42ae988b 100644 --- a/tests/e2e/fixtures/campaignE2EFixtures.ts +++ b/tests/e2e/fixtures/campaignE2EFixtures.ts @@ -294,7 +294,7 @@ class CampaignE2EOwnership { data: { advisors: [], priority: 'normal', - voteGoals: { good: null, regular: null, minimum: null }, + expectedVotes: { pessimistic: null, central: null, optimistic: null }, politicalTrend: { status: null, note: null, recordedBy: null, recordedAt: null }, strengths: [], risks: [], diff --git a/tests/helpers/campaignFixtures.ts b/tests/helpers/campaignFixtures.ts index 314d212c..cc894d41 100644 --- a/tests/helpers/campaignFixtures.ts +++ b/tests/helpers/campaignFixtures.ts @@ -934,7 +934,7 @@ export class CampaignFixtures { data: { advisors: [], priority: 'normal', - voteGoals: { good: null, regular: null, minimum: null }, + expectedVotes: { pessimistic: null, central: null, optimistic: null }, politicalTrend: { status: null, note: null, recordedBy: null, recordedAt: null }, strengths: [], risks: [], diff --git a/tests/int/campaignFixtures.int.spec.ts b/tests/int/campaignFixtures.int.spec.ts index e67ad7c3..8a967257 100644 --- a/tests/int/campaignFixtures.int.spec.ts +++ b/tests/int/campaignFixtures.int.spec.ts @@ -123,7 +123,7 @@ describe('campaign integration fixtures', () => { id: municipality.id, data: { priority: 'alta', - voteGoals: { good: 100, regular: 50, minimum: 10 }, + expectedVotes: { pessimistic: 10, central: 50, optimistic: 100 }, strengths: [{ text: fixtures.value('Força') }], risks: [{ text: fixtures.value('Risco') }], nextSteps: fixtures.value('Encaminhamento'), @@ -166,7 +166,11 @@ describe('campaign integration fixtures', () => { const municipality = await payload.findByID({ collection: 'municipality', id: municipalityID, depth: 0 }) expect(municipality.advisors ?? []).toEqual([]) expect(municipality.priority).toBe('normal') - expect(municipality.voteGoals).toMatchObject({ good: null, regular: null, minimum: null }) + expect(municipality.expectedVotes).toMatchObject({ + pessimistic: null, + central: null, + optimistic: null, + }) expect(municipality.strengths ?? []).toEqual([]) expect(municipality.risks ?? []).toEqual([]) expect(municipality.nextSteps ?? null).toBeNull() diff --git a/tests/int/campaignMunicipalityAccess.int.spec.ts b/tests/int/campaignMunicipalityAccess.int.spec.ts index e905a0b9..8ab38a04 100644 --- a/tests/int/campaignMunicipalityAccess.int.spec.ts +++ b/tests/int/campaignMunicipalityAccess.int.spec.ts @@ -168,7 +168,6 @@ describe('municipality catalog seed and per-role access', () => { await updateMunicipalityStrategyRecord(payload, coordinator, { municipality: municipality.id, priority: 'alta', - voteGoals: { good: 5000, regular: 3000, minimum: 1000 }, strengths: ['Base sindical forte'], dobradinhaNotes: 'Negociação com deputado estadual em curso.', }) @@ -216,15 +215,17 @@ describe('municipality catalog seed and per-role access', () => { const updated = await updateMunicipalityStrategyRecord(payload, advisor, { municipality: administered.id, - voteGoals: { good: 800, regular: 500, minimum: 200 }, + priority: 'alta', + nextSteps: 'Agendar reunião com a executiva municipal.', }) - expect(updated.voteGoals?.regular).toBe(500) + expect(updated.priority).toBe('alta') + expect(updated.nextSteps).toBe('Agendar reunião com a executiva municipal.') fixtures.touchMunicipality(administered.id) await expect( updateMunicipalityStrategyRecord(payload, advisor, { municipality: outside.id, - voteGoals: { good: 10, regular: null, minimum: null }, + priority: 'alta', }), ).rejects.toThrow() }) diff --git a/tests/unit/projectionSheetParse.unit.spec.ts b/tests/unit/projectionSheetParse.unit.spec.ts index 470e1abf..a1731349 100644 --- a/tests/unit/projectionSheetParse.unit.spec.ts +++ b/tests/unit/projectionSheetParse.unit.spec.ts @@ -21,63 +21,63 @@ describe('parseSheetNumber', () => { }) describe('parseExpectationCell', () => { - it('parses labeled pipe format with and without spaces', () => { + it('maps Bom/Regular/Mínimo onto optimistic/central/pessimistic', () => { expect(parseExpectationCell('Bom:200 | Regular: 150 | Minimo: 100')).toEqual({ - good: 200, - regular: 150, - minimum: 100, + optimistic: 200, + central: 150, + pessimistic: 100, }) expect(parseExpectationCell('Bom: 300 | Regular: 200 | Minimo: 100')).toEqual({ - good: 300, - regular: 200, - minimum: 100, + optimistic: 300, + central: 200, + pessimistic: 100, }) expect(parseExpectationCell('Bom: 200| Regular: 150| Minimo: 100')).toEqual({ - good: 200, - regular: 150, - minimum: 100, + optimistic: 200, + central: 150, + pessimistic: 100, }) }) it('parses thousand separators inside labeled cells', () => { expect(parseExpectationCell('Bom: 2.000 | Regular: 1.500 | Minimo: 1.000')).toEqual({ - good: 2000, - regular: 1500, - minimum: 1000, + optimistic: 2000, + central: 1500, + pessimistic: 1000, }) expect(parseExpectationCell('Bom: 10.000 | Regular: 8.000 | Minimo: 6.000')).toEqual({ - good: 10000, - regular: 8000, - minimum: 6000, + optimistic: 10000, + central: 8000, + pessimistic: 6000, }) }) it('accepts Mínimo with accent', () => { expect(parseExpectationCell('Bom: 100 | Regular: 80 | Mínimo: 50')).toEqual({ - good: 100, - regular: 80, - minimum: 50, + optimistic: 100, + central: 80, + pessimistic: 50, }) }) it('parses slash format', () => { expect(parseExpectationCell('800/500/400')).toEqual({ - good: 800, - regular: 500, - minimum: 400, + optimistic: 800, + central: 500, + pessimistic: 400, }) expect(parseExpectationCell('300 /200 /100')).toEqual({ - good: 300, - regular: 200, - minimum: 100, + optimistic: 300, + central: 200, + pessimistic: 100, }) }) it('tolerates backslash separators from sheet export glitches', () => { expect(parseExpectationCell('Bom:300 \\Regular:150\\ mínimo: 50')).toEqual({ - good: 300, - regular: 150, - minimum: 50, + optimistic: 300, + central: 150, + pessimistic: 50, }) }) diff --git a/tests/unit/voteGoals.unit.spec.ts b/tests/unit/voteGoals.unit.spec.ts deleted file mode 100644 index d358b3d1..00000000 --- a/tests/unit/voteGoals.unit.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { municipalityStrategyUpdateSchema } from '@/lib/schemas/municipality' -import { - aggregateVoteGoals, - countHighPriorityEntries, - getVoteGoalsOrderViolation, - sumVoteGoals, - voteGoalProgressPercent, -} from '@/utilities/voteGoals' - -describe('vote goals schema and aggregates', () => { - it('accepts vote goals and priority on the municipality strategy schema', () => { - const parsed = municipalityStrategyUpdateSchema.parse({ - municipality: 1, - voteGoals: { good: 1000, regular: 800, minimum: 500 }, - priority: 'alta', - }) - - expect(parsed.voteGoals).toEqual({ good: 1000, regular: 800, minimum: 500 }) - expect(parsed.priority).toBe('alta') - }) - - it('aggregates vote goals and counts high-priority municipalities', () => { - const municipalities = [ - { - voteGoals: { good: 1000, regular: 800, minimum: 500 }, - priority: 'alta' as const, - }, - { - voteGoals: { good: 200, regular: 150, minimum: 100 }, - priority: 'normal' as const, - }, - ] - - expect(aggregateVoteGoals(municipalities)).toEqual({ - good: 1200, - regular: 950, - minimum: 600, - highPriorityCount: 1, - }) - expect(sumVoteGoals(municipalities)).toEqual({ good: 1200, regular: 950, minimum: 600 }) - expect(countHighPriorityEntries(municipalities)).toBe(1) - expect(voteGoalProgressPercent(400, 800)).toBe(50) - expect(voteGoalProgressPercent(null, 800)).toBeNull() - }) - - it('flags vote goal order violations on sparse inputs', () => { - expect(getVoteGoalsOrderViolation({ good: 100, regular: 200 })).toBe('regular') - expect(getVoteGoalsOrderViolation({ regular: 50, minimum: 100 })).toBe('minimum') - expect(getVoteGoalsOrderViolation({ good: 100, minimum: 200 })).toBe('minimum') - expect(getVoteGoalsOrderViolation({ good: 100, regular: 80 })).toBeNull() - }) -})