E8: Conta da cadeira (metas derivadas, potencial por município, cobertura)

- campoParties.ts + committed election-aggregates artifact v2 (campo federal
  votes, federal tally, 2022 majoritarian presidente/governador)
- global campaignGoals (state goal + margin) with staff-only read /
  unrestricted-write access
- municipalityPotential.ts (teto do campo, captura, share intracampo,
  roll-off, proportional state-goal decomposition) and goalCoverage.ts
  (meta vs comprometido semantics locked this session)
- UI: dashboard + list overview 'Cobertura da meta' metric, new list column,
  detail page 'Conta da cadeira' card
- /simplify pass: dropped dead per-request TI-warning computation, O(1)
  potential lookup, deduped near-identical election loaders/tally types/
  percent formatters
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-24 19:29:35 -03:00
parent 7ac099be42
commit 5a4a9033ed
37 changed files with 19829 additions and 80 deletions

View file

@ -8,6 +8,7 @@ alwaysApply: false
## Status
- **B19 registrado (2026-07-24):** gerenciar assessores em `/campanha/assessores` (lista + novo + detalhe) — só Coordenador Geral e Candidato; criar/editar contas `advisor`, carteira de municípios, reenviar senha; alinha access update/phone do `candidate`. Sem migration. Plano: [`gerenciar-assessores.md`](../../../docs/plans/gerenciar-assessores.md).
- **E8 entregue em código (2026-07-24):** conta da cadeira — artefato TSE commitado estendido para v2 (`campoFederalVotesByYear`, `federalTallyByYear`, `majoritarian2022` presidente/governador T1 2022, via `pnpm build:election-aggregates`; teto de bytes revisado 400→700 KB) + curadoria `src/lib/campoParties.ts` (ano→siglas do campo, validada contra `electionPartySpectrum` por unit test); global `campaignGoals` (meta estadual 150 mil + margem, migration `20260724_180000_add_campaign_goals_global`, access staff-read/coordinator-write, **sem** hook de revalidação — `/campanha` é dinâmico com auth); `municipalityPotential.ts` (válidos projetados, teto do campo, captura, share intracampo, roll-off 2022-only, decomposição proporcional da meta + sanity por TI) e `goalCoverage.ts` (`meta = expectedVotes[cenário] ?? suggestedGoal`; `comprometido = Σ pledges`, nunca a expectativa da mesa — semântica travada contra o conflito da auditoria); UI Impeccable classe B: linha "Cobertura da meta" no dashboard e no overview da lista, nova coluna na lista de municípios (renomeando "Cobertura"→"Assessoria" para eliminar ambiguidade), card "Conta da cadeira" no detalhe (fixo em `central`, sem seletor de cenário). `/simplify` pós-entrega: removida a checagem de sanidade por TI do caminho quente por requisição (fica exportada/testada para o E12 consumir), lookup de potencial por município virou O(1) (`Map` em vez de `.find`), loaders quase-idênticos de votos majoritários/deputado federal unificados, tipo de tally duplicado unificado, formatadores de percentual/progresso deduplicados entre dashboard/overview/card. Desbloqueia **E9/E10/E12/E13** no roadmap. **Follow-up mesmo dia (feedback do usuário):** tooltips hover/foco nos 4 números de diagnóstico do card (`MunicipalityHoverTooltip`, sem ícone de interrogação); seed local passou a importar presidente/governador também para 2014/2018 (artefato/fórmulas continuam 2022-only); `/impeccable critique` do card (2×P1 touch dead-end + colisão de layout, 2×P2 idiomas de disclosure/cópia densa, 1×P3 progress bar) endereçado por completo — tooltip agora abre/fecha por toque com dismiss por toque fora, nunca cobre "Cobertura da meta", cópia reescrita em frase-líder + fórmula sem cross-reference, barra de progresso mais visível a 0%; registrado **E18** (documentação de conceitos de inteligência de campanha, plano futuro). Plano: [`conta-da-cadeira.md`](../../../docs/plans/conta-da-cadeira.md).
- **C12 Registro-fundação entregue em código (2026-07-24):** trajetória de `votePledge` por versions nativas com baseline; atualização municipal `sinal` tipada (explicações, fonte e triangulação); `actionPlan.origin`; demandas vinculadas ao plano com custo derivado e criação múltipla atômica no formulário; `allocationDecision` ex-ante imutável com snapshot pequeno. Migration consolidada `20260724_180000_add_campaign_foundation_records`; testes de integração e jornada E2E dedicados.
- **B18 registrado (2026-07-24):** filtros **salvos com nome** em `/campanha/municipios` (`localStorage`) + acesso rápido de 2º nível sob Municípios no sidebar (hover desktop / expand mobile; sticky só no filtro salvo ativo). Soft B16/B17 (barra). Fatia concreta do “saved views” que FD2 adiava como genérico. Sem migration. Plano: [`filtros-salvos-municipios.md`](../../../docs/plans/filtros-salvos-municipios.md).
- **Avaliação: reorder de colunas por DnD (2026-07-24):** pedido para arrastar headers em `/campanha/municipios` → **fora de escopo** (não confundir com **B17** hide/show). Tecnicamente possível (~1–1,5d), mas conflita com sort B15 + filtros B16 no mesmo header, anti spreadsheet do PRODUCT, e não desbloqueia decisão de alocação. Gatilho em [`reordenar-colunas-lista-municipios.md`](../../../docs/plans/reordenar-colunas-lista-municipios.md).
@ -87,6 +88,7 @@ alwaysApply: false
- Em 2026-07-24, **header da coluna A11 = `2022` (não "Concentração 2022"):** o título longo competia com células curtas (`%` + `12º`); no contexto da mesa o ano basta. Definição da métrica fica no hover do header (`formatMunicipalityConcentrationHint`). **Rejeitado:** manter "Concentração" no rótulo visível.
- Em 2026-07-24, **C12 preserva fatos e decisões antes de sugerir:** pledges mantêm histórico nativo; sinais exigem tipo+fonte e distinguem triangulação; origem/custo são observados via plano→demandas; decisão de alocação é append-only para campanha (correção só por admin), sempre com racional e snapshot ex-ante pequeno. Demandas criadas junto do plano compartilham a mesma transação e fazem rollback integral em conflito. **Rejeitado:** sobrescrever trajetória, custo duplicado no plano, sugestão que decide automaticamente.
- Em 2026-07-24, **conta da cadeira (E8) — meta × comprometido, teto do campo e derivados vão para o artefato commitado, não para queries em runtime:** `meta = expectedVotes[cenário] ?? suggestedGoal` (a expectativa da mesa nunca é substituída pela soma dos pledges no denominador de cobertura — resolve o conflito de `resolveMunicipalityStaffVoteTotal`, que faz a expectativa vencer); `comprometido = aggregate.effectiveByScenario[cenário]` (só Σ pledges). Teto do campo = presidencial do campo T1 2022 (#13) como série primária, governador como leitura secundária (assumido, para validar com produto). Decomposição da meta estadual é proporcional ao teto do campo projetado (`suggestedGoal_i = metaEfetiva × tetoProjetado_i / Σ tetoProjetado`), nunca uma fórmula composta com captura/share/roll-off como pesos — essas ficam como colunas de diagnóstico. Roll-off (brancos+nulos DF − majoritária) é 2022-only (não há tally majoritário seedado para 2014/2018). `teto do campo`/`captura`/`share intracampo`/`roll-off` são derivados de um artefato TSE **commitado** (v2 de `bahia-federal-baseline.json`, via `pnpm build:election-aggregates`), não de `unstable_cache` sobre `electionCandidateVote` (~100k/73k/52k linhas por ano) — o hardening de 2026-07-23 já havia banido esse padrão do runtime. Meta estadual mora num global editável (`campaignGoals`, 150 mil inicial); `campoParties.ts` é lib estática curada por ano, não collection administrável nem segunda fonte de verdade de partido (valida contra `electionPartySpectrum.ts` por unit test). **Rejeitado:** meta por município 100% automática; fórmula composta multifatorial de potencial; inferir "campo" pela coligação bruta do TSE; collection de metas versionadas nesta fatia (fica para C12).
- 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.

View file

@ -65,6 +65,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
**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; `--dry-run` + guard `ALLOW_REMOTE_DB`; parser puro em `src/lib/projectionSheetParse.ts` (Bom→`optimistic`, Regular→`central`, Mínimo→`pessimistic`). **Fase 2 (mesmo dia, decisão do produto — não é mais zero PII):** o seed também importa as colunas de estratégia, com PRIORITÁRIAS sobrepondo MAPA GERAL quando não-vazia — SITUAÇÃO→`politicalTrend` (QUEDA/MANTÉM/AUMENTO→desfavorável/neutra/favorável, com nota de proveniência), DOBRADINHAS→`stateDeputy`+`stateDeputies` (união; célula crua em `dobradinhaNotes`), LIDERANÇAS→`contact` name-only **sem telefone** + `leadership` (dedup estadual por slug do nome; `Contact.phone` virou opcional via migration `20260724_175500_contact_phone_optional`, UI continua exigindo), ASSESSOR RESPONSÁVEL→`campaignUser`+`advisors` (união; Edizio=coordinator, Solla=candidate — `candidate` agora é elegível como assessor em `eligibleCampaignStaffWhere`; novos usuários com email `<slug>@planilha.invalid` + senha aleatória, sem login até um admin trocar), ENCAMINHAMENTOS→`nextSteps`, OBSERVAÇÃO→`strengths`/`risks` (classificação curada por slug). Relações são união (nunca removem vínculos da UI); re-run idempotente; nomes de atores políticos entraram sem trilha de consentimento — regularizar com a assessoria jurídica. 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-24):** **E8** Conta da cadeira (metas derivadas, potencial por município, cobertura) — committed TSE artifact extended to v2 (`pnpm build:election-aggregates` now writes `campoFederalVotesByYear`, `federalTallyByYear`, and 2022 `majoritarian2022` presidente/governador T1 per municipality, alongside the existing `votesByYear`/`validVotesByYear`; byte budget raised 400→700 KB in the unit test); `src/lib/campoParties.ts` (curated field-party siglas per election year, unit-tested against `electionPartySpectrum.ts`'s `esquerda` bucket — not a second party classifier); global `campaignGoals` (`src/globals/CampaignGoals.ts`, migration `20260724_180000_add_campaign_goals_global`, `stateGoal` default 150,000 + `margin`/`baseYear`/`note`, `read` = admin/staff, `update` = admin/`isCampaignUnrestricted`, **no** `afterChange` hook — `/campanha` is dynamic with auth so nothing caches it); pure derived utilities `src/utilities/municipalityPotential.ts` (projected valid votes, field ceiling, capture rate, intra-field share, 2022-only roll-off, proportional goal decomposition + TI sanity check) and `src/utilities/goalCoverage.ts` — **locked semantics:** `meta = expectedVotes[scenario] ?? suggestedGoal`, `comprometido = aggregate.effectiveByScenario[scenario]` (pledges only — the mesa's own expectation never substitutes for the pledge sum in the coverage denominator, unlike `resolveMunicipalityStaffVoteTotal`); orchestration in `src/utilities/municipalityGoalAccount.ts` (`cache()`-deduplicated across dashboard/list/detail). UI (Impeccable class B): "Cobertura da meta" row on the dashboard and the list overview `CampaignMetricStrip`, a new list column (renaming the old "Cobertura" column to "Assessoria" to remove ambiguity) via a shared `MunicipalityListGoalCoverageCell` (compact/default layouts), and a new "Conta da cadeira" card on the municipality detail page (fixed to the `central` scenario, replacing the "Votos estimados" block that used to live in `MunicipalityStrategyCard`). Unblocks roadmap items **E9/E10/E12/E13**. Plano: [`docs/plans/conta-da-cadeira.md`](docs/plans/conta-da-cadeira.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**.
## Posts & Tags (news / publications)
@ -124,7 +126,7 @@ The internal `/campanha` area is gated by its own authentication, deliberately k
`/campanha` is organized around `municipality` ("Município") — the campaign's PREDEFINED operational territory. Deployed to production on 2026-07-23 (destructive remodel migrations applied via the Vercel build). Master plan: `docs/plans/remodelagem-municipios.md` (supersedes `docs/plans/remodelagem-pracas.md`).
- **Município definition:** 435 seeded rows — one per Bahia municipality (416), except Salvador (19 zone municipalities, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated). Canonical catalog: `src/lib/municipalityCatalog.ts` (`tests/fixtures/municipality-catalog.snapshot.json`). Seeded by migration `20260723_200000_remodel_municipalities` (+ reconcile `20260723_202000_reconcile_municipality_remodel`). Geography is admin-only; staff edit strategy fields and the coordinator assigns `advisors`.
- **Collections (admin group `Campanha`):** `municipality`, `leadership`, `votePledge`, `organization`, `stateDeputy` (dobradinhas), `campaignDemand`, `municipalityUpdate`, `campaignInvite`, `supporter`, `actionPlan`.
- **Collections (admin group `Campanha`):** `municipality`, `leadership`, `votePledge`, `organization`, `stateDeputy` (dobradinhas), `campaignDemand`, `municipalityUpdate`, `campaignInvite`, `supporter`, `actionPlan`. **Global** (same admin group): `campaignGoals` (E8 "conta da cadeira" — state-level vote goal + margin; `read` = admin/staff, `update` = admin/`isCampaignUnrestricted`; no `afterChange` hook — nothing caches it).
- **Roles:** `coordinator` ("Coordenador Geral") and `candidate` ("Candidato", unrestricted visibility via `isCampaignUnrestricted`) see/manage everything staff sees; `advisor` ("Assessor") is scoped to `municipality.advisors`; `leader` ("Liderança") is **lockdown** — home is the supporter contact tool only (`LeaderContactsPanel`, `source: lideranca`), reads only supporters they created (`createdBy`), no municipalities/leaderships/pledges/demands/plans/election data.
- **Vote pledge asymmetry:** staff declare `declaredVotes` and record `estimatedVotes` (pessimistic/central/optimistic); leaders never see estimates. Aggregates use `estimated[S] ?? declared`, default `central` (`src/utilities/votePledgeData.ts`).
- **Demandas:** staff-only create/read (`canCreateCampaignDemand`); optional `leadership` provenance selected by staff. Escalated decisions: coordinator **or** candidate.

View file

@ -1,12 +1,37 @@
# 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; `expectedVotes` seedados por E4R — o grupo `voteGoals` foi removido do app em 2026-07-24, a série manual por cenário é só `expectedVotes`)
Status: entregue em código (2026-07-24; ver "Entregue (as-built)" abaixo)
Atualizado em: 2026-07-24 (auditoria de implementação — ver "Achados da auditoria" abaixo; escopo completo confirmado com o usuário; appetite revisado ~2,5–3d; entrega registrada)
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
Appetite: ~2,5–3 dias eng (revisado — degrau 3 da caching ladder para teto do campo/share intracampo/roll-off, ver auditoria); migration pequena (global `campaignGoals`) + artefato TSE estendido + utilities derivadas + encaixes de UI
Responsável: —
## Achados da auditoria de implementação (2026-07-24)
Confirmado contra o repositório antes do craft: dependências duras satisfeitas (A10 vivo, remodelagem em produção, `voteGoals` de fato removido); o artefato commitado [bahia-federal-baseline.json](../../src/lib/electionAggregates/bahia-federal-baseline.json) já tem `votesByYear`/`validVotesByYear` por município para 2014/2018/2022; o banco local seedado para esta sessão confirma TSE completo nos três anos (DF T1 2014/2018/2022 + ticket 2022 completo, incluindo presidente/governador por zona) — o rabbit hole "roll-off por zona sem tally do majoritário" fica **fechado por evidência para 2022** (não para 2014/2018, que só têm deputado federal).
Ajustes ao plano abaixo, travados nesta sessão:
1. **Escala.** Teto do campo / share intracampo / roll-off NÃO usam `unstable_cache` sobre as collections como a seção "Abordagem proposta" sugeria — `electionCandidateVote` tem ~100k/73k/52k linhas por ano (2022/2018/2014); varrer isso por município na lista/dashboard é exatamente o padrão que o hardening de 2026-07-23 baniu do runtime. Vai para o **degrau 3 da caching ladder**: estender o artefato commitado via `pnpm build:election-aggregates` (`campoFederalVotesByYear`, `federalTallyByYear`, `majoritarian2022`), não um loader novo com tag.
2. **Meta × comprometido (semântica).** A seção "Objetivos" abaixo (linha da cobertura) e o rabbit hole sobre `expectedVotes ?? Σ pledges` estavam ambíguos: hoje `resolveMunicipalityStaffVoteTotal` faz a expectativa **substituir** a soma dos pledges — usar isso como denominador daria 100% de cobertura nos ~189 municípios com planilha (E4R). **Travado:** `meta = expectedVotes[cenário] ?? suggestedGoal`; `comprometido = aggregate.effectiveByScenario[cenário]` (só pledges, nunca a expectativa da mesa). Cobertura = "quanto da expectativa está lastreado em compromisso auditável" — mesma regra do plano do [E9](fila-de-alocacao.md).
3. **Global sem hook de revalidação.** `revalidateGlobal` citado nos "Componentes" abaixo é cerimônia morta: `/campanha` é dinâmico com auth (nada cacheia o global) e a tag não entraria no allowlist de `revalidateRequest.ts`. O global `campaignGoals` não terá `afterChange` hook (comentário no código justificando).
4. **`campoParties.ts` não é uma segunda fonte de verdade de partido.** Já existe [electionPartySpectrum.ts](../../src/lib/electionPartySpectrum.ts) (buckets esquerda/centro/direita). `campoParties.ts` é a curadoria de **campo por ano** (2014/2018/2022/2026); um unit test prova que toda sigla do campo está no bucket `esquerda`, com exclusões documentadas.
5. **Access de global é terreno novo.** Os 4 globals existentes (`SiteSettings`, `HomePage`, `Metadata`, `PrivacyPolicy`) usam `read: () => true` / `update: Boolean(user)`; nenhum está no grupo `Campanha` nem nega leitura. `campaignGoals` precisa de access explícito (JWT de `campaignUser` alcança `/api/globals/*`).
Decisões de produto travadas na mesma sessão (detalhes na seção "Decisões travadas" e "Questões em aberto" abaixo, já atualizadas): meta estadual inicial 150.000 (editável); válidos projetados = média ponderada `(v2014 + v2018 + 2×v2022) / 4`; decomposição proporcional ao teto do campo projetado (não fórmula composta); teto do campo = presidencial do campo T1 2022 (#13) como primário, governador como leitura secundária (assumido, validar com produto); roll-off = (brancos+nulos DF) − (brancos+nulos majoritária), 2022-only.
## Entregue (as-built, 2026-07-24)
Todas as 6 fases implementadas conforme o plano de execução, com os ajustes da auditoria já incorporados:
- **Artefato v2** (`pnpm build:election-aggregates`): `campoFederalVotesByYear`, `federalTallyByYear` e `majoritarian2022` (presidente/governador T1 2022) adicionados por município a `bahia-federal-baseline.json`, ao lado de `votesByYear`/`validVotesByYear`. Curadoria `src/lib/campoParties.ts` (2014/2018/2022/2026), validada por unit test contra o bucket `esquerda` de `electionPartySpectrum.ts`. Teto de bytes do artefato revisado de 400 KB para 700 KB (`tests/unit/bahiaElectionAggregates.unit.spec.ts`).
- **Global `campaignGoals`** (`src/globals/CampaignGoals.ts`, migration `20260724_180000_add_campaign_goals_global`): `stateGoal` (default 150.000), `margin`, `baseYear`, `note`; `read` = admin/staff, `update` = admin/`isCampaignUnrestricted`; **sem** hook `afterChange` (achado #4 da auditoria — cerimônia morta, `/campanha` é dinâmico com auth). Loader `src/utilities/campaignGoals.ts` com React `cache()`.
- **Derivados puros**: `src/utilities/municipalityPotential.ts` (`projectedValidVotes`, `fieldCeiling`, `projectedFieldCeiling`, `captureRate`, `intraFieldShare`, `rollOff`, `decomposeStateGoal`, `sanityCheckSuggestedGoalsByTerritory`) e `src/utilities/goalCoverage.ts` (`meta = expectedVotes[cenário] ?? suggestedGoal`; `comprometido = aggregate.effectiveByScenario[cenário]`, nunca a expectativa da mesa — achado #2). Orquestração server-scoped em `src/utilities/municipalityGoalAccount.ts` (`cache()`-deduplicado entre dashboard/lista/detalhe).
- **UI** (Impeccable classe B, shape → craft → critique → harden): linha "Cobertura da meta" na `CampaignMetricStrip` do dashboard (`CampaignDashboard.tsx`) e do overview da lista (`MunicipalityListOverview.tsx`); nova coluna "Cobertura da meta" em `MunicipalityList.tsx` (a coluna antiga "Cobertura" foi renomeada para "Assessoria" para eliminar a ambiguidade), com célula compartilhada `MunicipalityListGoalCoverageCell.tsx` (variante `compact` para a tabela desktop, `default` para os cards mobile); card novo "Conta da cadeira" (`MunicipalityGoalAccountCard.tsx`) na aba Visão geral do detalhe do município, fixo no cenário `central` (sem seletor de cenário nessa página) — substitui o bloco "Votos estimados" que antes vivia em `MunicipalityStrategyCard.tsx`.
- **Verificação:** `tsc --noEmit`, `pnpm lint`, `pnpm exec knip`, unit + int (incl. `tests/int/campaignGoalsAccess.int.spec.ts` cobrindo a matriz de acesso por papel) e `pnpm build` verdes contra o banco local; `pnpm test:e2e` verde quando executado isolado (flakiness de timeout/`ECONNRESET` observada só sob contenção de recursos do sandbox com múltiplos dev servers em paralelo — não é regressão de código; 1 assertion ajustada para um seletor Playwright não-ambíguo pós-mudança de UI).
- **Divergências do plano original:** nenhuma decisão de produto mudou; os 6 achados da auditoria (seção acima) e as decisões travadas na sessão foram seguidos à risca. `territoryWarnings` (sanity check por TI) ficam computados e disponíveis em `municipalityPotential.ts`/`computeStatewideGoalDecomposition`, mas **não** ganharam superfície de UI nesta entrega — o brief de shape aprovado (Fase 4a) cobriu só as 4 integrações (dashboard, overview, lista, detalhe); exibir os avisos por TI é candidato natural para E12 (camada TI) ou um fill-in futuro.
## Design (Impeccable)
Âncoras: `PRODUCT.md` (princípio 5 — métricas relativas e locais; anti-goal % estadual absoluto) / `DESIGN.md` (register `product`) · tema `data-theme='campaign'`.
@ -21,13 +46,13 @@ 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.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).
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`. **Teto do campo, share intracampo e roll-off também vão para esse artefato** (não para um loader novo com `unstable_cache`): `electionCandidateVote` tem ~100k/73k/52k linhas por ano (2022/2018/2014) e a lista/dashboard varreriam isso por município a cada carregamento — exatamente o padrão de runtime que o hardening de 2026-07-23 baniu. `pnpm build:election-aggregates` passa a escrever `campoFederalVotesByYear`, `federalTallyByYear` e `majoritarian2022` por município, ao lado de `votesByYear`/`validVotesByYear` já existentes.
## Objetivos
- Global `campaignGoals` (admin group `Campanha`, staff-only): meta estadual de votos, margem, ano-base, nota.
- 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 "—").
- Cobertura por município e agregada, para os três cenários: `meta = expectedVotes[cenário] ?? suggestedGoal` (derivado); `comprometido = aggregate.effectiveByScenario[cenário]` (só Σ pledges — declarado/estimado por leadership, nunca a expectativa da mesa); `coverageRatio = comprometido ÷ meta`. Cenário ativo escolhido no cliente (`MunicipalityEstimateScenarioProvider`, default `central`). 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 `expectedVotes`.
- Sanity check por TI: Σ metas dos municípios do TI vs. participação histórica do TI no voto (aviso, não bloqueio).
@ -42,22 +67,29 @@ O relatório de discovery ([§5.1](../research/relatorio-entrevista-persona-camp
## Questões em aberto
- **Meta estadual inicial?** **Resolvido em campo (2026-07-23 — [CUSTOMER.md](../CUSTOMER.md)):** piso projetado do coordenador = **150 mil** (2022: 129 mil) — topo da faixa da cadeira. Valor inicial do global `campaignGoals` = 150 mil, editável (margem continua dele); o QE cheio segue rejeitado como default.
- **Projeção de válidos: média simples da série ou tendência?** Opções: média 3 eleições | último ano | regressão simples. **Recomendação:** média ponderada (2022 peso 2) — barata, estável, sem cheiro de "previsão estatística" (fora de escopo).
- **Projeção de válidos: média simples da série ou tendência?** **Resolvido (auditoria 2026-07-24):** média ponderada com peso 2 para 2022 — `(v2014 + v2018 + 2×v2022) / 4`. Barata, estável, documentada como não-previsão (fora de escopo regressão/tendência).
- **Teto do campo: presidencial ou governador?** **Assumido (validar com produto):** presidencial do campo T1 2022 (#13) como série primária; governador (também #13, mesmo campo) como leitura secundária de diagnóstico — presidencial tem participação mais homogênea entre municípios pequenos e grandes.
- **Forma da decomposição meta→município?** **Resolvido:** proporcional ao teto do campo projetado — `suggestedGoal_i = metaEfetiva × tetoProjetado_i / Σ tetoProjetado` — não uma fórmula composta com captura/share/roll-off como pesos (isso viraria um modelo preditivo multifatorial, rejeitado). Captura, share intracampo e roll-off ficam como **colunas de diagnóstico**, não como entradas da conta.
- **Roll-off: quais anos?** **Resolvido:** só 2022 (única eleição seedada com majoritária T1 completa por zona); nota na UI explicando a limitação em vez de calcular para 2014/2018.
## Abordagem proposta
```mermaid
flowchart LR
Tally["electionTally + electionCandidateVote<br/>(série 2014/2018/2022)"]
Campo["campoParties.ts"]
Seed["electionTally + electionCandidateVote<br/>(local, já seedado)"]
Campo["campoParties.ts<br/>(curadoria ano → siglas)"]
Build["pnpm build:election-aggregates"]
Artifact["bahia-federal-baseline.json v2<br/>(Solla, válidos, campo DF,<br/>teto majoritário 2022, roll-off 2022)"]
Goals["global campaignGoals"]
Pot["computeMunicipalityPotential<br/>(válidos proj., teto, captura, roll-off)"]
Cov["computeGoalCoverage<br/>(Σ pledges ÷ meta)"]
Cov["computeGoalCoverage<br/>(meta vs. comprometido)"]
Pledges["aggregatePledgesByMunicipality<br/>(votePledgeData.ts)"]
UI["dashboard + MunicipalityList/Overview + detalhe"]
Tally --> Pot
Campo --> Pot
Goals --> Cov
Seed --> Build
Campo --> Build
Build --> Artifact
Artifact --> Pot
Goals --> Pot
Pot --> Cov
Pledges --> Cov
Cov --> UI
@ -65,17 +97,18 @@ flowchart LR
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`) + `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`.
- **`src/lib/campoParties.ts`**: mapa ano→partidos do campo (2014/2018/2022/2026), validado contra `electionPartySpectrum.ts` (unit test), com exclusões documentadas.
- **`scripts/build-election-aggregates.mjs` + `src/lib/bahiaElectionAggregates.ts`**: artefato estendido (v2) — por município, além de `votesByYear`/`validVotesByYear`, agora `campoFederalVotesByYear` (3 anos), `federalTallyByYear` (comparecimento/brancos/nulos) e `majoritarian2022` (presidente/governador T1: votos + brancos/nulos/válidos).
- **`src/utilities/municipalityPotential.ts`**: deriva por município a partir do artefato (nenhuma query no runtime); expõe `projectedValidVotes`, `fieldCeiling`, `captureRate`, `intraFieldShare`, `rollOff`, `suggestedGoal`.
- **`src/utilities/goalCoverage.ts`**: `meta = expectedVotes[cenário] ?? suggestedGoal`; `comprometido = aggregate.effectiveByScenario[cenário]` (só Σ pledges, via [A10](cenarios-estimativa-votos.md) / `votePledgeData.ts`, default `central`) — a expectativa da mesa nunca substitui os pledges no denominador de cobertura; sanity por TI via `bahiaTerritories.ts`.
- **Global `CampaignGoals`** (`src/globals/CampaignGoals.ts`): campos `stateGoal`, `margin`, `baseYear`, `note`; access staff-read/coordinator-write; **sem** hook de revalidação (`/campanha` é dinâmico com auth, nada cacheia este global — ver "Achados da auditoria").
- **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.
## Dependências
- Duras: nenhuma pendente — **A10** entregue ([plano](cenarios-estimativa-votos.md)) e remodelagem em produção desde 2026-07-23 (schema `municipality`/`votePledge` vivo). Suave: C12 (delta semanal real da cobertura usa trajetória de pledges; até lá, sem histórico).
- Reusa: `municipalityElectoralBaseline.ts`, `municipalityElectionGeography.ts`, `votePledgeData.ts` (agregação por cenário pós-A10), `municipalityPageData.ts` (`loadMunicipalityListPageBundle`), `campaignDashboardData.ts`, `municipalityStaffFormActions.ts`, `campaignAccess.ts`.
- Reusa: `bahiaElectionAggregates.ts` (artefato v2), `municipalityElectionGeography.ts` (loaders do artefato v2), `votePledgeData.ts` (agregação por cenário pós-A10), `campaignMunicipalityScope.ts` (`loadMunicipalityScope`), `campaignDashboardData.ts`, `municipalityStaffFormActions.ts`, `campaignAccess.ts`.
## Não escopo
@ -85,12 +118,12 @@ Componentes:
- **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 `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).
- **Roll-off por zona de Salvador sem tally do majoritário na mesma malha.** **Fechado por evidência (auditoria 2026-07-24):** o banco local seedado tem presidente/governador T1 e T2 por zona para 2022 (450 células por escopo, cobrindo as 19 zonas de Salvador); não existe para 2014/2018 (só deputado federal foi seedado nesses anos) — roll-off fica 2022-only, com nota na UI.
## Adiado com gatilho
- **Delta semanal persistido.** Revisitar quando C12 entregar trajetória (gatilho: primeira semana com 2 snapshots).
- **Meta por cenário (Bom/Regular/Mínimo) × cobertura tripla.** Gatilho: coordenador pedir leitura por cenário na reunião (hoje: cobre contra `regular ?? suggestedGoal`).
- **Meta por cenário (pessimista/central/otimista) × cobertura tripla.** Resolvido nesta sessão como escopo do próprio E8 (não mais adiado): cobertura é computada para os três cenários no servidor, seletor de cenário fica no cliente (`MunicipalityEstimateScenarioProvider`, default `central`).
## Referências

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-24 (B19 gerenciar assessores; C12 ✓; C11 absorve escala plano↔demandas; B18; B17; B16; reorder DnD fora de escopo; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6, E8)
Atualizado em: 2026-07-24 (B19 gerenciar assessores; **E8 ✓** conta da cadeira — desbloqueia E9/E10/E12/E13; E18 registrado; C12 ✓; C11 absorve escala plano↔demandas; B18; B17; B16; reorder DnD fora de escopo; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6)
Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026)
@ -47,6 +47,7 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md](
- **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.
- **E17 (2026-07-24)** — tabela comparativa dos 27 Territórios de Identidade no Início staff (`/campanha`): `territoryOverview.ts` (rollup puro client-safe: `computeTerritoryRollup` + `sortTerritoryRows`) + `loadTerritoryOverview.ts` (loader server-only, `overrideAccess: true`) + `TerritoryOverviewTable.tsx` (tabela densa, ordenação client-side default `% da própria votação desc`, Metropolitano decomposto em Salvador 19 zonas × Demais RMS, linha→`/campanha/municipios?region=<TI>`). Somas/razões apenas (salvaguarda MAUP); sem migration/collection. Primeira fatia de E12. Plano: [tabela-ti-inicio.md](plans/tabela-ti-inicio.md).
- **C12 (2026-07-24)** — registro-fundação: versions nativas em `votePledge` com baseline; sinais tipados em `municipalityUpdate`; origem do `actionPlan`; demandas vinculadas com custo derivado e criação múltipla atômica; `allocationDecision` ex-ante imutável. Migration `20260724_180000_add_campaign_foundation_records`; testes int + E2E. Plano: [registro-fundacao.md](plans/registro-fundacao.md).
- **E8 (2026-07-24)** — conta da cadeira: artefato TSE commitado estendido para v2 (`campoFederalVotesByYear`, `federalTallyByYear`, `majoritarian2022` presidente/governador T1, via `pnpm build:election-aggregates`); curadoria `src/lib/campoParties.ts` (ano→siglas do campo, validada contra `electionPartySpectrum`); global `campaignGoals` (meta estadual 150 mil + margem, access staff-read/coordinator-write, migration `20260724_180000_add_campaign_goals_global`); `municipalityPotential.ts` (válidos projetados, teto do campo, captura, share intracampo, roll-off 2022-only, decomposição proporcional da meta + sanity por TI) e `goalCoverage.ts` (`meta = expectedVotes[cenário] ?? suggestedGoal`; `comprometido = Σ pledges`, nunca a expectativa da mesa); encaixado no dashboard, na visão geral e na lista de municípios (nova coluna "Cobertura da meta"), e num novo card "Conta da cadeira" no detalhe. Plano: [conta-da-cadeira.md](plans/conta-da-cadeira.md). Desbloqueia **E9**, **E10**, **E12**, **E13** (e melhora **B13**/**E16**).
## Próximos — Campanha (`/campanha`)
@ -56,15 +57,14 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
| ID | Fatia | Plano | Entrega essencial | Classe | Appetite | Janela | Depende de |
| --- | ---------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | --------- | ------------------------------ |
| E8 | Conta da cadeira | [detalhes](plans/conta-da-cadeira.md) | Global `campaignGoals`; potencial por município (válidos projetados, captura do campo, roll-off, headroom); decomposição meta→município; cobertura Σpledges÷meta (cenário default = média); meta inicial ancorada em campo: piso 150 mil (2022: 129k) | B | ~2d | 1–2 | — (A10 ✓, remodelagem em prod) |
| E9 | Fila de alocação | [detalhes](plans/fila-de-alocacao.md) | Lista de decisão (7 colunas + % da própria votação — absorve A11), ordenação por déficit descoberto (default do staff quando houver metas — registro no fluxo de poder) + risco por frescor, "coluna da vergonha" | B | ~1,5d | 2 | E8 |
| E16 | Dossiê do município (pré-agenda) | [detalhes](plans/dossie-municipio.md) | Dossiê pré-visita compondo dados existentes (série/captura, pledges/cobertura, lideranças, conjuntura, forças/riscos, dobradinhas, sinais, perfil A8) + emendas quando houver dado (G11); visão print — pedido explícito de campo (O6) | B | ~1d | 2 | — (melhora com E8/C12) |
| E10 | Classificação territorial relativa | [detalhes](plans/classificacao-territorial-relativa.md) | Âncoras relativas (LQ / share da cadeira marginal), multi-eixo; substitui 35/20/10 para DF | B | ~1d | 3 | E8 |
| B13 | Escala relativa no mapa | [detalhes](plans/escala-relativa-mapa.md) | Quantis do candidato (default) + LQ + rank; símbolo proporcional por votos em jogo; % válidos mantido | B | ~2d | 3 | E8, E10 |
| E9 | Fila de alocação | [detalhes](plans/fila-de-alocacao.md) | Lista de decisão (7 colunas + % da própria votação — absorve A11), ordenação por déficit descoberto (default do staff quando houver metas — registro no fluxo de poder) + risco por frescor, "coluna da vergonha" | B | ~1,5d | 2 | E8 ✓ |
| E16 | Dossiê do município (pré-agenda) | [detalhes](plans/dossie-municipio.md) | Dossiê pré-visita compondo dados existentes (série/captura, pledges/cobertura, lideranças, conjuntura, forças/riscos, dobradinhas, sinais, perfil A8) + emendas quando houver dado (G11); visão print — pedido explícito de campo (O6) | B | ~1d | 2 | — (melhora com E8 ✓/C12) |
| E10 | Classificação territorial relativa | [detalhes](plans/classificacao-territorial-relativa.md) | Âncoras relativas (LQ / share da cadeira marginal), multi-eixo; substitui 35/20/10 para DF | B | ~1d | 3 | E8 ✓ |
| B13 | Escala relativa no mapa | [detalhes](plans/escala-relativa-mapa.md) | Quantis do candidato (default) + LQ + rank; símbolo proporcional por votos em jogo; % válidos mantido | B | ~2d | 3 | E8 ✓, E10 |
| E14 | Níveis de envolvimento N0–N4 | [detalhes](plans/niveis-de-envolvimento.md) | `engagementLevel` com histerese e sinais de reversão; staff-only (vocabulário duplo) | B | ~1d | 3 | C12 |
| E11 | Motor de sugestões v1 | [detalhes](plans/motor-de-sugestoes.md) | 8 padrões (P1/P2/P3/P5/P6/P7/K-A/K-B), menu com estatuto, aceitar/descartar → `allocationDecision`, triagem 1–5 | C | ~3d | 3 | E8, E9, E10, C12 |
| E12 | Camada TI | [detalhes](plans/camada-territorios-identidade.md) | Rollups com salvaguardas MAUP, benchmark intra-TI (gatilhos T no motor = fase 2 de E11) | B | ~1,5d | 3 | E8 |
| E13 | Planejador de presença/giros | [detalhes](plans/planejador-de-giros.md) | Elegibilidade (5 condições ✓/—), fases construção/consolidação/ativação, compositor de giro, visita pedida×justificada | C | ~1,5d | 3 | E8, C12 |
| E11 | Motor de sugestões v1 | [detalhes](plans/motor-de-sugestoes.md) | 8 padrões (P1/P2/P3/P5/P6/P7/K-A/K-B), menu com estatuto, aceitar/descartar → `allocationDecision`, triagem 1–5 | C | ~3d | 3 | E8 ✓, E9, E10, C12 |
| E12 | Camada TI | [detalhes](plans/camada-territorios-identidade.md) | Rollups com salvaguardas MAUP, benchmark intra-TI (gatilhos T no motor = fase 2 de E11) | B | ~1,5d | 3 | E8 ✓ |
| E13 | Planejador de presença/giros | [detalhes](plans/planejador-de-giros.md) | Elegibilidade (5 condições ✓/—), fases construção/consolidação/ativação, compositor de giro, visita pedida×justificada | C | ~1,5d | 3 | E8 ✓, C12 |
| E15 | Backtest pós-eleição | [detalhes](plans/backtest-pos-eleicao.md) | Pledge (trajetória) vs. resultado por zona; calibração de limiares | A | ~1d | pós-04/10 | C12, TSE 2026 |
### Demais itens abertos
@ -132,9 +132,9 @@ flowchart TD
E17n["E17 ✓ Tabela TI no Início"]
subgraph Intel["Inteligência de campanha"]
E8i["E8 Conta da cadeira"]
E8i["E8 ✓ Conta da cadeira"]
E9i["E9 Fila de alocação"]
C12i["C12 Registro-fundação"]
C12i["C12 ✓ Registro-fundação"]
E16i["E16 Dossiê do município"]
E10i["E10 Classificação relativa"]
B13i["B13 Escala relativa no mapa"]
@ -176,13 +176,13 @@ flowchart TD
B8F2 -.ZE Salvador.-> B14n
```
Paralelizáveis agora: **E8** (A10 ✓, C12 ✓ e remodelagem em produção), **E16** (compõe o existente), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), **B16** (filtros no header; soft B15 ✓ já entregue), **B17** (seletor de colunas; soft B15/B16 — pousa na barra atual ou slim), **B18** (filtros salvos + submenu Municípios; soft B16/B17 — botão na barra), **B19** (gerenciar assessores — onboarding sem `/admin`; sem deps duras), fill-ins (ícone de prioridade na lista, Cenário junto aos filtros — encaixa na barra slim de B16 —, O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12) não absorver os deltas do ZAP (não compete com E8/E9).
Paralelizáveis agora: **E9** (E8 ✓ entregue), **E10**/**E12** (E8 ✓ desbloqueou ambos), **E16** (compõe o existente), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), **B16** (filtros no header; soft B15 ✓ já entregue), **B17** (seletor de colunas; soft B15/B16 — pousa na barra atual ou slim), **B18** (filtros salvos + submenu Municípios; soft B16/B17 — botão na barra), **B19** (gerenciar assessores — onboarding sem `/admin`; sem deps duras), fill-ins (ícone de prioridade na lista, Cenário junto aos filtros — encaixa na barra slim de B16 —, O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12 ✓) não absorver os deltas do ZAP (não compete com E9).
### Sequência por janela (só pendentes)
**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) e **A11 ✓** (posição em votos + default `sort=votos`) entregues 2026-07-24; **B19** gerenciar assessores (caminho do onboarding sem `/admin` — criar/ativar contas e carteiras); **B16** (filtros no header) / **B17** (seletor de colunas) / **B18** (filtros salvos + atalho sidebar) se folga de UX da lista no onboarding; gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E8** pode começar em paralelo; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa).
**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E8 ✓** conta da cadeira entregue; **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) e **A11 ✓** (posição em votos + default `sort=votos`) entregues 2026-07-24; **B19** gerenciar assessores (caminho do onboarding sem `/admin` — criar/ativar contas e carteiras); **B16** (filtros no header) / **B17** (seletor de colunas) / **B18** (filtros salvos + atalho sidebar) se folga de UX da lista no onboarding; gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E9** pode começar em paralelo; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa).
**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; **C12 ✓** registro-fundação já em código; **E8** conta da cadeira → **E9** fila de alocação; **E16** dossiê do município (pedido O6 — compõe o existente, melhora com E8); D2 se sobrar folga.
**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; **E9** fila de alocação (E8 ✓ e **C12 ✓** já entregues); **E16** dossiê do município (pedido O6 — compõe o existente, melhora com E8 ✓/C12 ✓); D2 se sobrar folga.
**Janela 3 — 16/08 → set:** A6 dobradinha (pós-TSE 15/08); **E10** classificação relativa → **B13** escala relativa no mapa; **E14** níveis N0–N4; **E11** motor de sugestões v1; **E12** camada TI; **E13** planejador de giros; **B8 F2** polígonos das zonas de Salvador; débitos sobreviventes. Se houver folga **e** os deltas do ZAP não estiverem sendo absorvidos pelo fluxo sede-digita (C12): começar **D3**.
@ -190,7 +190,7 @@ Paralelizáveis agora: **E8** (A10 ✓, C12 ✓ e remodelagem em produção), **
### Cortes seguros / não cortáveis
**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); C2 dados reais; assimetria declarado×estimado (relação de campo); **E8**+**E9**+**C12** (a conta da cadeira, a fila e o registro ex-ante são o mínimo de "inteligência, não planilha" — e C12 é irrecuperável se não registrar durante a campanha); **B19** gerenciar assessores (onboarding Onda 0 §4 — CG/candidato não entram em `/admin`; sem esta UI o time só nasce via seed ou admin Payload).
**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); **E9** (a fila de alocação é o mínimo de "inteligência, não planilha"); **B19** gerenciar assessores (onboarding Onda 0 §4 — CG/candidato não entram em `/admin`; sem esta UI o time só nasce via seed ou admin Payload).
**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter fila E9 sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; **B14** município mais próximo (lista/busca e Recentes continuam); **B18** filtros salvos (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B17** seletor de colunas (tabela completa continua; scroll horizontal); **B16** filtros no header (barra de filtros atual continua a funcionar); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); **E16** dossiê (último dos extras — pedido explícito de campo); débitos/fill-ins.

View file

@ -1,13 +1,17 @@
/**
* Builds the committed per-municipality election baseline artifact consumed by
* the campaign map (src/lib/electionAggregates/bahia-federal-baseline.json).
* the campaign map and by E8's "conta da cadeira" derived potential/coverage
* (src/lib/electionAggregates/bahia-federal-baseline.json).
*
* Why an artifact instead of runtime queries: the 2014/2018/2022 TSE results
* are immutable, but the map used to re-scan statewide city×zone slices on
* every dashboard visit. Precomputing the per-municipality sums (Solla nominal
* votes + federal T1 valid votes, cut by the same `municipalityCatalog`
* geography used at runtime — Salvador split into zone municipalities, every
* other municipality whole) removes all historical-year DB work.
* are immutable, but the map/E8 used to re-scan statewide city×zone slices
* (electionCandidateVote has ~100k/73k/52k rows for 2022/2018/2014) on every
* dashboard/list visit. Precomputing the per-municipality sums — Solla nominal
* votes, federal T1 valid votes, campo-parties federal nominal votes,
* federal T1 turnout/blank/null, and 2022 majoritarian (presidente/governador
* #13) votes+tally — cut by the same `municipalityCatalog` geography used at
* runtime (Salvador split into zone municipalities, every other municipality
* whole) removes all historical-year DB work from the request/build path.
*
* Why not compute during `pnpm build`: Vercel builds must not depend on the
* production database CONTENT (TSE seeds are local-only by policy), and the
@ -35,16 +39,18 @@ loadEnv({ path: '.env.local' })
loadEnv({ path: '.env' })
const payloadConfig = (await import('../src/payload.config.ts')).default
const { BASELINE_TICKET_2022, HISTORICAL_SERIES_YEARS } = await import(
'../src/lib/electionResults.ts'
)
const { BASELINE_TICKET_2022, ELECTION_YEAR_2022, FEDERAL_DEPUTY_OFFICE, HISTORICAL_SERIES_YEARS } =
await import('../src/lib/electionResults.ts')
const { municipalityCatalog } = await import('../src/lib/municipalityCatalog.ts')
const { municipalityElectionGeography } = await import(
'../src/utilities/municipalityElectionGeography.ts'
)
const {
loadCampoFederalVotesByCityZone,
loadCandidateVotesByCityZone,
loadOfficeTallyByCityZone,
loadValidVotesByCityZone,
sumOfficeTallyForGeography,
sumVotesForGeography,
} = await import('../src/utilities/municipalityElectoralBaseline.ts')
@ -65,13 +71,20 @@ const cliReader = { collection: 'users' }
const candidateNumber = BASELINE_TICKET_2022.candidate.candidateNumber
const municipalities = {}
for (const entry of municipalityCatalog) {
municipalities[entry.slug] = { votesByYear: {}, validVotesByYear: {} }
municipalities[entry.slug] = {
votesByYear: {},
validVotesByYear: {},
campoFederalVotesByYear: {},
federalTallyByYear: {},
}
}
for (const year of HISTORICAL_SERIES_YEARS) {
const [candidateVotes, validVotes] = await Promise.all([
const [candidateVotes, validVotes, campoVotes, federalTally] = await Promise.all([
loadCandidateVotesByCityZone(payload, cliReader, { year, candidateNumber }),
loadValidVotesByCityZone(payload, cliReader, { year }),
loadCampoFederalVotesByCityZone(payload, cliReader, { year }),
loadOfficeTallyByCityZone(payload, cliReader, { year, office: FEDERAL_DEPUTY_OFFICE, turn: '1' }),
])
if (candidateVotes.size === 0) {
@ -83,17 +96,65 @@ for (const year of HISTORICAL_SERIES_YEARS) {
}
let yearTotal = 0
let campoTotal = 0
for (const entry of municipalityCatalog) {
const geography = municipalityElectionGeography(entry)
const votes = sumVotesForGeography(candidateVotes, geography)
const valid = sumVotesForGeography(validVotes, geography)
const campo = sumVotesForGeography(campoVotes, geography)
const tally = sumOfficeTallyForGeography(federalTally, geography)
municipalities[entry.slug].votesByYear[String(year)] = votes
municipalities[entry.slug].validVotesByYear[String(year)] = valid
municipalities[entry.slug].campoFederalVotesByYear[String(year)] = campo
municipalities[entry.slug].federalTallyByYear[String(year)] = tally
yearTotal += votes
campoTotal += campo
}
console.log(`[build:election-aggregates] ${year}: ${yearTotal} votes for #${candidateNumber}.`)
console.log(
`[build:election-aggregates] ${year}: ${yearTotal} votes for #${candidateNumber} ` +
`(campo total: ${campoTotal}).`,
)
}
// 2022-only: majoritarian (presidente/governador #13) votes + tally, used for
// "teto do campo" and roll-off — no majoritária seeded for 2014/2018 (E8 audit).
const majoritarianOffices = [
{ key: 'president', office: 'presidente', candidateNumber: BASELINE_TICKET_2022.president.candidateNumber },
{ key: 'governor', office: 'governador', candidateNumber: BASELINE_TICKET_2022.governor.candidateNumber },
]
const majoritarianByOffice = {}
for (const { key, office, candidateNumber: officeCandidateNumber } of majoritarianOffices) {
const [votes, tally] = await Promise.all([
loadCandidateVotesByCityZone(payload, cliReader, {
year: ELECTION_YEAR_2022,
office,
turn: '1',
candidateNumber: officeCandidateNumber,
}),
loadOfficeTallyByCityZone(payload, cliReader, { year: ELECTION_YEAR_2022, office, turn: '1' }),
])
majoritarianByOffice[key] = { votes, tally }
}
let presidentTotal = 0
for (const entry of municipalityCatalog) {
const geography = municipalityElectionGeography(entry)
const president = {
votes: sumVotesForGeography(majoritarianByOffice.president.votes, geography),
...sumOfficeTallyForGeography(majoritarianByOffice.president.tally, geography),
}
const governor = {
votes: sumVotesForGeography(majoritarianByOffice.governor.votes, geography),
...sumOfficeTallyForGeography(majoritarianByOffice.governor.tally, geography),
}
municipalities[entry.slug].majoritarian2022 = { president, governor }
presidentTotal += president.votes
}
console.log(
`[build:election-aggregates] 2022 majoritarian: ${presidentTotal} votes for president #${BASELINE_TICKET_2022.president.candidateNumber}.`,
)
const artifact = {
provenance:
'Derived from TSE open data seeded by pnpm db:seed:tse; regenerate with pnpm build:election-aggregates.',

View file

@ -9,6 +9,7 @@ import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { CandidateComparePicker } from '@/components/campaign/CandidateComparePicker'
import { MunicipalityBaselineCard } from '@/components/campaign/MunicipalityBaselineCard'
import { MunicipalityCandidateComparisonTable } from '@/components/campaign/MunicipalityCandidateComparisonTable'
import { MunicipalityGoalAccountCard } from '@/components/campaign/MunicipalityGoalAccountCard'
import { MunicipalityLeadershipsPanel } from '@/components/campaign/MunicipalityLeadershipsPanel'
import { MunicipalityPledgesPanel } from '@/components/campaign/MunicipalityPledgesPanel'
import { MunicipalityStrategyCard } from '@/components/campaign/MunicipalityStrategyCard'
@ -32,6 +33,7 @@ import {
} from '@/utilities/municipalityDetailTabUi'
import { municipalityElectionGeographyForSlug } from '@/utilities/municipalityElectionGeography'
import { loadMunicipalityElectoralBaseline } from '@/utilities/municipalityElectoralBaseline'
import { loadMunicipalityGoalAccount } from '@/utilities/municipalityGoalAccount'
import {
getMunicipalityDetailViewModel,
MunicipalityNotFoundError,
@ -46,10 +48,13 @@ import {
parseMunicipalityUpdateFeedParams,
} from '@/utilities/municipalityUpdatePageData'
import { loadAdvisorSummaries } from '@/utilities/municipalityViewModels'
import type { VoteEstimateScenarioViewModel } from '@/utilities/voteEstimate'
import {
aggregateMunicipalityPledgesFromRows,
loadMunicipalityPledges,
toMunicipalityPledgeCoverageView,
type MunicipalityPledgeAggregate,
type MunicipalityPledgeCoverageView,
} from '@/utilities/votePledgeData'
import { declareVotesFormAction, estimateVotesFormAction } from './pledgeFormActions'
import { createMunicipalityUpdateFormAction } from './updateFormActions'
@ -182,26 +187,64 @@ const OverviewTab = async ({
view.kind === 'zona' ? <MunicipalityZoneNeighborhoodsCard municipalitySlug={view.slug} /> : null
const pledges = await loadMunicipalityPledges(payload, user, view.id)
const pledgeCoverage = toMunicipalityPledgeCoverageView(
aggregateMunicipalityPledgesFromRows(pledges),
)
const pledgeAggregate = aggregateMunicipalityPledgesFromRows(pledges)
const pledgeCoverage = toMunicipalityPledgeCoverageView(pledgeAggregate)
return (
<div className="flex flex-col gap-6">
{zoneNeighborhoodsCard}
{view.strategy ? (
<MunicipalityStrategyCard
strategy={view.strategy}
municipalitySlug={view.slug}
canEdit
pledgeCoverage={pledgeCoverage}
/>
<>
<MunicipalityStrategyCard strategy={view.strategy} municipalitySlug={view.slug} canEdit />
<GoalAccountCard
payloadUser={{ payload, user }}
municipalityID={view.id}
slug={view.slug}
expectedVotes={view.strategy.expectedVotes}
pledgeCoverage={pledgeCoverage}
pledgeAggregate={pledgeAggregate}
/>
</>
) : null}
<MunicipalityPledgesPanel pledges={pledges} estimateFormAction={estimateVotesFormAction} />
</div>
)
}
const GoalAccountCard = async ({
payloadUser: { payload, user },
municipalityID,
slug,
expectedVotes,
pledgeCoverage,
pledgeAggregate,
}: {
payloadUser: PayloadUser
municipalityID: number
slug: string
expectedVotes: VoteEstimateScenarioViewModel
pledgeCoverage: MunicipalityPledgeCoverageView | null
pledgeAggregate: MunicipalityPledgeAggregate
}) => {
const { suggestedGoal, goalCoverage, potential } = await loadMunicipalityGoalAccount(
payload,
user,
{ slug, expectedVotes },
pledgeAggregate,
)
return (
<MunicipalityGoalAccountCard
municipalityID={municipalityID}
expectedVotes={expectedVotes}
pledgeCoverage={pledgeCoverage}
suggestedGoal={suggestedGoal}
goalCoverage={goalCoverage}
potential={potential}
/>
)
}
const MunicipalityTabFallback = () => (
<div aria-hidden="true" className="flex flex-col gap-4">
<div className="h-40 w-full animate-pulse rounded-xl border bg-muted/40" />

View file

@ -13,6 +13,11 @@ import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { formatElectionNumber } from '@/lib/electionInsights'
import type { StaffDashboardView } from '@/utilities/campaignDashboardData'
import {
formatGoalCoverageDeficitLabel,
formatGoalCoverageRatioLabel,
goalCoverageProgressPercent,
} from '@/utilities/goalCoverage'
import {
formatVoteEstimateEndpointsLabel,
voteEstimateScenarioLabels,
@ -68,6 +73,12 @@ export const CampaignDashboard = ({
? Math.round((view.withAdvisorCount / view.municipalityCount) * 100)
: undefined,
},
{
label: 'Cobertura da meta',
value: formatGoalCoverageRatioLabel(view.goalCoverage),
detail: formatGoalCoverageDeficitLabel(view.goalCoverage),
progress: goalCoverageProgressPercent(view.goalCoverage),
},
{
label: 'Declarações sem estimativa',
value: formatElectionNumber(view.missingEstimateCount),

View file

@ -12,6 +12,19 @@ export type CampaignMetric = {
valueAriaLabel?: string
}
/**
* Tailwind needs the full class name present in source to generate it — this
* map keeps existing 3-metric callers (`sm:grid-cols-3`, unchanged) working
* byte-for-byte while giving 4-metric callers (E8 "conta da cadeira") a
* balanced 2x2 layout on narrow screens instead of an orphaned 4th cell.
*/
const gridColsBySize: Record<number, string> = {
1: 'sm:grid-cols-1',
2: 'sm:grid-cols-2',
3: 'sm:grid-cols-3',
4: 'sm:grid-cols-2 lg:grid-cols-4',
}
export const CampaignMetricStrip = ({
metrics,
className,
@ -21,7 +34,8 @@ export const CampaignMetricStrip = ({
}) => (
<dl
className={cn(
'grid grid-cols-1 gap-0 divide-y overflow-hidden rounded-xl bg-card ring-1 ring-foreground/10 sm:grid-cols-3 sm:divide-x sm:divide-y-0',
'grid grid-cols-1 gap-0 divide-y overflow-hidden rounded-xl bg-card ring-1 ring-foreground/10 sm:divide-x sm:divide-y-0',
gridColsBySize[metrics.length] ?? 'sm:grid-cols-3',
className,
)}
>

View file

@ -0,0 +1,130 @@
import { CampaignInfoHint } from '@/components/campaign/CampaignInfoHint'
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/MunicipalityListExpectedVotesControl'
import { Progress } from '@/components/ui/Progress'
import { formatElectionNumber } from '@/lib/electionInsights'
import {
formatGoalCoverageDeficitLabel,
formatGoalCoverageRatioLabel,
formatRatioAsPercentLabel,
goalCoverageProgressPercent,
type MunicipalityGoalCoverage,
} from '@/utilities/goalCoverage'
import type { MunicipalityPotential, RollOff } from '@/utilities/municipalityPotential'
import type { VoteEstimateScenarioViewModel } from '@/utilities/voteEstimate'
import type { MunicipalityPledgeCoverageView } from '@/utilities/votePledgeData'
const formatRollOff = (rollOff: RollOff | null): string => {
if (!rollOff) return 'Sem majoritária seedada (2014/2018)'
const votesLabel = formatElectionNumber(rollOff.votes)
const percentLabel =
rollOff.percentOfTurnout != null
? ` (${formatRatioAsPercentLabel(rollOff.percentOfTurnout)} do comparecimento)`
: ''
return `${votesLabel}${percentLabel}`
}
/**
* E8 "conta da cadeira" — meta × comprometido para este município, com o
* bloco de diagnóstico (teto do campo, captura, share intracampo, roll-off).
* Cenário fixo `central`: o detalhe não tem seletor de cenário (a lista e o
* dashboard têm).
*/
export const MunicipalityGoalAccountCard = ({
municipalityID,
expectedVotes,
pledgeCoverage,
suggestedGoal,
goalCoverage,
potential,
}: {
municipalityID: number
expectedVotes: VoteEstimateScenarioViewModel
pledgeCoverage: MunicipalityPledgeCoverageView | null
suggestedGoal: number
goalCoverage: MunicipalityGoalCoverage
potential: MunicipalityPotential
}) => {
const usesMesaEstimate = expectedVotes.central != null
const latestIntraFieldShareYear = Math.max(
...Object.keys(potential.intraFieldShareByYear).map(Number),
)
const latestIntraFieldShare = potential.intraFieldShareByYear[latestIntraFieldShareYear] ?? null
return (
<section
aria-labelledby="municipality-goal-account-title"
className="flex flex-col gap-4 rounded-xl border p-4"
>
<div className="flex items-center gap-1">
<h2 id="municipality-goal-account-title" className="text-base font-medium">
Conta da cadeira
</h2>
<CampaignInfoHint label="Sobre a conta da cadeira">
<p>
<strong>Meta</strong> é a estimativa da mesa; sem estimativa, usa a meta sugerida
(decomposição proporcional da meta estadual pelo teto do campo projetado).{' '}
<strong>Comprometido</strong> é só a soma das declarações de lideranças — nunca a meta.
O roll-off (diferença de brancos/nulos entre a disputa de DF e a majoritária) só existe
para 2022; não há majoritária seedada em 2014/2018.
</p>
</CampaignInfoHint>
</div>
<div className="rounded-lg bg-muted/40 px-3 py-2">
<p className="text-xs font-medium text-muted-foreground">Votos estimados</p>
<MunicipalityListExpectedVotesControl
municipalityID={municipalityID}
expectedVotes={expectedVotes}
pledgeCoverage={pledgeCoverage}
/>
<p className="mt-1 text-xs text-muted-foreground">
Meta usada na conta: {formatElectionNumber(goalCoverage.goal)} (
{usesMesaEstimate ? 'estimativa da mesa' : 'meta sugerida, decomposição da meta estadual'})
</p>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-2">
<span className="text-2xl font-semibold tabular-nums">
{formatGoalCoverageRatioLabel(goalCoverage)}
</span>
<span className="text-sm text-muted-foreground">Cobertura da meta</span>
</div>
{goalCoverage.coverageRatio != null ? (
<Progress
value={goalCoverageProgressPercent(goalCoverage)}
aria-label={`Cobertura da meta: ${formatGoalCoverageRatioLabel(goalCoverage)}`}
/>
) : null}
<p className="text-xs text-muted-foreground tabular-nums">
{formatGoalCoverageDeficitLabel(goalCoverage)}
</p>
</div>
<dl className="grid grid-cols-2 gap-3 border-t pt-3 text-sm sm:grid-cols-4">
<div>
<dt className="text-xs text-muted-foreground">Teto do campo (proj.)</dt>
<dd className="tabular-nums">{formatElectionNumber(Math.round(potential.projectedFieldCeiling))}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Captura (2022)</dt>
<dd className="tabular-nums">{formatRatioAsPercentLabel(potential.captureRate2022)}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Share intracampo ({latestIntraFieldShareYear})</dt>
<dd className="tabular-nums">{formatRatioAsPercentLabel(latestIntraFieldShare)}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Roll-off (2022)</dt>
<dd className="tabular-nums">{formatRollOff(potential.rollOff2022)}</dd>
</div>
</dl>
{suggestedGoal <= 0 ? (
<p className="text-xs text-muted-foreground">
Sem teto do campo projetado para decompor uma meta sugerida — cadastre a estimativa da
mesa manualmente.
</p>
) : null}
</section>
)
}

View file

@ -4,6 +4,7 @@ import Link from 'next/link'
import { MunicipalityAdvisorAvatarStack } from '@/components/campaign/MunicipalityAdvisorAvatarStack'
import { MunicipalityListAdvisorsControl } from '@/components/campaign/MunicipalityListAdvisorsControl'
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/MunicipalityListExpectedVotesControl'
import { MunicipalityListGoalCoverageCell } from '@/components/campaign/MunicipalityListGoalCoverageCell'
import { MunicipalityListTrendControl } from '@/components/campaign/MunicipalityListTrendControl'
import { MunicipalitySortableHead } from '@/components/campaign/MunicipalitySortableHead'
import { Badge } from '@/components/ui/Badge'
@ -160,6 +161,14 @@ export const MunicipalityList = ({
/>
</dd>
</div>
<div className="col-span-2">
<dt className="text-muted-foreground">Cobertura da meta</dt>
<dd>
<MunicipalityListGoalCoverageCell
coverageByScenario={municipality.goalCoverageByScenario}
/>
</dd>
</div>
<div className="col-span-2">
<dt className="text-muted-foreground">Assessoria</dt>
<dd>
@ -224,8 +233,9 @@ export const MunicipalityList = ({
Última atualização
</MunicipalitySortableHead>
<MunicipalitySortableHead state={state} sortKey="coverage">
Cobertura
Assessoria
</MunicipalitySortableHead>
<TableHead>Cobertura da meta</TableHead>
</>
) : (
<MunicipalitySortableHead state={state} sortKey="lastUpdateAt">
@ -318,6 +328,12 @@ export const MunicipalityList = ({
{hasAdvisor ? 'Coberta' : 'Sem assessor'}
</Badge>
</TableCell>
<TableCell>
<MunicipalityListGoalCoverageCell
coverageByScenario={municipality.goalCoverageByScenario}
layout="compact"
/>
</TableCell>
</>
) : (
<TableCell>

View file

@ -0,0 +1,47 @@
'use client'
import { useMunicipalityEstimateScenarioOptional } from '@/components/campaign/MunicipalityEstimateScenarioContext'
import {
formatGoalCoverageDeficitLabel,
formatGoalCoverageDeficitShortLabel,
formatGoalCoverageRatioLabel,
type MunicipalityGoalCoverage,
} from '@/utilities/goalCoverage'
import { DEFAULT_VOTE_ESTIMATE_SCENARIO, type VoteEstimateScenario } from '@/utilities/voteEstimate'
/**
* E8 "conta da cadeira" list cell: reads the shared scenario picker (same
* pattern as `MunicipalityListExpectedVotesControl`) and shows the
* percent + deficit for that scenario, computed server-side for all three.
*
* `compact` (desktop table, dense) shows a short signed deficit with the
* full sentence as a `title` tooltip — same "abbreviate + title" pattern as
* `StaffMunicipalityVotesDisplay`'s "Nas lideranças" subline — so the column
* doesn't force a wide `whitespace-nowrap` cell. `default` (mobile cards,
* more room) spells the sentence out.
*/
export const MunicipalityListGoalCoverageCell = ({
coverageByScenario,
layout = 'default',
}: {
coverageByScenario: Record<VoteEstimateScenario, MunicipalityGoalCoverage>
layout?: 'default' | 'compact'
}) => {
const scenarioContext = useMunicipalityEstimateScenarioOptional()
const activeScenario = scenarioContext?.scenario ?? DEFAULT_VOTE_ESTIMATE_SCENARIO
const coverage = coverageByScenario[activeScenario]
const deficitLabel = formatGoalCoverageDeficitLabel(coverage)
return (
<div className="flex flex-col items-start">
<span className="font-medium tabular-nums">{formatGoalCoverageRatioLabel(coverage)}</span>
{layout === 'compact' ? (
<span className="text-xs text-muted-foreground tabular-nums" title={deficitLabel}>
{formatGoalCoverageDeficitShortLabel(coverage)}
</span>
) : (
<span className="text-xs text-muted-foreground tabular-nums">{deficitLabel}</span>
)}
</div>
)
}

View file

@ -12,6 +12,11 @@ import {
import { VoteEstimateScenarioStrip } from '@/components/campaign/VoteEstimateScenarioStrip'
import { formatElectionNumber } from '@/lib/electionInsights'
import { cn } from '@/lib/utils'
import {
formatGoalCoverageDeficitLabel,
formatGoalCoverageRatioLabel,
goalCoverageProgressPercent,
} from '@/utilities/goalCoverage'
import type { MunicipalityListOverviewData } from '@/utilities/municipalityPageData'
import {
formatVoteEstimateEndpointsLabel,
@ -46,6 +51,7 @@ export const MunicipalityListOverview = ({ view }: { view: MunicipalityListOverv
const endpointsLabel = formatVoteEstimateEndpointsLabel(scenarioTotals)
const scenarioLabel = voteEstimateScenarioLabels[scenario]
const hasScenarioTotals = hasAnyVoteEstimate(scenarioTotals)
const goalCoverage = view.goalCoverageByScenario[scenario]
const heroDetail = (
<div className="flex flex-col gap-1.5">
@ -117,6 +123,12 @@ export const MunicipalityListOverview = ({ view }: { view: MunicipalityListOverv
? Math.round((view.withAdvisorCount / view.municipalityCount) * 100)
: undefined,
},
{
label: 'Cobertura da meta',
value: formatGoalCoverageRatioLabel(goalCoverage),
detail: formatGoalCoverageDeficitLabel(goalCoverage),
progress: goalCoverageProgressPercent(goalCoverage),
},
]}
/>
</section>

View file

@ -1,7 +1,6 @@
import { PencilIcon } from 'lucide-react'
import Link from 'next/link'
import { StaffMunicipalityVotesDisplay } from '@/components/campaign/StaffMunicipalityVotesDisplay'
import { StateDeputyChips } from '@/components/campaign/StateDeputyChips'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
@ -11,7 +10,6 @@ import {
politicalTrendLabels,
} from '@/utilities/municipalityUi'
import type { MunicipalityDetailViewModel } from '@/utilities/municipalityViewModels'
import type { MunicipalityPledgeCoverageView } from '@/utilities/votePledgeData'
const dateFormatter = new Intl.DateTimeFormat('pt-BR')
@ -19,12 +17,10 @@ export const MunicipalityStrategyCard = ({
strategy,
municipalitySlug,
canEdit,
pledgeCoverage,
}: {
strategy: NonNullable<MunicipalityDetailViewModel['strategy']>
municipalitySlug: string
canEdit: boolean
pledgeCoverage: MunicipalityPledgeCoverageView | null
}) => {
const trend = strategy.politicalTrend
@ -63,14 +59,6 @@ export const MunicipalityStrategyCard = ({
) : null}
</div>
<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">
<h3 className="text-sm font-medium">Tendência política</h3>

View file

@ -0,0 +1,72 @@
import { isCampaignStaff, isCampaignUnrestricted, isPayloadAdmin } from '@/utilities/campaignAccess'
import type { GlobalConfig } from 'payload'
const slug = 'campaignGoals'
/**
* State-level campaign goal used by E8 "conta da cadeira" to decompose a
* per-municipality suggested goal proportional to each municipality's
* projected field ceiling (`municipalityPotential.ts`). Editable by staff
* (piso do CG, sessão 2026-07-23: 150.000) rather than derived automatically —
* decomposition reads this value, it never writes back to it.
*
* No `afterChange` revalidation hook: `/campanha` is dynamic with
* authentication on every request (no ISR/ISR tag caches this global), and
* `campaignGoals` is not in the `revalidateRequest.ts` tag allowlist. Adding
* one here would be dead ceremony (see the E8 plan audit finding #4).
*
* Access is explicit (not Payload's "any authenticated user" default) because
* a `campaignUser` JWT reaches `/api/globals/*` directly: leaders and
* unauthenticated requests must be denied on read, and only coordinator/
* candidate (`isCampaignUnrestricted`) may write the state goal.
*/
export const CampaignGoals: GlobalConfig = {
slug,
label: 'Metas da campanha',
admin: {
group: 'Campanha',
},
access: {
read: ({ req: { user } }) => isPayloadAdmin(user) || isCampaignStaff(user),
update: ({ req: { user } }) => isPayloadAdmin(user) || isCampaignUnrestricted(user),
},
fields: [
{
name: 'stateGoal',
type: 'number',
label: 'Meta estadual (votos)',
required: true,
defaultValue: 150_000,
min: 0,
admin: {
description:
'Piso decidido pela coordenação geral. A meta por município (E8) é decomposta proporcionalmente ao teto do campo projetado a partir deste valor.',
},
},
{
name: 'margin',
type: 'number',
label: 'Margem (%)',
min: 0,
admin: {
description: 'Margem de segurança sobre a meta estadual, para leitura da mesa (não altera a decomposição).',
},
},
{
name: 'baseYear',
type: 'number',
label: 'Ano de referência',
admin: {
description: 'Ano-base considerado pela coordenação ao fixar a meta estadual (contexto, não usado em cálculo).',
},
},
{
name: 'note',
type: 'textarea',
label: 'Observação',
admin: {
description: 'Contexto livre sobre a decisão da meta (ex.: data e instância que decidiu).',
},
},
],
}

View file

@ -6,16 +6,44 @@ import artifactJson from '@/lib/electionAggregates/bahia-federal-baseline.json'
* `municipalityCatalog` geography the runtime uses (Salvador split by zone,
* every other municipality whole — Camaçari included).
*
* v2 (E8 "conta da cadeira"): adds the curated campo-parties federal nominal
* votes, federal T1 turnout/blank/null tally, and 2022 majoritarian
* (presidente/governador #13) votes+tally used for "teto do campo",
* "share intracampo" and roll-off. 2022-only for the majoritarian slice — no
* majoritária seeded for 2014/2018 (E8 audit finding).
*
* Generated by `pnpm build:election-aggregates` from the locally seeded TSE
* data — never computed at build or request time (see the script header for
* the rationale). A snapshot test pins artifact keys ↔ catalog slugs.
*/
/** Turnout/valid/blank/null tally cell (`municipalityElectoralBaseline.ts`'s `OfficeTallyCell` is this same type). */
export type FederalBaselineTallyCell = {
comparecimento: number
votosValidos: number
votosBranco: number
votosNulo: number
}
/** One 2022 majoritarian office (presidente/governador #13): votes + tally. */
type MajoritarianOfficeCell = FederalBaselineTallyCell & {
votes: number
}
export type MunicipalityFederalBaseline = {
/** TSE year (as string) → Solla nominal votes inside the municipality geography. */
votesByYear: Record<string, number>
/** TSE year (as string) → federal T1 valid votes inside the municipality geography. */
validVotesByYear: Record<string, number>
/** TSE year (as string) → curated campo-parties (`campoParties.ts`) federal T1 nominal votes. */
campoFederalVotesByYear: Record<string, number>
/** TSE year (as string) → federal T1 turnout/valid/blank/null tally. */
federalTallyByYear: Record<string, FederalBaselineTallyCell>
/** 2022-only majoritarian tickets (absent for older artifacts / unknown slugs). */
majoritarian2022?: {
president: MajoritarianOfficeCell
governor: MajoritarianOfficeCell
}
}
type FederalBaselineArtifact = {
@ -33,7 +61,12 @@ export const FEDERAL_BASELINE_CANDIDATE_NUMBER = artifact.candidateNumber
export const federalBaselineMunicipalitySlugs = (): string[] =>
Object.keys(artifact.municipalities)
const EMPTY_BASELINE: MunicipalityFederalBaseline = { votesByYear: {}, validVotesByYear: {} }
const EMPTY_BASELINE: MunicipalityFederalBaseline = {
votesByYear: {},
validVotesByYear: {},
campoFederalVotesByYear: {},
federalTallyByYear: {},
}
/**
* Baseline for one catalog municipality slug. Unknown slugs return an empty

61
src/lib/campoParties.ts Normal file
View file

@ -0,0 +1,61 @@
import { normalizePartySigla } from '@/lib/electionPartySpectrum'
/**
* Curated "campo" (Solla/PT political field) parties by year, used for the
* deputado-federal "share intracampo" diagnostic (E8): of the votes that went
* to the field, how many went to Solla specifically. This is NOT the same
* concept as `electionPartySpectrum.ts` (ideological esquerda/centro/direita
* bucket, from the Bolognesi et al. expert survey) — every entry here MUST
* also be in the `esquerda` bucket (enforced by
* `tests/unit/campoParties.unit.spec.ts`), but the reverse isn't required:
* plenty of `esquerda`-bucket parties are not part of this specific ticket's
* field in a given year.
*
* Deliberately NOT inferred from the raw TSE `coalition` string on
* `electionCandidateVote`: Brazilian proportional "coligações" bundle
* opportunistic, ideologically unrelated allies purely to maximize the
* quotient. Confirmed against the locally seeded 2014/2018 data — Solla's own
* registered coalition that year also included PP, PR, PSD, PTB (2014) /
* PODE, PP, PR, PSD (2018), none of which belong in the political field.
*
* Sourcing:
* - 2014: coalition "MAIS MUDANÇAS, NOVAS CONQUISTAS" (BA, deputado federal) —
* PT's core-left partners inside it: PC do B, PDT.
* - 2018: coalition "FRENTE DO TRABALHO POR TODA BAHIA" (BA, deputado
* federal) — PT's core-left partners inside it: PC do B, PSB.
* - 2022: PT ran under the "Federação Brasil da Esperança — FE Brasil"
* (PT/PC do B/PV), a permanent partisan federation (TSE registered it
* 2022-05-24) — not a temporary coalition. Verified via TSE candidate
* filing (Folha "Ficha do candidato" 2022) and press coverage (Estadão,
* Bahia Notícias). PSB backed the same governismo ticket for governor that
* year but ran its own deputado-federal slate outside the federation, so it
* is excluded from the 2022 federal-deputy field (would double count a
* separate slate as "ours").
* - 2026: FE Brasil is legally bound to stay unified through the end of the
* 2023–2026 mandate, so it carries over unchanged. Revisit once 2026
* deputado-federal candidacies are registered (TSE `consulta_cand_2026`).
*/
export type CampoElectionYear = 2014 | 2018 | 2022 | 2026
const CAMPO_PARTIES_BY_YEAR: Readonly<Record<CampoElectionYear, readonly string[]>> = {
2014: ['PT', 'PCDOB', 'PDT'],
2018: ['PT', 'PCDOB', 'PSB'],
2022: ['PT', 'PCDOB', 'PV'],
2026: ['PT', 'PCDOB', 'PV'],
}
export const CAMPO_ELECTION_YEARS = Object.keys(CAMPO_PARTIES_BY_YEAR).map(Number) as CampoElectionYear[]
export const isCampoElectionYear = (year: number): year is CampoElectionYear =>
(CAMPO_ELECTION_YEARS as readonly number[]).includes(year)
/** Campo party siglas (already normalized) for one year, or [] for an unknown year. */
export const campoPartiesForYear = (year: number): readonly string[] =>
isCampoElectionYear(year) ? CAMPO_PARTIES_BY_YEAR[year] : []
/** Whether a TSE party sigla belongs to the curated field for that year. */
export const isCampoParty = (party: string | null | undefined, year: number): boolean => {
const normalized = normalizePartySigla(party)
if (!normalized) return false
return campoPartiesForYear(year).includes(normalized)
}

File diff suppressed because it is too large Load diff

View file

@ -138,7 +138,8 @@ const bahiaMunicipalityByKey = new Map(
/**
* Known TSE NM_MUNICIPIO spellings that diverge from the SEPLAN/SECULT canonical
* names in bahiaTerritories (2022 open-data files). Keys are normalizeMunicipalityKey outputs.
* names in bahiaTerritories (seen across 2014/2018/2022 open-data files). Keys
* are normalizeMunicipalityKey outputs.
*/
const TSE_MUNICIPALITY_ALIASES: Readonly<Record<string, string>> = {
// TSE: CAMACÃ → canonical Camacan
@ -149,6 +150,8 @@ const TSE_MUNICIPALITY_ALIASES: Readonly<Record<string, string>> = {
'MUQUEM DO SAO FRANCISCO': 'Muquém de São Francisco',
// TSE: SANTA TEREZINHA → Santa Teresinha
'SANTA TEREZINHA': 'Santa Teresinha',
// TSE 2018 votacao_candidato_munzona: QUINJINGUE (typo) → canonical Quijingue
QUINJINGUE: 'Quijingue',
}
for (const [aliasKey, canonical] of Object.entries(TSE_MUNICIPALITY_ALIASES)) {

View file

@ -0,0 +1,38 @@
import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
/**
* Adds the `campaignGoals` global (E8 "conta da cadeira" — state-level vote
* goal + margin used to decompose a per-municipality suggested goal).
*
* Hand-written rather than `migrate:create`-generated: the committed snapshot
* chain has no `.json` for the last several migrations (2026-07-23/24 —
* `add_vote_estimate_scenarios`, `remodel_municipalities`,
* `add_state_deputy`, `reconcile_municipality_remodel`,
* `drop_municipality_vote_goals`, `contact_phone_optional` all shipped
* hand-written SQL without regenerating the drizzle-kit snapshot), so the
* schema differ has no accurate "before" state for `municipality`/`contact`
* and asks to disambiguate unrelated historical renames. This migration only
* adds a brand-new table, so it's written directly following the pattern of
* `20260719_054706_add_privacy_policy_global` (idempotent guard is
* unnecessary — Payload never lets two migrations with the same name run
* twice, and the table cannot already exist).
*/
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TABLE "campaign_goals" (
"id" serial PRIMARY KEY NOT NULL,
"state_goal" numeric NOT NULL,
"margin" numeric,
"base_year" numeric,
"note" varchar,
"updated_at" timestamp(3) with time zone,
"created_at" timestamp(3) with time zone
);
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP TABLE "campaign_goals" CASCADE;
`)
}

View file

@ -26,6 +26,7 @@ import * as migration_20260723_202000_reconcile_municipality_remodel from './202
import * as migration_20260724_133600_drop_municipality_vote_goals from './20260724_133600_drop_municipality_vote_goals'
import * as migration_20260724_175500_contact_phone_optional from './20260724_175500_contact_phone_optional'
import * as migration_20260724_180000_add_campaign_foundation_records from './20260724_180000_add_campaign_foundation_records'
import * as migration_20260724_180000_add_campaign_goals_global from './20260724_180000_add_campaign_goals_global'
export const migrations = [
{
@ -168,4 +169,9 @@ export const migrations = [
down: migration_20260724_180000_add_campaign_foundation_records.down,
name: '20260724_180000_add_campaign_foundation_records',
},
{
up: migration_20260724_180000_add_campaign_goals_global.up,
down: migration_20260724_180000_add_campaign_goals_global.down,
name: '20260724_180000_add_campaign_goals_global',
},
]

View file

@ -145,12 +145,14 @@ export interface Config {
home: Home;
metadata: Metadatum;
'privacy-policy': PrivacyPolicy;
campaignGoals: CampaignGoal;
};
globalsSelect: {
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
home: HomeSelect<false> | HomeSelect<true>;
metadata: MetadataSelect<false> | MetadataSelect<true>;
'privacy-policy': PrivacyPolicySelect<false> | PrivacyPolicySelect<true>;
campaignGoals: CampaignGoalsSelect<false> | CampaignGoalsSelect<true>;
};
locale: null;
widgets: {
@ -2043,6 +2045,31 @@ export interface PrivacyPolicy {
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "campaignGoals".
*/
export interface CampaignGoal {
id: number;
/**
* Piso decidido pela coordenação geral. A meta por município (E8) é decomposta proporcionalmente ao teto do campo projetado a partir deste valor.
*/
stateGoal: number;
/**
* Margem de segurança sobre a meta estadual, para leitura da mesa (não altera a decomposição).
*/
margin?: number | null;
/**
* Ano-base considerado pela coordenação ao fixar a meta estadual (contexto, não usado em cálculo).
*/
baseYear?: number | null;
/**
* Contexto livre sobre a decisão da meta (ex.: data e instância que decidiu).
*/
note?: string | null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "site-settings_select".
@ -2115,6 +2142,19 @@ export interface PrivacyPolicySelect<T extends boolean = true> {
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "campaignGoals_select".
*/
export interface CampaignGoalsSelect<T extends boolean = true> {
stateGoal?: T;
margin?: T;
baseYear?: T;
note?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "collections_widget".

View file

@ -34,6 +34,7 @@ import { SupporterImportBatch } from './collections/SupporterImportBatch'
import { Tag } from './collections/Tag'
import { Users } from './collections/Users'
import { VotePledge } from './collections/VotePledge'
import { CampaignGoals } from './globals/CampaignGoals'
import { HomePage } from './globals/HomePage'
import { Metadata } from './globals/Metadata'
import { PrivacyPolicy } from './globals/PrivacyPolicy'
@ -108,7 +109,7 @@ export default buildConfig({
Post,
Tag,
],
globals: [SiteSettings, HomePage, Metadata, PrivacyPolicy],
globals: [SiteSettings, HomePage, Metadata, PrivacyPolicy, CampaignGoals],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '',
typescript: {

View file

@ -2,6 +2,8 @@ import type { Payload } from 'payload'
import type { CampaignUser, VotePledge } from '@/payload-types'
import { loadMunicipalityScope } from '@/utilities/campaignMunicipalityScope'
import type { MunicipalityGoalCoverage } from '@/utilities/goalCoverage'
import { loadMunicipalityGoalCoverageBundle } from '@/utilities/municipalityGoalAccount'
import { relationshipId, requireRelationshipId } from '@/utilities/relationship'
import type { VoteEstimateScenario } from '@/utilities/voteEstimate'
import { rollupMunicipalityStaffVotes } from '@/utilities/votePledgeData'
@ -16,6 +18,8 @@ export type StaffDashboardView = {
declaredVotesTotal: number
pledgeCount: number
missingEstimateCount: number
/** E8 "conta da cadeira" — meta × comprometido, cenário central (dashboard não tem seletor de cenário). */
goalCoverage: MunicipalityGoalCoverage
missingEstimates: Array<{
pledgeID: number
municipalityName: string
@ -108,6 +112,13 @@ export const getCampaignDashboardData = async (
const rollup = rollupMunicipalityStaffVotes(municipalities.docs, pledgeAggregates)
const { staffVoteTotalByScenario, declaredVotesTotal, pledgeCount, missingEstimateCount } = rollup
const goalCoverageBundle = await loadMunicipalityGoalCoverageBundle(
payload,
user,
municipalities.docs,
pledgeAggregates,
)
const leadershipIDs = [
...new Set(
missingEstimatePledges.docs.map((pledge) => requireRelationshipId(pledge.leadership)),
@ -141,6 +152,7 @@ export const getCampaignDashboardData = async (
declaredVotesTotal,
pledgeCount,
missingEstimateCount,
goalCoverage: goalCoverageBundle.aggregateByScenario.central,
missingEstimates: missingEstimatePledges.docs.map((pledge) => {
const municipality = municipalityById.get(requireRelationshipId(pledge.municipality))
const leadership = leadershipsById.get(requireRelationshipId(pledge.leadership))

View file

@ -0,0 +1,37 @@
import 'server-only'
import { cache } from 'react'
import type { Payload } from 'payload'
import type { CampaignGoal, CampaignUser, User } from '@/payload-types'
type CampaignGoalsReader = CampaignUser | User
/**
* Request-scoped read of the `campaignGoals` global (E8 "conta da cadeira" —
* state-level vote goal + margin, decomposed into a per-municipality
* suggested goal by `municipalityPotential.ts`). Degrau 1 da caching ladder
* (React `cache()` per-request dedup): the global changes rarely and every
* page that shows coverage needs the same value once per request.
*
* No `unstable_cache`/tag layer on top: `/campanha` is dynamic with
* per-request auth, so a cross-request cache would need its own
* invalidation path for no read-latency benefit (see the global's own
* comment on why it skips the `revalidateGlobal` hook).
*
* Access is enforced explicitly (not delegated to `payload.findGlobal`'s
* default admin bypass): pass `user` + `overrideAccess: false` so leaders and
* unauthenticated CLI-less callers hit the global's own `access.read` and get
* denied, matching the Local API security pattern used everywhere else in
* this codebase.
*/
export const loadCampaignGoals = cache(
async (payload: Payload, user: CampaignGoalsReader): Promise<CampaignGoal> =>
payload.findGlobal({
slug: 'campaignGoals',
depth: 0,
user,
overrideAccess: false,
}),
)

View file

@ -0,0 +1,136 @@
import { formatElectionNumber } from '@/lib/electionInsights'
import type { MunicipalityPledgeAggregate } from '@/utilities/votePledgeData'
import {
getVoteEstimateForScenario,
VOTE_ESTIMATE_SCENARIOS,
type VoteEstimateScenario,
type VoteEstimateScenarioFields,
} from '@/utilities/voteEstimate'
/**
* E8 "conta da cadeira" — coverage of the goal by auditable pledges.
*
* Semantics locked this session (see the E8 plan's audit finding #2):
* - **meta** = `expectedVotes[cenário] ?? suggestedGoal` — the mesa's own
* expectation when set (`Municipality.expectedVotes`), falling back to the
* goal decomposed from the state goal (`municipalityPotential.ts`).
* - **comprometido** = `aggregate.effectiveByScenario[cenário]` — the pledge
* aggregate ONLY (`estimated[S] ?? declared`, summed over `votePledge`
* rows). It is deliberately **not** `resolveMunicipalityStaffVoteTotal`,
* which lets `expectedVotes` override the pledge sum — using that here
* would report 100% coverage everywhere the projection-sheet import (E4R)
* set an expectation without a single pledge behind it.
*
* Coverage answers "how much of what the mesa expects is backed by an
* auditable commitment", not "did the mesa fill in a number".
*/
export type MunicipalityGoalCoverage = {
goal: number
committed: number
/** committed / goal, or null when goal is zero/negative (avoids a misleading Infinity/NaN). */
coverageRatio: number | null
/** goal − committed; negative means the municipality is over-committed relative to its goal. */
deficit: number
}
/** Read-only zero coverage — never mutate; use for view models outside the staff scope. */
const emptyMunicipalityGoalCoverage: Readonly<MunicipalityGoalCoverage> = {
goal: 0,
committed: 0,
coverageRatio: null,
deficit: 0,
}
/** All three scenarios pointing at the same zero coverage (never mutate). */
export const createEmptyGoalCoverageByScenario = (): Record<
VoteEstimateScenario,
MunicipalityGoalCoverage
> => {
const byScenario = {} as Record<VoteEstimateScenario, MunicipalityGoalCoverage>
for (const scenario of VOTE_ESTIMATE_SCENARIOS) byScenario[scenario] = emptyMunicipalityGoalCoverage
return byScenario
}
/** Coverage for one municipality, one scenario. */
export const computeGoalCoverage = (
expectedVotes: VoteEstimateScenarioFields | null | undefined,
suggestedGoal: number,
pledgeAggregate: MunicipalityPledgeAggregate,
scenario: VoteEstimateScenario,
): MunicipalityGoalCoverage => {
const goal = getVoteEstimateForScenario(expectedVotes, scenario) ?? suggestedGoal
const committed = pledgeAggregate.effectiveByScenario[scenario]
const coverageRatio = goal > 0 ? committed / goal : null
const deficit = goal - committed
return { goal, committed, coverageRatio, deficit }
}
/** Coverage for one municipality, all three scenarios (mirrors `staffVoteTotalByScenario`). */
export const computeGoalCoverageByScenario = (
expectedVotes: VoteEstimateScenarioFields | null | undefined,
suggestedGoal: number,
pledgeAggregate: MunicipalityPledgeAggregate,
): Record<VoteEstimateScenario, MunicipalityGoalCoverage> => {
const coverageByScenario = {} as Record<VoteEstimateScenario, MunicipalityGoalCoverage>
for (const scenario of VOTE_ESTIMATE_SCENARIOS) {
coverageByScenario[scenario] = computeGoalCoverage(
expectedVotes,
suggestedGoal,
pledgeAggregate,
scenario,
)
}
return coverageByScenario
}
/** Sums a list of per-municipality coverages into one aggregate (dashboard/overview total). */
export const aggregateGoalCoverage = (
entries: ReadonlyArray<MunicipalityGoalCoverage>,
): MunicipalityGoalCoverage => {
const goal = entries.reduce((sum, entry) => sum + entry.goal, 0)
const committed = entries.reduce((sum, entry) => sum + entry.committed, 0)
const coverageRatio = goal > 0 ? committed / goal : null
const deficit = goal - committed
return { goal, committed, coverageRatio, deficit }
}
/**
* Generic ratio→percent label, e.g. for `MunicipalityPotential`'s
* `captureRate2022`/`intraFieldShareByYear` diagnostics on the goal-account
* card — shared here so those and `coverageRatio` don't each round/format
* independently.
*/
export const formatRatioAsPercentLabel = (ratio: number | null): string =>
ratio == null ? '—' : `${Math.round(ratio * 100)}%`
/** `coverageRatio` as a rounded percent string, or "—" when the goal is zero/unknown. */
export const formatGoalCoverageRatioLabel = (coverage: MunicipalityGoalCoverage): string =>
formatRatioAsPercentLabel(coverage.coverageRatio)
/** `coverageRatio` clamped to [0, 100] for a `Progress` bar, or `undefined` when unknown. */
export const goalCoverageProgressPercent = (coverage: MunicipalityGoalCoverage): number | undefined =>
coverage.coverageRatio == null ? undefined : Math.min(100, Math.round(coverage.coverageRatio * 100))
/** UI copy for `deficit`: how many votes are missing (or exceeded) relative to the goal. */
export const formatGoalCoverageDeficitLabel = (coverage: MunicipalityGoalCoverage): string => {
if (coverage.goal <= 0) return 'Sem meta definida'
if (coverage.deficit > 0) {
return `Faltam ${formatElectionNumber(coverage.deficit)} votos para a meta`
}
if (coverage.deficit < 0) {
return `${formatElectionNumber(Math.abs(coverage.deficit))} acima da meta`
}
return 'Meta coberta'
}
/**
* `-1.234`/`+300`-style short form of `deficit` for dense table cells — pair
* with `formatGoalCoverageDeficitLabel` as a `title` tooltip (same pattern as
* `StaffMunicipalityVotesDisplay`'s "Nas lideranças" subline).
*/
export const formatGoalCoverageDeficitShortLabel = (coverage: MunicipalityGoalCoverage): string => {
if (coverage.goal <= 0) return '—'
if (coverage.deficit === 0) return '0'
const sign = coverage.deficit > 0 ? '−' : '+'
return `${sign}${formatElectionNumber(Math.abs(coverage.deficit))}`
}

View file

@ -2,11 +2,15 @@ import configPromise from '@payload-config'
import { unstable_cache } from 'next/cache'
import { getPayload, type Payload } from 'payload'
import type { FederalBaselineTallyCell } from '@/lib/bahiaElectionAggregates'
import { isCampoParty } from '@/lib/campoParties'
import {
BASELINE_TICKET_2022,
ELECTION_YEAR_2022,
FEDERAL_DEPUTY_OFFICE,
HISTORICAL_SERIES_YEARS,
type ElectionOffice,
type ElectionTurn,
} from '@/lib/electionResults'
import type { CampaignUser, User } from '@/payload-types'
import { assertCanReadElectionData } from '@/utilities/campaignAccess'
@ -265,11 +269,21 @@ export const loadValidVotesByCityZone = async (
return validVotes
}
/** cityCode:zone → votes map for one candidate/year (municipality-level slicing). */
/**
* cityCode:zone → votes map for one candidate/year (municipality-level
* slicing). Defaults to deputado-federal T1 for the existing map/E2 callers;
* E8's "teto do campo" passes `office`/`turn` explicitly to slice
* presidente/governador instead.
*/
export const loadCandidateVotesByCityZone = async (
payload: Payload,
user: ElectionReader,
{ year, candidateNumber }: { year: number; candidateNumber: number },
{
year,
candidateNumber,
office = FEDERAL_DEPUTY_OFFICE,
turn = '1',
}: { year: number; candidateNumber: number; office?: ElectionOffice; turn?: ElectionTurn },
): Promise<Map<string, number>> => {
assertCanReadElectionData(user)
@ -278,8 +292,8 @@ export const loadCandidateVotesByCityZone = async (
where: {
and: [
{ year: { equals: year } },
{ office: { equals: FEDERAL_DEPUTY_OFFICE } },
{ turn: { equals: '1' } },
{ office: { equals: office } },
{ turn: { equals: turn } },
{ voteType: { equals: 'nominal' } },
{ candidateNumber: { equals: candidateNumber } },
],
@ -298,3 +312,117 @@ export const loadCandidateVotesByCityZone = async (
}
return votes
}
/**
* cityCode:zone → sum of nominal deputado-federal T1 votes for every
* candidate whose party is in the curated `campoParties.ts` field for that
* year (E8 "share intracampo" denominator — CLI/build-time use only, this
* scans the whole statewide slice for the office).
*/
export const loadCampoFederalVotesByCityZone = async (
payload: Payload,
user: ElectionReader,
{ year }: { year: number },
): Promise<Map<string, number>> => {
assertCanReadElectionData(user)
const result = await payload.find({
collection: 'electionCandidateVote',
where: {
and: [
{ year: { equals: year } },
{ office: { equals: FEDERAL_DEPUTY_OFFICE } },
{ turn: { equals: '1' } },
{ voteType: { equals: 'nominal' } },
],
},
depth: 0,
limit: 0,
pagination: false,
select: { cityCode: true, zoneNumber: true, party: true, votes: true },
overrideAccess: true,
})
const votes = new Map<string, number>()
for (const row of result.docs) {
if (!isCampoParty(row.party, year)) continue
const key = `${row.cityCode}:${row.zoneNumber}`
votes.set(key, (votes.get(key) ?? 0) + (row.votes ?? 0))
}
return votes
}
/** Turnout/valid/blank/null tally cell — same shape the committed artifact stores per year. */
export type OfficeTallyCell = FederalBaselineTallyCell
/**
* cityCode:zone → turnout/valid/blank/null tally for one office×year×turn
* (used for the roll-off diagnostic: DF tally vs. majoritarian tally in the
* same cell — CLI/build-time use only).
*/
export const loadOfficeTallyByCityZone = async (
payload: Payload,
user: ElectionReader,
{ year, office, turn }: { year: number; office: ElectionOffice; turn: ElectionTurn },
): Promise<Map<string, OfficeTallyCell>> => {
assertCanReadElectionData(user)
const result = await payload.find({
collection: 'electionTally',
where: {
and: [{ year: { equals: year } }, { office: { equals: office } }, { turn: { equals: turn } }],
},
depth: 0,
limit: 0,
pagination: false,
select: {
cityCode: true,
zoneNumber: true,
comparecimento: true,
votosValidos: true,
votosBranco: true,
votosNulo: true,
},
overrideAccess: true,
})
const tally = new Map<string, OfficeTallyCell>()
for (const row of result.docs) {
const key = `${row.cityCode}:${row.zoneNumber}`
const current = tally.get(key) ?? {
comparecimento: 0,
votosValidos: 0,
votosBranco: 0,
votosNulo: 0,
}
tally.set(key, {
comparecimento: current.comparecimento + (row.comparecimento ?? 0),
votosValidos: current.votosValidos + (row.votosValidos ?? 0),
votosBranco: current.votosBranco + (row.votosBranco ?? 0),
votosNulo: current.votosNulo + (row.votosNulo ?? 0),
})
}
return tally
}
/** Sums an `OfficeTallyCell` map over a municipality's cityCode×zones. */
export const sumOfficeTallyForGeography = (
tallyByCityZone: Map<string, OfficeTallyCell>,
geography: MunicipalityElectionGeography,
): OfficeTallyCell => {
const total: OfficeTallyCell = {
comparecimento: 0,
votosValidos: 0,
votosBranco: 0,
votosNulo: 0,
}
for (const zone of geography.zones) {
const cell = tallyByCityZone.get(`${geography.cityCode}:${zone}`)
if (!cell) continue
total.comparecimento += cell.comparecimento
total.votosValidos += cell.votosValidos
total.votosBranco += cell.votosBranco
total.votosNulo += cell.votosNulo
}
return total
}

View file

@ -0,0 +1,130 @@
import 'server-only'
import { cache } from 'react'
import type { Payload } from 'payload'
import type { CampaignUser, User } from '@/payload-types'
import { loadCampaignGoals } from '@/utilities/campaignGoals'
import {
aggregateGoalCoverage,
computeGoalCoverage,
computeGoalCoverageByScenario,
type MunicipalityGoalCoverage,
} from '@/utilities/goalCoverage'
import {
computeMunicipalityPotential,
computeStatewideGoalDecomposition,
type MunicipalityPotential,
} from '@/utilities/municipalityPotential'
import {
DEFAULT_VOTE_ESTIMATE_SCENARIO,
VOTE_ESTIMATE_SCENARIOS,
type VoteEstimateScenarioFields,
type VoteEstimateScenario,
} from '@/utilities/voteEstimate'
import {
emptyMunicipalityPledgeAggregate,
type MunicipalityPledgeAggregate,
} from '@/utilities/votePledgeData'
type GoalAccountReader = CampaignUser | User
/**
* E8 "conta da cadeira" — request-scoped statewide goal decomposition
* (`campaignGoals` global + `computeStatewideGoalDecomposition`), degrau 1 da
* caching ladder. Every surface (dashboard, list overview, list rows,
* detail card) needs the SAME decomposition — `suggestedGoal` for one
* município is only meaningful relative to the full 435-slug ceiling total,
* so this always runs over the whole catalog, never a filtered scope, and is
* `cache()`-deduplicated so dashboard + list + detail don't each recompute it
* on requests that touch more than one.
*/
const loadGoalDecompositionCached = cache(
async (payload: Payload, user: GoalAccountReader) => {
const goals = await loadCampaignGoals(payload, user)
return computeStatewideGoalDecomposition(goals)
},
)
export type MunicipalityGoalCoverageBundle = {
coverageByMunicipalityID: Map<number, Record<VoteEstimateScenario, MunicipalityGoalCoverage>>
/** Sum of every municipality's coverage in `municipalities`, per scenario (dashboard/overview metric). */
aggregateByScenario: Record<VoteEstimateScenario, MunicipalityGoalCoverage>
}
/**
* Per-scenario goal coverage for a scope of municipalities (dashboard total,
* or the list's filtered overview) plus the per-municipality breakdown the
* list rows pick a scenario from client-side — mirrors
* `rollupMunicipalityStaffVotes`'s "compute all 3 scenarios server-side, pick
* client-side" shape.
*/
export const loadMunicipalityGoalCoverageBundle = async (
payload: Payload,
user: GoalAccountReader,
municipalities: ReadonlyArray<{
id: number
slug: string
expectedVotes?: VoteEstimateScenarioFields | null
}>,
pledgeAggregates: Map<number, MunicipalityPledgeAggregate>,
): Promise<MunicipalityGoalCoverageBundle> => {
const { suggestedGoalBySlug } = await loadGoalDecompositionCached(payload, user)
const coverageByMunicipalityID = new Map<
number,
Record<VoteEstimateScenario, MunicipalityGoalCoverage>
>()
for (const municipality of municipalities) {
const pledgeAggregate =
pledgeAggregates.get(municipality.id) ?? emptyMunicipalityPledgeAggregate
const suggestedGoal = suggestedGoalBySlug.get(municipality.slug) ?? 0
coverageByMunicipalityID.set(
municipality.id,
computeGoalCoverageByScenario(municipality.expectedVotes, suggestedGoal, pledgeAggregate),
)
}
const coverages = [...coverageByMunicipalityID.values()]
const aggregateByScenario = {} as Record<VoteEstimateScenario, MunicipalityGoalCoverage>
for (const scenario of VOTE_ESTIMATE_SCENARIOS) {
aggregateByScenario[scenario] = aggregateGoalCoverage(
coverages.map((coverageByScenario) => coverageByScenario[scenario]),
)
}
return { coverageByMunicipalityID, aggregateByScenario }
}
export type MunicipalityGoalAccount = {
suggestedGoal: number
/** Fixed to the `central` scenario — the detail page has no scenario selector. */
goalCoverage: MunicipalityGoalCoverage
potential: MunicipalityPotential
}
/**
* One município's full "conta da cadeira": suggested goal, coverage (central
* scenario), and the diagnostic potential block (teto do campo, captura,
* share intracampo, roll-off) — the detail page's "Conta da cadeira" card.
*/
export const loadMunicipalityGoalAccount = async (
payload: Payload,
user: GoalAccountReader,
municipality: { slug: string; expectedVotes?: VoteEstimateScenarioFields | null },
pledgeAggregate: MunicipalityPledgeAggregate,
): Promise<MunicipalityGoalAccount> => {
const { suggestedGoalBySlug, potentialBySlug } = await loadGoalDecompositionCached(payload, user)
const suggestedGoal = suggestedGoalBySlug.get(municipality.slug) ?? 0
const goalCoverage = computeGoalCoverage(
municipality.expectedVotes,
suggestedGoal,
pledgeAggregate,
DEFAULT_VOTE_ESTIMATE_SCENARIO,
)
const potential =
potentialBySlug.get(municipality.slug) ?? computeMunicipalityPotential(municipality.slug)
return { suggestedGoal, goalCoverage, potential }
}

View file

@ -10,6 +10,8 @@ import {
import type { CampaignUser, Municipality } from '@/payload-types'
import { isCampaignLeader, isCampaignStaff } from '@/utilities/campaignAccess'
import { loadMunicipalityScope } from '@/utilities/campaignMunicipalityScope'
import type { MunicipalityGoalCoverage } from '@/utilities/goalCoverage'
import { loadMunicipalityGoalCoverageBundle } from '@/utilities/municipalityGoalAccount'
import {
buildMunicipalityListWhere,
municipalityPageSize,
@ -48,6 +50,8 @@ export type MunicipalityListOverviewData = {
pledgeCount: number
missingEstimateCount: number
withAdvisorCount: number
/** E8 "conta da cadeira" — meta × comprometido do escopo filtrado, por cenário. */
goalCoverageByScenario: Record<VoteEstimateScenario, MunicipalityGoalCoverage>
}
export type MunicipalityListPageBundle = {
@ -171,10 +175,21 @@ export const loadMunicipalityListPageBundle = async (
let overview: MunicipalityListOverviewData | null = null
let pledgeAggregates = new Map<number, MunicipalityPledgeAggregate>()
let goalCoverageByMunicipalityID = new Map<
number,
Record<VoteEstimateScenario, MunicipalityGoalCoverage>
>()
if (staffScope && staffScope.municipalities.length > 0) {
pledgeAggregates = staffScope.pledgeAggregates
const rollup = rollupMunicipalityStaffVotes(staffScope.municipalities, pledgeAggregates)
const goalCoverageBundle = await loadMunicipalityGoalCoverageBundle(
payload,
user,
staffScope.municipalities,
pledgeAggregates,
)
goalCoverageByMunicipalityID = goalCoverageBundle.coverageByMunicipalityID
overview = {
municipalityCount: staffScope.municipalities.length,
staffVoteTotalByScenario: { ...rollup.staffVoteTotalByScenario },
@ -183,6 +198,7 @@ export const loadMunicipalityListPageBundle = async (
withAdvisorCount: staffScope.municipalities.filter(
(municipality) => (municipality.advisors ?? []).length > 0,
).length,
goalCoverageByScenario: goalCoverageBundle.aggregateByScenario,
}
}
@ -214,6 +230,7 @@ export const loadMunicipalityListPageBundle = async (
municipality,
isStaff ? pledgeAggregates.get(municipality.id) : undefined,
ranks.get(municipality.slug) ?? null,
isStaff ? goalCoverageByMunicipalityID.get(municipality.id) : undefined,
),
),
totalDocs,

View file

@ -0,0 +1,254 @@
import {
getMunicipalityFederalBaseline,
type MunicipalityFederalBaseline,
} from '@/lib/bahiaElectionAggregates'
import {
ELECTION_YEAR_2014,
ELECTION_YEAR_2018,
ELECTION_YEAR_2022,
HISTORICAL_SERIES_YEARS,
} from '@/lib/electionResults'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import type { BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import type { CampaignGoal } from '@/payload-types'
/**
* E8 "conta da cadeira" — derived electoral potential per municipality, built
* entirely from the committed `bahia-federal-baseline.json` artifact (no DB
* reads: pure functions over `getMunicipalityFederalBaseline`). Every
* projection here is a documented, fixed formula — never a fitted model (see
* the E8 plan's "rabbit holes vigiados": projection turning into a model).
*/
/**
* Projected 2026 valid votes: weighted average of 2014/2018/2022 federal T1
* valid votes, weighting 2022 double (closer election, more predictive of
* turnout composition). Documented as a fixed formula, not a forecast:
* `(v2014 + v2018 + 2×v2022) / 4`.
*/
export const projectedValidVotes = (baseline: MunicipalityFederalBaseline): number => {
const v2014 = baseline.validVotesByYear[String(ELECTION_YEAR_2014)] ?? 0
const v2018 = baseline.validVotesByYear[String(ELECTION_YEAR_2018)] ?? 0
const v2022 = baseline.validVotesByYear[String(ELECTION_YEAR_2022)] ?? 0
return (v2014 + v2018 + 2 * v2022) / 4
}
/**
* "Teto do campo" (field ceiling): 2022 T1 votes for the presidential
* candidate of the field (#13) — a majoritarian race attracts far more of
* the field's total vote than the fragmented federal-deputy quotient race,
* so it stands in for "how many votes could the field plausibly get here".
* Primary reading only (governador is a secondary cross-check surfaced by the
* artifact, not blended in — assumption flagged for product validation in
* the E8 plan). 2022-only: no majoritarian tally is seeded for 2014/2018.
*/
export const fieldCeiling = (baseline: MunicipalityFederalBaseline): number =>
baseline.majoritarian2022?.president.votes ?? 0
/**
* `fieldCeiling` scaled by the same turnout-growth factor used for
* `projectedValidVotes` (projected ÷ observed 2022 valid votes), since the
* ceiling itself has only one observed data point (2022). Falls back to the
* unscaled ceiling when 2022 valid votes are unknown (avoids a divide-by-zero
* blow-up rather than silently zeroing the projection).
*/
export const projectedFieldCeiling = (baseline: MunicipalityFederalBaseline): number => {
const ceiling2022 = fieldCeiling(baseline)
const validVotes2022 = baseline.validVotesByYear[String(ELECTION_YEAR_2022)] ?? 0
if (validVotes2022 <= 0) return ceiling2022
return ceiling2022 * (projectedValidVotes(baseline) / validVotes2022)
}
/**
* Capture rate (diagnostic only, not a decomposition input): share of the
* 2022 field ceiling that Solla's own candidacy actually captured. `null`
* when the ceiling is unknown/zero rather than a misleading 0.
*/
export const captureRate = (baseline: MunicipalityFederalBaseline): number | null => {
const ceiling = fieldCeiling(baseline)
if (ceiling <= 0) return null
const votes2022 = baseline.votesByYear[String(ELECTION_YEAR_2022)] ?? 0
return votes2022 / ceiling
}
/**
* Intra-field share (diagnostic only): of the curated field's federal-deputy
* nominal votes (`campoFederalVotesByYear`, `campoParties.ts`) in a given
* year, what share went to Solla specifically. Available for all three
* historical years (unlike `fieldCeiling`, which is majoritarian and
* 2022-only). `null` when the field polled zero that year.
*/
export const intraFieldShare = (baseline: MunicipalityFederalBaseline, year: number): number | null => {
const campo = baseline.campoFederalVotesByYear[String(year)] ?? 0
if (campo <= 0) return null
const votes = baseline.votesByYear[String(year)] ?? 0
return votes / campo
}
export type RollOff = {
/** (brancos+nulos DF) − (brancos+nulos majoritária), in votes. */
votes: number
/** `votes` as a share of the federal-deputy T1 turnout, or null if turnout is unknown. */
percentOfTurnout: number | null
}
/**
* Roll-off (diagnostic only): the DF race always shows more blank/null votes
* than the majoritarian race in the same turnout — this is the gap, in votes
* and as a share of comparecimento. 2022-only (no majoritarian tally for
* 2014/2018); `null` when either tally is missing for the slug.
*/
export const rollOff = (baseline: MunicipalityFederalBaseline): RollOff | null => {
const majoritarian = baseline.majoritarian2022?.president
const federalTally = baseline.federalTallyByYear[String(ELECTION_YEAR_2022)]
if (!majoritarian || !federalTally) return null
const dfBlankNull = federalTally.votosBranco + federalTally.votosNulo
const majoritarianBlankNull = majoritarian.votosBranco + majoritarian.votosNulo
const votes = dfBlankNull - majoritarianBlankNull
const percentOfTurnout = federalTally.comparecimento > 0 ? votes / federalTally.comparecimento : null
return { votes, percentOfTurnout }
}
export type MunicipalityPotential = {
slug: string
projectedValidVotes: number
fieldCeiling2022: number
projectedFieldCeiling: number
captureRate2022: number | null
intraFieldShareByYear: Record<number, number | null>
rollOff2022: RollOff | null
}
/** Assembles every derived metric above for one catalog municipality slug. */
export const computeMunicipalityPotential = (slug: string): MunicipalityPotential => {
const baseline = getMunicipalityFederalBaseline(slug)
const intraFieldShareByYear: Record<number, number | null> = {}
for (const year of HISTORICAL_SERIES_YEARS) {
intraFieldShareByYear[year] = intraFieldShare(baseline, year)
}
return {
slug,
projectedValidVotes: projectedValidVotes(baseline),
fieldCeiling2022: fieldCeiling(baseline),
projectedFieldCeiling: projectedFieldCeiling(baseline),
captureRate2022: captureRate(baseline),
intraFieldShareByYear,
rollOff2022: rollOff(baseline),
}
}
/** `computeMunicipalityPotential` for every catalog municipality (435 slugs). */
export const computeAllMunicipalityPotentials = (): MunicipalityPotential[] =>
municipalityCatalog.map((entry) => computeMunicipalityPotential(entry.slug))
/**
* Decomposes the state goal proportionally to each municipality's projected
* field ceiling — `suggestedGoal_i = stateGoal × projectedFieldCeiling_i / Σ
* projectedFieldCeiling` — NOT a composite formula folding in capture rate or
* intra-field share (those stay diagnostic columns; see the E8 plan's
* "rabbit holes vigiados"). `margin` on the `campaignGoals` global is
* informational for the table (a safety reading for the coordination), it
* does not change the decomposed value.
*
* Falls back to an even split when every ceiling is zero/unknown (e.g. an
* empty potentials list slice) rather than producing `NaN` suggested goals.
*/
export const decomposeStateGoal = (
potentials: ReadonlyArray<Pick<MunicipalityPotential, 'slug' | 'projectedFieldCeiling'>>,
goals: Pick<CampaignGoal, 'stateGoal'>,
): Map<string, number> => {
const stateGoal = goals.stateGoal
const totalCeiling = potentials.reduce((sum, entry) => sum + entry.projectedFieldCeiling, 0)
const suggestedGoalBySlug = new Map<string, number>()
if (totalCeiling <= 0) {
const even = potentials.length > 0 ? stateGoal / potentials.length : 0
for (const entry of potentials) suggestedGoalBySlug.set(entry.slug, even)
return suggestedGoalBySlug
}
for (const entry of potentials) {
suggestedGoalBySlug.set(entry.slug, stateGoal * (entry.projectedFieldCeiling / totalCeiling))
}
return suggestedGoalBySlug
}
export type TerritorySanityWarning = {
region: BahiaIdentityTerritory
suggestedGoalTotal: number
projectedValidVotesTotal: number
/** suggestedGoalTotal / projectedValidVotesTotal — > 1 means the decomposed goal exceeds the projected electorate. */
ratio: number
}
/**
* Sanity check (never blocking, per the plan): flags identity territories
* (TI) where the decomposed suggested goals, summed, exceed the projected
* valid-vote electorate of that territory — a signal that the decomposition
* needs a human look (state goal too high, or ceilings skewed), never a
* thrown error. Callers surface `warnAboveRatio` as an `CampaignInfoHint`,
* not a validation failure.
*/
export const sanityCheckSuggestedGoalsByTerritory = (
entries: ReadonlyArray<{
region: BahiaIdentityTerritory
suggestedGoal: number
projectedValidVotes: number
}>,
{ warnAboveRatio = 1 }: { warnAboveRatio?: number } = {},
): TerritorySanityWarning[] => {
const totalsByRegion = new Map<
BahiaIdentityTerritory,
{ suggestedGoalTotal: number; projectedValidVotesTotal: number }
>()
for (const entry of entries) {
const current = totalsByRegion.get(entry.region) ?? {
suggestedGoalTotal: 0,
projectedValidVotesTotal: 0,
}
current.suggestedGoalTotal += entry.suggestedGoal
current.projectedValidVotesTotal += entry.projectedValidVotes
totalsByRegion.set(entry.region, current)
}
const warnings: TerritorySanityWarning[] = []
for (const [region, totals] of totalsByRegion) {
if (totals.projectedValidVotesTotal <= 0) continue
const ratio = totals.suggestedGoalTotal / totals.projectedValidVotesTotal
if (ratio > warnAboveRatio) {
warnings.push({ region, ...totals, ratio })
}
}
return warnings
}
/**
* Convenience end-to-end: computes every municipality's potential and
* decomposes the state goal — the single entry point the dashboard/list/
* detail loaders need (`municipalityGoalAccount.ts`), keyed by slug for O(1)
* per-municipality lookup instead of a linear scan.
*
* Deliberately does NOT run `sanityCheckSuggestedGoalsByTerritory` here: no
* caller surfaces `TerritorySanityWarning`s yet (deferred to E12's TI layer
* — see the E8 plan), so computing them on every dashboard/list/detail
* request would be pure wasted work. Callers that need the check once E12
* lands can build the same `{ region, suggestedGoal, projectedValidVotes }`
* entries from `potentialBySlug` + `municipalityCatalog` and call it
* directly — it stays exported and unit-tested for that.
*/
export const computeStatewideGoalDecomposition = (
goals: Pick<CampaignGoal, 'stateGoal'>,
): {
potentialBySlug: Map<string, MunicipalityPotential>
suggestedGoalBySlug: Map<string, number>
} => {
const potentials = computeAllMunicipalityPotentials()
const suggestedGoalBySlug = decomposeStateGoal(potentials, goals)
const potentialBySlug = new Map(potentials.map((potential) => [potential.slug, potential]))
return { potentialBySlug, suggestedGoalBySlug }
}

View file

@ -3,11 +3,13 @@ import type { Payload } from 'payload'
import type { MunicipalityVoteRankEntry } from '@/lib/municipalityVoteRank'
import type { CampaignUser, Municipality } from '@/payload-types'
import { eligibleCampaignStaffWhere } from '@/utilities/campaignAccess'
import { createEmptyGoalCoverageByScenario, type MunicipalityGoalCoverage } from '@/utilities/goalCoverage'
import type { PoliticalTrendStatus } from '@/utilities/municipalityUi'
import { relationshipId } from '@/utilities/relationship'
import type { StateDeputySummary } from '@/utilities/stateDeputyData'
import {
toVoteEstimateScenarioViewModel,
type VoteEstimateScenario,
type VoteEstimateScenarioViewModel,
} from '@/utilities/voteEstimate'
import type { MunicipalityPledgeAggregate } from '@/utilities/votePledgeData'
@ -55,12 +57,15 @@ export type MunicipalityListViewModel = {
politicalTrendNote: string | null
pledges: MunicipalityPledgeAggregate
votePosition2022: MunicipalityVoteRankEntry | null
/** E8 "conta da cadeira" — meta × comprometido por cenário; null fora da staff view. */
goalCoverageByScenario: Record<VoteEstimateScenario, MunicipalityGoalCoverage>
}
export const toMunicipalityListViewModel = (
municipality: Municipality,
pledges: MunicipalityPledgeAggregate | undefined,
votePosition2022: MunicipalityListViewModel['votePosition2022'],
goalCoverageByScenario?: Record<VoteEstimateScenario, MunicipalityGoalCoverage>,
): MunicipalityListViewModel => ({
id: municipality.id,
name: municipality.name,
@ -78,6 +83,7 @@ export const toMunicipalityListViewModel = (
politicalTrendNote: municipality.politicalTrend?.note ?? null,
pledges: pledges ?? createEmptyMunicipalityPledgeAggregate(),
votePosition2022,
goalCoverageByScenario: goalCoverageByScenario ?? createEmptyGoalCoverageByScenario(),
})
export type MunicipalityAdvisorSummary = {

View file

@ -100,7 +100,15 @@ test.describe('Municípios — jornadas por papel', () => {
await expect(page.getByText('Votos estimados atualizados.')).toBeVisible()
await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}`)
await expect(page.getByText('5.000')).toBeVisible()
// "Conta da cadeira" (E8) shows the saved scenario values through
// `MunicipalityListExpectedVotesControl` — a compact edit-in-place
// control whose only always-visible digits are the active scenario
// (`central`, "3.000" here). The full pessimistic/central/optimistic
// trio only becomes plain page text via its screen-reader summary
// (visually a hover-only preview otherwise), so assert against that
// unique, unambiguous string rather than a bare "5.000" substring —
// that also matches the (opacity-0 but DOM-visible) hover-preview span.
await expect(page.getByText('Otimista: 5.000')).toBeVisible()
await expect(page.getByText(`Assessoria: ${advisor.name}`)).toBeVisible()
})

View file

@ -0,0 +1,111 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import config from '@/payload.config'
import { loadCampaignGoals } from '@/utilities/campaignGoals'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
/**
* Pins the `campaignGoals` global access matrix (E8): a `campaignUser` JWT
* reaches `/api/globals/*` directly, so — like the election-data collections —
* the deny for leaders and anonymous requests has to be explicit rather than
* inherited from Payload's "any authenticated user" default.
*/
describe('campaignGoals global access', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('denies anonymous read', async () => {
await expect(
payload.findGlobal({ slug: 'campaignGoals', overrideAccess: false }),
).rejects.toThrow(/permissão|not allowed/i)
})
it('denies a leader read', async () => {
const fixtures = campaignFixtures()
const leader = await fixtures.createCampaignUser('leader')
await expect(
payload.findGlobal({
slug: 'campaignGoals',
user: leader,
overrideAccess: false,
}),
).rejects.toThrow(/permissão|not allowed/i)
})
it('lets an advisor read but not write', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
await expect(
payload.findGlobal({ slug: 'campaignGoals', user: advisor, overrideAccess: false }),
).resolves.toMatchObject({ stateGoal: expect.any(Number) })
await expect(
payload.updateGlobal({
slug: 'campaignGoals',
data: { stateGoal: 160_000 },
user: advisor,
overrideAccess: false,
}),
).rejects.toThrow(/permissão|not allowed/i)
})
it.each(['coordinator', 'candidate'] as const)(
'lets a %s read and write',
async (role) => {
const fixtures = campaignFixtures()
const staff = await fixtures.createCampaignUser(role)
const updated = await payload.updateGlobal({
slug: 'campaignGoals',
data: { stateGoal: 155_000, margin: 5 },
user: staff,
overrideAccess: false,
})
expect(updated.stateGoal).toBe(155_000)
await expect(
payload.findGlobal({ slug: 'campaignGoals', user: staff, overrideAccess: false }),
).resolves.toMatchObject({ stateGoal: 155_000, margin: 5 })
},
)
it('lets a payload admin read and write', async () => {
const fixtures = campaignFixtures()
const admin = await fixtures.createAdminUser()
const updated = await payload.updateGlobal({
slug: 'campaignGoals',
data: { stateGoal: 150_000 },
user: admin,
overrideAccess: false,
})
expect(updated.stateGoal).toBe(150_000)
})
it('loadCampaignGoals resolves for staff and denies a leader', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const leader = await fixtures.createCampaignUser('leader')
await expect(loadCampaignGoals(payload, coordinator)).resolves.toMatchObject({
stateGoal: expect.any(Number),
})
await expect(loadCampaignGoals(payload, leader)).rejects.toThrow(/permissão|not allowed/i)
})
})

View file

@ -1,3 +1,6 @@
import { statSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
@ -5,9 +8,14 @@ import {
federalBaselineMunicipalitySlugs,
getMunicipalityFederalBaseline,
} from '@/lib/bahiaElectionAggregates'
import { BASELINE_TICKET_2022, HISTORICAL_SERIES_YEARS } from '@/lib/electionResults'
import { BASELINE_TICKET_2022, ELECTION_YEAR_2022, HISTORICAL_SERIES_YEARS } from '@/lib/electionResults'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
const ARTIFACT_PATH = join(
process.cwd(),
'src/lib/electionAggregates/bahia-federal-baseline.json',
)
/**
* Pins the committed election-aggregate artifact to the municipality catalog.
* If either changes shape (new remodel, new TSE scope), regenerate with
@ -54,5 +62,58 @@ describe('election aggregates artifact', () => {
const baseline = getMunicipalityFederalBaseline('nao-existe')
expect(baseline.votesByYear).toEqual({})
expect(baseline.validVotesByYear).toEqual({})
expect(baseline.campoFederalVotesByYear).toEqual({})
expect(baseline.federalTallyByYear).toEqual({})
expect(baseline.majoritarian2022).toBeUndefined()
})
it('has campo federal votes + federal tally for every historical year (v2 fields)', () => {
for (const year of HISTORICAL_SERIES_YEARS) {
let campoTotal = 0
let tallyComparecimentoTotal = 0
for (const slug of federalBaselineMunicipalitySlugs()) {
const baseline = getMunicipalityFederalBaseline(slug)
const campo = baseline.campoFederalVotesByYear[String(year)]
const tally = baseline.federalTallyByYear[String(year)]
expect(campo).toBeTypeOf('number')
expect(campo).toBeGreaterThanOrEqual(0)
expect(tally).toBeDefined()
expect(tally.comparecimento).toBeGreaterThanOrEqual(0)
expect(tally.votosValidos).toBeGreaterThanOrEqual(0)
campoTotal += campo ?? 0
tallyComparecimentoTotal += tally?.comparecimento ?? 0
}
// The campo (PT + core-left partners) always outpolls Solla alone, and
// turnout always dwarfs any single candidate's nominal votes.
expect(campoTotal).toBeGreaterThan(10_000)
expect(tallyComparecimentoTotal).toBeGreaterThan(campoTotal)
}
})
it('has 2022 majoritarian (presidente/governador #13) votes+tally for every municipality', () => {
let presidentTotal = 0
for (const slug of federalBaselineMunicipalitySlugs()) {
const baseline = getMunicipalityFederalBaseline(slug)
const majoritarian = baseline.majoritarian2022
expect(majoritarian).toBeDefined()
if (!majoritarian) continue
expect(majoritarian.president.votes).toBeGreaterThanOrEqual(0)
expect(majoritarian.governor.votes).toBeGreaterThanOrEqual(0)
expect(majoritarian.president.comparecimento).toBeGreaterThanOrEqual(majoritarian.president.votes)
presidentTotal += majoritarian.president.votes
}
expect(presidentTotal).toBeGreaterThan(10_000)
// Sanity: the 2022 majoritarian slice must not silently regress to a
// different election year (only 2022 is seeded/expected here).
expect(HISTORICAL_SERIES_YEARS).toContain(ELECTION_YEAR_2022)
})
it('stays under the committed artifact byte budget', () => {
const { size } = statSync(ARTIFACT_PATH)
// v1 (votes + valid votes only) was ~96 KB; v2 adds campo votes, federal
// tally and the 2022 majoritarian slice, measured at ~522 KB — budget
// with headroom, not a precise pin, so future fields don't require
// touching this test.
expect(size).toBeLessThan(700 * 1024)
})
})

View file

@ -14,11 +14,13 @@ import { Progress } from '@/components/ui/Progress'
import { Toggle } from '@/components/ui/Toggle'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { stub } from '../helpers/stub'
import { createEmptyGoalCoverageByScenario } from '@/utilities/goalCoverage'
import type { MunicipalityAdvisorSummary, MunicipalityListViewModel } from '@/utilities/municipalityViewModels'
import { toVoteEstimateScenarioViewModel } from '@/utilities/voteEstimate'
import { createEmptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeData'
import { stub } from '../helpers/stub'
vi.mock('next/navigation', () => ({
useRouter: () => ({
replace: vi.fn(),
@ -171,6 +173,11 @@ describe('campaign visual foundation', () => {
share: 0.031,
totalUnits: 435,
},
goalCoverageByScenario: {
pessimistic: { goal: 1500, committed: 1200, coverageRatio: 0.8, deficit: 300 },
central: { goal: 1500, committed: 1200, coverageRatio: 0.8, deficit: 300 },
optimistic: { goal: 1500, committed: 1200, coverageRatio: 0.8, deficit: 300 },
},
},
{
id: 2,
@ -189,6 +196,7 @@ describe('campaign visual foundation', () => {
politicalTrendNote: null,
pledges: createEmptyMunicipalityPledgeAggregate(),
votePosition2022: null,
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
},
]
@ -210,6 +218,9 @@ describe('campaign visual foundation', () => {
expect(html).toContain('Coberta')
expect(html).toContain('Sem assessor')
expect(html).toContain('Tendência')
expect(html).toContain('Cobertura da meta')
expect(html).toContain('80%')
expect(html).toContain('Faltam 300 votos para a meta')
})
it('hides leadership coverage subline when pledges only have declared votes', () => {
@ -238,6 +249,7 @@ describe('campaign visual foundation', () => {
missingEstimateCount: 1,
},
votePosition2022: null,
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
},
],
advisorNamesById: new Map<number, MunicipalityAdvisorSummary>(),
@ -272,6 +284,7 @@ describe('campaign visual foundation', () => {
politicalTrendNote: null,
pledges: createEmptyMunicipalityPledgeAggregate(),
votePosition2022: null,
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
},
],
advisorNamesById: new Map<number, MunicipalityAdvisorSummary>(),

View file

@ -0,0 +1,71 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
CAMPO_ELECTION_YEARS,
campoPartiesForYear,
isCampoElectionYear,
isCampoParty,
} from '@/lib/campoParties'
import { partySpectrum } from '@/lib/electionPartySpectrum'
/**
* Guards against `campoParties.ts` drifting into a second, unvalidated party
* classifier: every curated "campo" party must also land in the `esquerda`
* bucket of `electionPartySpectrum.ts` (the Bolognesi et al. expert survey).
* The reverse is not required — see the file header for sourcing per year.
*/
describe('campoParties', () => {
it('every curated party is in the esquerda spectrum bucket, for every year', () => {
for (const year of CAMPO_ELECTION_YEARS) {
for (const party of campoPartiesForYear(year)) {
expect(partySpectrum(party)).toBe('esquerda')
}
}
})
it('covers 2014, 2018, 2022 and 2026', () => {
expect(new Set(CAMPO_ELECTION_YEARS)).toEqual(new Set([2014, 2018, 2022, 2026]))
})
it('includes PT and PC do B (PCDOB) across every curated year', () => {
for (const year of CAMPO_ELECTION_YEARS) {
expect(isCampoParty('PT', year)).toBe(true)
expect(isCampoParty('PC do B', year)).toBe(true)
}
})
it('resolves 2022/2026 to the FE Brasil federation (PT/PC do B/PV)', () => {
for (const year of [2022, 2026] as const) {
expect(new Set(campoPartiesForYear(year))).toEqual(new Set(['PT', 'PCDOB', 'PV']))
}
})
it('excludes opportunistic centrão allies that shared the same raw TSE coalition', () => {
// Solla's own 2014 coalition ("MAIS MUDANÇAS, NOVAS CONQUISTAS") and 2018
// coalition ("FRENTE DO TRABALHO POR TODA BAHIA") also included these
// parties — deliberately excluded because they are not in the political
// field (confirmed direita/centro in electionPartySpectrum).
expect(isCampoParty('PP', 2014)).toBe(false)
expect(isCampoParty('PR', 2014)).toBe(false)
expect(isCampoParty('PSD', 2014)).toBe(false)
expect(isCampoParty('PTB', 2014)).toBe(false)
expect(isCampoParty('PODE', 2018)).toBe(false)
expect(isCampoParty('PP', 2018)).toBe(false)
expect(isCampoParty('PR', 2018)).toBe(false)
expect(isCampoParty('PSD', 2018)).toBe(false)
})
it('excludes PSB from the 2022 federal field (ran its own deputado-federal slate)', () => {
expect(isCampoParty('PSB', 2022)).toBe(false)
})
it('normalizes siglas and fails closed on unknown years/parties', () => {
expect(isCampoParty('pt', 2022)).toBe(true)
expect(isCampoParty(null, 2022)).toBe(false)
expect(isCampoParty('PT', 2015)).toBe(false)
expect(isCampoElectionYear(2015)).toBe(false)
expect(campoPartiesForYear(2015)).toEqual([])
})
})

View file

@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest'
import {
aggregateGoalCoverage,
computeGoalCoverage,
computeGoalCoverageByScenario,
} from '@/utilities/goalCoverage'
import { createEmptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeData'
/**
* Fixes the meta×comprometido semantics locked in the E8 plan audit: a
* municipality can have `expectedVotes` filled by the E4R projection-sheet
* import with ZERO pledges behind it — coverage must report that gap, not
* 100% (which is what `resolveMunicipalityStaffVoteTotal`'s override
* behavior would give if reused here).
*/
describe('computeGoalCoverage', () => {
it('meta = expectedVotes[cenário] when set; comprometido = pledge aggregate only (never the override)', () => {
// Feira de Santana-style case: mesa expects 3000 (E4R sheet, cenário média),
// but only 1200 is backed by declared/estimated pledges.
const aggregate = createEmptyMunicipalityPledgeAggregate()
aggregate.effectiveByScenario.central = 1200
const coverage = computeGoalCoverage({ central: 3000 }, 5000, aggregate, 'central')
expect(coverage.goal).toBe(3000) // NOT 100% — expectedVotes wins over suggestedGoal, not over pledges
expect(coverage.committed).toBe(1200)
expect(coverage.coverageRatio).toBeCloseTo(0.4, 6)
expect(coverage.deficit).toBe(1800)
})
it('falls back to suggestedGoal when expectedVotes is unset for that scenario', () => {
const aggregate = createEmptyMunicipalityPledgeAggregate()
aggregate.effectiveByScenario.central = 400
const coverage = computeGoalCoverage(null, 1000, aggregate, 'central')
expect(coverage.goal).toBe(1000)
expect(coverage.committed).toBe(400)
expect(coverage.coverageRatio).toBeCloseTo(0.4, 6)
})
it('coverageRatio is null (not Infinity/NaN) when goal is zero', () => {
const aggregate = createEmptyMunicipalityPledgeAggregate()
const coverage = computeGoalCoverage(null, 0, aggregate, 'central')
expect(coverage.coverageRatio).toBeNull()
expect(coverage.deficit).toBe(0)
})
it('deficit is negative when the municipality is over-committed relative to its goal', () => {
const aggregate = createEmptyMunicipalityPledgeAggregate()
aggregate.effectiveByScenario.central = 1500
const coverage = computeGoalCoverage(null, 1000, aggregate, 'central')
expect(coverage.deficit).toBe(-500)
expect(coverage.coverageRatio).toBeCloseTo(1.5, 6)
})
it('reads each scenario independently (mirrors staffVoteTotalByScenario)', () => {
const aggregate = createEmptyMunicipalityPledgeAggregate()
aggregate.effectiveByScenario.pessimistic = 100
aggregate.effectiveByScenario.central = 200
aggregate.effectiveByScenario.optimistic = 300
const byScenario = computeGoalCoverageByScenario(
{ pessimistic: 500, optimistic: 900 },
600, // suggestedGoal fallback used only for 'central' (unset in expectedVotes)
aggregate,
)
expect(byScenario.pessimistic.goal).toBe(500)
expect(byScenario.pessimistic.committed).toBe(100)
expect(byScenario.central.goal).toBe(600) // falls back to suggestedGoal
expect(byScenario.central.committed).toBe(200)
expect(byScenario.optimistic.goal).toBe(900)
expect(byScenario.optimistic.committed).toBe(300)
})
})
describe('aggregateGoalCoverage', () => {
it('sums goal and committed across municipalities and recomputes the ratio', () => {
const aggregate = aggregateGoalCoverage([
{ goal: 3000, committed: 1200, coverageRatio: 0.4, deficit: 1800 },
{ goal: 1000, committed: 1000, coverageRatio: 1, deficit: 0 },
])
expect(aggregate.goal).toBe(4000)
expect(aggregate.committed).toBe(2200)
expect(aggregate.coverageRatio).toBeCloseTo(0.55, 6)
expect(aggregate.deficit).toBe(1800)
})
it('returns coverageRatio null for an empty list (zero goal)', () => {
expect(aggregateGoalCoverage([]).coverageRatio).toBeNull()
})
})

View file

@ -0,0 +1,224 @@
import { describe, expect, it } from 'vitest'
import type { MunicipalityFederalBaseline } from '@/lib/bahiaElectionAggregates'
import type { BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import {
captureRate,
decomposeStateGoal,
fieldCeiling,
intraFieldShare,
projectedFieldCeiling,
projectedValidVotes,
rollOff,
sanityCheckSuggestedGoalsByTerritory,
type MunicipalityPotential,
} from '@/utilities/municipalityPotential'
const baseline = (overrides: Partial<MunicipalityFederalBaseline> = {}): MunicipalityFederalBaseline => ({
votesByYear: {},
validVotesByYear: {},
campoFederalVotesByYear: {},
federalTallyByYear: {},
...overrides,
})
describe('projectedValidVotes', () => {
it('weights 2022 double: (v2014 + v2018 + 2×v2022) / 4', () => {
const b = baseline({ validVotesByYear: { '2014': 1000, '2018': 2000, '2022': 3000 } })
// (1000 + 2000 + 2*3000) / 4 = 9000 / 4 = 2250
expect(projectedValidVotes(b)).toBe(2250)
})
it('treats missing years as zero', () => {
const b = baseline({ validVotesByYear: { '2022': 4000 } })
expect(projectedValidVotes(b)).toBe(2000)
})
})
describe('fieldCeiling / projectedFieldCeiling', () => {
it('reads the 2022 president #13 votes as the primary ceiling', () => {
const b = baseline({
majoritarian2022: {
president: { votes: 5000, comparecimento: 8000, votosValidos: 7500, votosBranco: 300, votosNulo: 200 },
governor: { votes: 5500, comparecimento: 8000, votosValidos: 7500, votosBranco: 300, votosNulo: 200 },
},
})
expect(fieldCeiling(b)).toBe(5000)
})
it('is 0 when no majoritarian data is seeded for the slug', () => {
expect(fieldCeiling(baseline())).toBe(0)
})
it('scales the ceiling by the projected/observed 2022 valid-vote ratio', () => {
const b = baseline({
validVotesByYear: { '2014': 4000, '2018': 6000, '2022': 8000 },
majoritarian2022: {
president: { votes: 5000, comparecimento: 9000, votosValidos: 8500, votosBranco: 300, votosNulo: 200 },
governor: { votes: 5200, comparecimento: 9000, votosValidos: 8500, votosBranco: 300, votosNulo: 200 },
},
})
// projected = (4000 + 6000 + 2*8000) / 4 = 6500; ratio = 6500/8000 = 0.8125
expect(projectedFieldCeiling(b)).toBeCloseTo(5000 * 0.8125, 6)
})
it('falls back to the unscaled ceiling when 2022 valid votes are unknown', () => {
const b = baseline({
majoritarian2022: {
president: { votes: 5000, comparecimento: 9000, votosValidos: 8500, votosBranco: 300, votosNulo: 200 },
governor: { votes: 5200, comparecimento: 9000, votosValidos: 8500, votosBranco: 300, votosNulo: 200 },
},
})
expect(projectedFieldCeiling(b)).toBe(5000)
})
})
describe('captureRate', () => {
it('is Solla 2022 votes over the 2022 field ceiling', () => {
const b = baseline({
votesByYear: { '2022': 1000 },
majoritarian2022: {
president: { votes: 5000, comparecimento: 9000, votosValidos: 8500, votosBranco: 300, votosNulo: 200 },
governor: { votes: 5200, comparecimento: 9000, votosValidos: 8500, votosBranco: 300, votosNulo: 200 },
},
})
expect(captureRate(b)).toBeCloseTo(0.2, 6)
})
it('is null when the ceiling is unknown', () => {
expect(captureRate(baseline({ votesByYear: { '2022': 1000 } }))).toBeNull()
})
})
describe('intraFieldShare', () => {
it('is Solla votes over the curated campo federal votes, per year', () => {
const b = baseline({
votesByYear: { '2018': 800 },
campoFederalVotesByYear: { '2018': 2000 },
})
expect(intraFieldShare(b, 2018)).toBeCloseTo(0.4, 6)
})
it('is null when the field polled zero that year', () => {
expect(intraFieldShare(baseline(), 2018)).toBeNull()
})
})
describe('rollOff', () => {
it('is (brancos+nulos DF) − (brancos+nulos majoritária), with percent of turnout', () => {
const b = baseline({
federalTallyByYear: {
'2022': { comparecimento: 10_000, votosValidos: 9000, votosBranco: 600, votosNulo: 400 },
},
majoritarian2022: {
president: { votes: 5000, comparecimento: 10_000, votosValidos: 9600, votosBranco: 250, votosNulo: 150 },
governor: { votes: 5200, comparecimento: 10_000, votosValidos: 9600, votosBranco: 250, votosNulo: 150 },
},
})
// DF blank+null = 1000; majoritarian blank+null = 400; roll-off = 600
const result = rollOff(b)
expect(result).not.toBeNull()
expect(result?.votes).toBe(600)
expect(result?.percentOfTurnout).toBeCloseTo(0.06, 6)
})
it('is null (2022-only diagnostic) when the majoritarian tally is missing', () => {
const b = baseline({
federalTallyByYear: {
'2022': { comparecimento: 10_000, votosValidos: 9000, votosBranco: 600, votosNulo: 400 },
},
})
expect(rollOff(b)).toBeNull()
})
it('is null for years without a seeded federal tally (2014/2018)', () => {
expect(rollOff(baseline())).toBeNull()
})
})
describe('decomposeStateGoal', () => {
it('splits the state goal proportionally to projectedFieldCeiling, summing back to it', () => {
const potentials = [
{ slug: 'a', projectedFieldCeiling: 1000 },
{ slug: 'b', projectedFieldCeiling: 3000 },
{ slug: 'c', projectedFieldCeiling: 6000 },
]
const suggested = decomposeStateGoal(potentials, { stateGoal: 150_000 })
expect(suggested.get('a')).toBeCloseTo(15_000, 6)
expect(suggested.get('b')).toBeCloseTo(45_000, 6)
expect(suggested.get('c')).toBeCloseTo(90_000, 6)
const total = [...suggested.values()].reduce((sum, value) => sum + value, 0)
expect(total).toBeCloseTo(150_000, 6)
})
it('falls back to an even split when every ceiling is zero (never NaN)', () => {
const potentials = [
{ slug: 'a', projectedFieldCeiling: 0 },
{ slug: 'b', projectedFieldCeiling: 0 },
]
const suggested = decomposeStateGoal(potentials, { stateGoal: 100_000 })
expect(suggested.get('a')).toBe(50_000)
expect(suggested.get('b')).toBe(50_000)
})
it('returns an empty map for an empty potentials list', () => {
expect(decomposeStateGoal([], { stateGoal: 150_000 }).size).toBe(0)
})
})
describe('sanityCheckSuggestedGoalsByTerritory', () => {
const region = 'Litoral Sul' as BahiaIdentityTerritory
const otherRegion = 'Chapada Diamantina' as BahiaIdentityTerritory
it('never blocks — always returns an array, even when nothing is out of range', () => {
const warnings = sanityCheckSuggestedGoalsByTerritory([
{ region, suggestedGoal: 5000, projectedValidVotes: 20_000 },
])
expect(warnings).toEqual([])
})
it('flags a territory whose decomposed goal total exceeds its projected electorate', () => {
const warnings = sanityCheckSuggestedGoalsByTerritory([
{ region, suggestedGoal: 15_000, projectedValidVotes: 10_000 },
{ region: otherRegion, suggestedGoal: 4_000, projectedValidVotes: 10_000 },
])
expect(warnings).toHaveLength(1)
expect(warnings[0]?.region).toBe(region)
expect(warnings[0]?.ratio).toBeCloseTo(1.5, 6)
})
it('ignores territories with no projected electorate (avoids a divide-by-zero false positive)', () => {
const warnings = sanityCheckSuggestedGoalsByTerritory([
{ region, suggestedGoal: 1000, projectedValidVotes: 0 },
])
expect(warnings).toEqual([])
})
})
describe('computeMunicipalityPotential / computeAllMunicipalityPotentials (real artifact)', () => {
it('returns a well-shaped potential for a real catalog slug and a zeroed one for an unknown slug', async () => {
const { computeMunicipalityPotential } = await import('@/utilities/municipalityPotential')
const { municipalityCatalog } = await import('@/lib/municipalityCatalog')
const realSlug = municipalityCatalog[0]!.slug
const real: MunicipalityPotential = computeMunicipalityPotential(realSlug)
expect(real.slug).toBe(realSlug)
expect(real.projectedValidVotes).toBeGreaterThanOrEqual(0)
expect(real.projectedFieldCeiling).toBeGreaterThanOrEqual(0)
const unknown = computeMunicipalityPotential('nao-existe')
expect(unknown.projectedValidVotes).toBe(0)
expect(unknown.fieldCeiling2022).toBe(0)
expect(unknown.rollOff2022).toBeNull()
})
it('computeAllMunicipalityPotentials covers exactly the municipality catalog', async () => {
const { computeAllMunicipalityPotentials } = await import('@/utilities/municipalityPotential')
const { municipalityCatalog } = await import('@/lib/municipalityCatalog')
const potentials = computeAllMunicipalityPotentials()
expect(potentials).toHaveLength(municipalityCatalog.length)
})
})