Remove duplicated voteGoals; seed projection sheet into expectedVotes.
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.
This commit is contained in:
parent
d14ed67801
commit
c07969f4a7
27 changed files with 206 additions and 452 deletions
|
|
@ -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<T>`), 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<T>` 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.
|
||||
|
|
|
|||
|
|
@ -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<T>`), 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**.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<br/>(3 formatos + milhar)"]
|
||||
Match["canonicalizeMunicipalityName<br/>+ municipalityCatalog"]
|
||||
Sanity["sanity: votos × artefato<br/>REGIÃO × bahiaTerritories"]
|
||||
Write["Payload Local API<br/>(voteGoals, priority; overwrite)"]
|
||||
Write["Payload Local API<br/>(expectedVotes, priority; overwrite)"]
|
||||
Report["relatório stdout<br/>(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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, { id: number, slug: string, priority: string | null, voteGoals: DbVoteGoals }>} */
|
||||
/** @type {Map<string, { id: number, slug: string, priority: string | null, expectedVotes: DbExpectedVotes }>} */
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 id="municipality-strategy-title" className="text-base font-medium">
|
||||
Estratégia da Praça
|
||||
Estratégia do município
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{strategy.priority === 'alta' ? (
|
||||
|
|
@ -65,31 +63,13 @@ export const MunicipalityStrategyCard = ({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
<dl className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg bg-muted/40 px-3 py-2 sm:col-span-2">
|
||||
<dt className="text-xs font-medium text-muted-foreground">Votos estimados</dt>
|
||||
<dd>
|
||||
<StaffMunicipalityVotesDisplay
|
||||
expectedVotes={strategy.expectedVotes}
|
||||
pledgeCoverage={pledgeCoverage}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
{(
|
||||
[
|
||||
['Meta Bom', goals.good],
|
||||
['Meta Regular', goals.regular],
|
||||
['Meta Mínimo', goals.minimum],
|
||||
] as const
|
||||
).map(([label, value]) => (
|
||||
<div key={label} className="rounded-lg bg-muted/40 px-3 py-2">
|
||||
<dt className="text-xs font-medium text-muted-foreground">{label}</dt>
|
||||
<dd className="text-lg font-medium tabular-nums">
|
||||
{value == null ? '—' : voteFormatter.format(value)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="rounded-lg bg-muted/40 px-3 py-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Votos estimados</p>
|
||||
<StaffMunicipalityVotesDisplay
|
||||
expectedVotes={strategy.expectedVotes}
|
||||
pledgeCoverage={pledgeCoverage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{trend.status || trend.note ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export const MunicipalityStrategyForm = ({
|
|||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<form action={submitStrategy} className="flex flex-col gap-4 rounded-xl border p-4">
|
||||
<h2 className="text-base font-medium">Metas e inteligência</h2>
|
||||
<h2 className="text-base font-medium">Estratégia e inteligência</h2>
|
||||
<input type="hidden" name="municipalityId" value={municipalityID} />
|
||||
<input type="hidden" name="municipalitySlug" value={municipalitySlug} />
|
||||
<Field>
|
||||
|
|
@ -121,28 +121,6 @@ export const MunicipalityStrategyForm = ({
|
|||
<NativeSelectOption value="alta">Prioritária</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{(
|
||||
[
|
||||
['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]) => (
|
||||
<Field key={name}>
|
||||
<FieldLabel htmlFor={`municipality-${name}`}>{label}</FieldLabel>
|
||||
<Input
|
||||
id={`municipality-${name}`}
|
||||
name={name}
|
||||
type="number"
|
||||
min={0}
|
||||
inputMode="numeric"
|
||||
defaultValue={value ?? undefined}
|
||||
className="min-h-11"
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
{fieldError(strategyState.fieldErrors, 'form') ? (
|
||||
<FieldError>{fieldError(strategyState.fieldErrors, 'form')}</FieldError>
|
||||
) : null}
|
||||
|
|
@ -202,8 +180,8 @@ export const MunicipalityStrategyForm = ({
|
|||
Votos estimados
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<input type="hidden" name="municipalityId" value={municipalityID} />
|
||||
|
|
|
|||
|
|
@ -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<VoteEstimateScenarioFields, 'pessimistic' | 'central' | 'optimistic'>
|
||||
>
|
||||
|
||||
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. */
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
`)
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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<T extends boolean = true> {
|
|||
zoneNumber?: T;
|
||||
advisors?: T;
|
||||
priority?: T;
|
||||
voteGoals?:
|
||||
| T
|
||||
| {
|
||||
good?: T;
|
||||
regular?: T;
|
||||
minimum?: T;
|
||||
};
|
||||
expectedVotes?:
|
||||
| T
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue