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:
parent
7ac099be42
commit
5a4a9033ed
37 changed files with 19829 additions and 80 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
130
src/components/campaign/MunicipalityGoalAccountCard.tsx
Normal file
130
src/components/campaign/MunicipalityGoalAccountCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
47
src/components/campaign/MunicipalityListGoalCoverageCell.tsx
Normal file
47
src/components/campaign/MunicipalityListGoalCoverageCell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
72
src/globals/CampaignGoals.ts
Normal file
72
src/globals/CampaignGoals.ts
Normal 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).',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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
61
src/lib/campoParties.ts
Normal 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
|
|
@ -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)) {
|
||||
|
|
|
|||
38
src/migrations/20260724_180000_add_campaign_goals_global.ts
Normal file
38
src/migrations/20260724_180000_add_campaign_goals_global.ts
Normal 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;
|
||||
`)
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
37
src/utilities/campaignGoals.ts
Normal file
37
src/utilities/campaignGoals.ts
Normal 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,
|
||||
}),
|
||||
)
|
||||
136
src/utilities/goalCoverage.ts
Normal file
136
src/utilities/goalCoverage.ts
Normal 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))}`
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
130
src/utilities/municipalityGoalAccount.ts
Normal file
130
src/utilities/municipalityGoalAccount.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
254
src/utilities/municipalityPotential.ts
Normal file
254
src/utilities/municipalityPotential.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
111
tests/int/campaignGoalsAccess.int.spec.ts
Normal file
111
tests/int/campaignGoalsAccess.int.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>(),
|
||||
|
|
|
|||
71
tests/unit/campoParties.unit.spec.ts
Normal file
71
tests/unit/campoParties.unit.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
93
tests/unit/goalCoverage.unit.spec.ts
Normal file
93
tests/unit/goalCoverage.unit.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
224
tests/unit/municipalityPotential.unit.spec.ts
Normal file
224
tests/unit/municipalityPotential.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue