From 5a4a9033ed67882c8f753fefccf120edbf5710df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Fri, 24 Jul 2026 19:29:35 -0300 Subject: [PATCH 1/3] =?UTF-8?q?E8:=20Conta=20da=20cadeira=20(metas=20deriv?= =?UTF-8?q?adas,=20potencial=20por=20munic=C3=ADpio,=20cobertura)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .cursor/rules/projects/nucleos-eleitorais.mdc | 2 + AGENTS.md | 4 +- docs/plans/conta-da-cadeira.md | 71 +- docs/roadmap.md | 30 +- scripts/build-election-aggregates.mjs | 85 +- .../campanha/(app)/municipios/[slug]/page.tsx | 61 +- src/components/campaign/CampaignDashboard.tsx | 11 + .../campaign/CampaignMetricStrip.tsx | 16 +- .../campaign/MunicipalityGoalAccountCard.tsx | 130 + src/components/campaign/MunicipalityList.tsx | 18 +- .../MunicipalityListGoalCoverageCell.tsx | 47 + .../campaign/MunicipalityListOverview.tsx | 12 + .../campaign/MunicipalityStrategyCard.tsx | 12 - src/globals/CampaignGoals.ts | 72 + src/lib/bahiaElectionAggregates.ts | 35 +- src/lib/campoParties.ts | 61 + .../bahia-federal-baseline.json | 17835 ++++++++++++++++ src/lib/electionResults.ts | 5 +- ...260724_180000_add_campaign_goals_global.ts | 38 + src/migrations/index.ts | 6 + src/payload-types.ts | 40 + src/payload.config.ts | 3 +- src/utilities/campaignDashboardData.ts | 12 + src/utilities/campaignGoals.ts | 37 + src/utilities/goalCoverage.ts | 136 + .../municipalityElectoralBaseline.ts | 136 +- src/utilities/municipalityGoalAccount.ts | 130 + src/utilities/municipalityPageData.ts | 17 + src/utilities/municipalityPotential.ts | 254 + src/utilities/municipalityViewModels.ts | 6 + tests/e2e/campaignMunicipalities.e2e.spec.ts | 10 +- tests/int/campaignGoalsAccess.int.spec.ts | 111 + .../unit/bahiaElectionAggregates.unit.spec.ts | 63 +- tests/unit/campaignComponents.unit.spec.ts | 15 +- tests/unit/campoParties.unit.spec.ts | 71 + tests/unit/goalCoverage.unit.spec.ts | 93 + tests/unit/municipalityPotential.unit.spec.ts | 224 + 37 files changed, 19829 insertions(+), 80 deletions(-) create mode 100644 src/components/campaign/MunicipalityGoalAccountCard.tsx create mode 100644 src/components/campaign/MunicipalityListGoalCoverageCell.tsx create mode 100644 src/globals/CampaignGoals.ts create mode 100644 src/lib/campoParties.ts create mode 100644 src/migrations/20260724_180000_add_campaign_goals_global.ts create mode 100644 src/utilities/campaignGoals.ts create mode 100644 src/utilities/goalCoverage.ts create mode 100644 src/utilities/municipalityGoalAccount.ts create mode 100644 src/utilities/municipalityPotential.ts create mode 100644 tests/int/campaignGoalsAccess.int.spec.ts create mode 100644 tests/unit/campoParties.unit.spec.ts create mode 100644 tests/unit/goalCoverage.unit.spec.ts create mode 100644 tests/unit/municipalityPotential.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 2cef8987..62adc182 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 6eb156c6..617c20c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `@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. diff --git a/docs/plans/conta-da-cadeira.md b/docs/plans/conta-da-cadeira.md index 0bf894df..1d6af04c 100644 --- a/docs/plans/conta-da-cadeira.md +++ b/docs/plans/conta-da-cadeira.md @@ -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
(série 2014/2018/2022)"] - Campo["campoParties.ts"] + Seed["electionTally + electionCandidateVote
(local, já seedado)"] + Campo["campoParties.ts
(curadoria ano → siglas)"] + Build["pnpm build:election-aggregates"] + Artifact["bahia-federal-baseline.json v2
(Solla, válidos, campo DF,
teto majoritário 2022, roll-off 2022)"] Goals["global campaignGoals"] Pot["computeMunicipalityPotential
(válidos proj., teto, captura, roll-off)"] - Cov["computeGoalCoverage
(Σ pledges ÷ meta)"] + Cov["computeGoalCoverage
(meta vs. comprometido)"] Pledges["aggregatePledgesByMunicipality
(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 diff --git a/docs/roadmap.md b/docs/roadmap.md index ea6914c0..cf237458 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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=`). 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. diff --git a/scripts/build-election-aggregates.mjs b/scripts/build-election-aggregates.mjs index 1000fe3d..e085eac8 100644 --- a/scripts/build-election-aggregates.mjs +++ b/scripts/build-election-aggregates.mjs @@ -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.', diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx index ed8ad070..1c640200 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx @@ -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' ? : null const pledges = await loadMunicipalityPledges(payload, user, view.id) - const pledgeCoverage = toMunicipalityPledgeCoverageView( - aggregateMunicipalityPledgesFromRows(pledges), - ) + const pledgeAggregate = aggregateMunicipalityPledgesFromRows(pledges) + const pledgeCoverage = toMunicipalityPledgeCoverageView(pledgeAggregate) return (
{zoneNeighborhoodsCard} {view.strategy ? ( - + <> + + + ) : null}
) } +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 ( + + ) +} + const MunicipalityTabFallback = () => (
-
-
Teto do campo (proj.)
-
{formatElectionNumber(Math.round(potential.projectedFieldCeiling))}
-
-
-
Captura (2022)
-
{formatRatioAsPercentLabel(potential.captureRate2022)}
-
-
-
Share intracampo ({latestIntraFieldShareYear})
-
{formatRatioAsPercentLabel(latestIntraFieldShare)}
-
-
-
Roll-off (2022)
-
{formatRollOff(potential.rollOff2022)}
-
+ + Teto do campo projeta quantos votos válidos o campo pode + alcançar em 2026. + + } + formula="Fórmula: votos do presidencial do campo em 2022 (1º turno), ajustados pelo crescimento de comparecimento projetado para a disputa de deputado federal." + /> + } + /> + + Captura mostra quanto do teto do campo Jorge Solla conquistou + em 2022. + + } + formula="Fórmula: votos de Solla (2022) ÷ teto do campo (presidencial 2022). Só diagnóstico — não entra na meta." + /> + } + /> + + Share intracampo mostra a fração dos votos do campo, só na + disputa de deputado federal, que foi para Jorge Solla nesse ano. + + } + formula="O denominador é o próprio campo nessa disputa — não o teto presidencial de 2022, que é o denominador da Captura." + /> + } + /> + + Roll-off mede quanto voto em branco/nulo a disputa de deputado + federal atrai a mais que a presidencial, no mesmo pleito. + + } + formula="Fórmula: (brancos + nulos, deputado federal 2022) − (brancos + nulos, presidencial 2022)." + /> + } + />
{suggestedGoal <= 0 ? (

diff --git a/src/components/campaign/MunicipalityHoverTooltip.tsx b/src/components/campaign/MunicipalityHoverTooltip.tsx index 32505803..501ca199 100644 --- a/src/components/campaign/MunicipalityHoverTooltip.tsx +++ b/src/components/campaign/MunicipalityHoverTooltip.tsx @@ -1,27 +1,82 @@ 'use client' -import type { ReactElement, ReactNode } from 'react' +import { cloneElement, useEffect, useRef, useState } from 'react' +import type { PointerEvent, ReactElement, ReactNode, Ref } from 'react' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -/** Hover/focus explanation with no extra chrome — wraps an existing control or heading. */ +/** + * Hover/focus explanation with no extra chrome — wraps an existing control or + * heading. Radix's `TooltipTrigger` deliberately ignores touch pointer + * events — no hover on touch, and a tap doesn't reliably focus a non-form + * element either — which otherwise makes the whole tooltip unreachable on + * phones (an `/impeccable critique` finding). The open state is controlled + * so a `pointerType === 'touch'` tap can explicitly toggle it; mouse hover + * and keyboard focus are untouched (Radix keeps driving `onOpenChange` for + * those exactly as before), so a **mouse** click — which already hovered the + * trigger on its way in — can't immediately re-close what hover just opened. + * + * Radix's Tooltip has no outside-tap dismiss layer (that's a Popover + * feature), so once opened by touch it would otherwise stay open until the + * page navigates. The `pointerdown`-outside listener below closes it — + * tapping a *different* metric's trigger counts as "outside" too, which is + * what keeps at most one of these tooltips open at a time. + */ +type TappableElement = ReactElement<{ + onPointerUp?: (event: PointerEvent) => void + ref?: Ref +}> + export const MunicipalityHoverTooltip = ({ content, children, side = 'bottom', align = 'center', + sideOffset = 4, }: { content: ReactNode - children: ReactElement + children: TappableElement side?: 'top' | 'right' | 'bottom' | 'left' align?: 'start' | 'center' | 'end' -}) => ( - - - {children} - - {content} - - - -) + sideOffset?: number +}) => { + const [open, setOpen] = useState(false) + const triggerRef = useRef(null) + + useEffect(() => { + if (!open) return + const closeIfOutside = (event: globalThis.PointerEvent) => { + if (event.pointerType !== 'touch') return + if (triggerRef.current?.contains(event.target as Node)) return + setOpen(false) + } + document.addEventListener('pointerdown', closeIfOutside) + return () => document.removeEventListener('pointerdown', closeIfOutside) + }, [open]) + + const trigger = cloneElement(children, { + ref: (node: HTMLElement | null) => { + triggerRef.current = node + }, + onPointerUp: (event: PointerEvent) => { + children.props.onPointerUp?.(event) + if (event.pointerType === 'touch') setOpen((current) => !current) + }, + }) + + return ( + + + {trigger} + + {content} + + + + ) +} diff --git a/src/lib/bahiaElectionAggregates.ts b/src/lib/bahiaElectionAggregates.ts index c44d67c2..ba2f6d4d 100644 --- a/src/lib/bahiaElectionAggregates.ts +++ b/src/lib/bahiaElectionAggregates.ts @@ -9,8 +9,12 @@ import artifactJson from '@/lib/electionAggregates/bahia-federal-baseline.json' * 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). + * "share intracampo" and roll-off. 2022-only for the majoritarian slice by + * design (E8's formulas anchor the field ceiling and roll-off to the single + * most recent majoritarian race) — the DB has majoritária seeded for + * 2014/2018 too (`pnpm db:seed:tse`, since 2026-07-24), but it isn't cut into + * this artifact; a future consumer that needs it extends `majoritarian2022` + * into a `majoritarianByYear` map. * * Generated by `pnpm build:election-aggregates` from the locally seeded TSE * data — never computed at build or request time (see the script header for diff --git a/src/lib/electionResultsBuild.ts b/src/lib/electionResultsBuild.ts index 47801fba..69260bd0 100644 --- a/src/lib/electionResultsBuild.ts +++ b/src/lib/electionResultsBuild.ts @@ -13,6 +13,8 @@ import { import { applyStateVoteTotals, dedupeCandidates, + mergeDuplicateTallyRows, + mergeDuplicateVoteRows, parseCandidateVoteRow, parseConsultaCandRow, parseDetalheApuracaoRow, @@ -21,6 +23,17 @@ import { const ALL_OFFICES = new Set(ELECTION_OFFICES) export const FEDERAL_ONLY_OFFICES = new Set([FEDERAL_DEPUTY_OFFICE]) +/** + * Historical seed scope (2014/2018): federal deputy (E2 trend series) plus + * presidente/governador (E8 majoritarian baseline — same rationale as the + * 2022 full-ticket import, just for the two prior cycles). Excludes + * deputado_estadual: no consumer needs the state-assembly race historically. + */ +export const HISTORICAL_BASELINE_OFFICES = new Set([ + FEDERAL_DEPUTY_OFFICE, + 'presidente', + 'governador', +]) const STATE_OFFICES = new Set([ 'governador', 'deputado_federal', @@ -78,11 +91,16 @@ export const buildElectionResultsFromCsvRows = (args: { offices.has('deputado_estadual') const unknownMunicipalities = new Set() - const votes = collectParsed(args.voteRows, year, unknownMunicipalities, (row) => - parseCandidateVoteRow(row, { stateFilter: 'BA', offices }), + // Dedupe TSE's "voto em trânsito" split rows — see mergeDuplicateVoteRows's doc. + const votes = mergeDuplicateVoteRows( + collectParsed(args.voteRows, year, unknownMunicipalities, (row) => + parseCandidateVoteRow(row, { stateFilter: 'BA', offices }), + ), ) - const tallies = collectParsed(args.tallyRows, year, unknownMunicipalities, (row) => - parseDetalheApuracaoRow(row, { stateFilter: 'BA', offices }), + const tallies = mergeDuplicateTallyRows( + collectParsed(args.tallyRows, year, unknownMunicipalities, (row) => + parseDetalheApuracaoRow(row, { stateFilter: 'BA', offices }), + ), ) const candidatesRaw: ElectionCandidateRow[] = [] diff --git a/src/lib/electionResultsParse.ts b/src/lib/electionResultsParse.ts index ce171761..fe099d71 100644 --- a/src/lib/electionResultsParse.ts +++ b/src/lib/electionResultsParse.ts @@ -15,6 +15,70 @@ import { } from '@/lib/electionResults' import { computeIdentityKey } from '@/lib/electionCandidateIdentity' +/** + * Merges rows sharing the same `keyFn` key via `combine`, preserving first-seen + * order. Shared by `mergeDuplicateVoteRows`/`mergeDuplicateTallyRows` below — + * both fold TSE's "voto em trânsito" split rows (see their docs) and differ + * only in the key and how colliding rows combine. + */ +const mergeByKey = ( + rows: readonly T[], + keyFn: (row: T) => string, + combine: (existing: T, row: T) => T, +): T[] => { + const merged = new Map() + for (const row of rows) { + const key = keyFn(row) + const existing = merged.get(key) + merged.set(key, existing ? combine(existing, row) : row) + } + return [...merged.values()] +} + +const voteRowKey = (row: CandidateVoteRow): string => + `${row.year}|${row.office}|${row.turn}|${row.state}|${row.cityCode}|${row.zoneNumber}|${row.candidateNumber}|${row.voteType}` + +/** + * TSE's presidente data (the only office open to "voto em trânsito" — voting + * away from your registered zone, national ballot only) records the trânsito + * ballots as a SEPARATE row sharing the same (year, office, turn, state, + * cityCode, zoneNumber, candidateNumber, voteType) key as the regular row — + * colliding with `ElectionCandidateVote`'s unique index, which doesn't track + * the untracked ST_VOTO_EM_TRANSITO flag. Both rows are additive (distinct + * electorates, same candidate/zone), so sum `votes` instead of dropping + * either. Observed in a handful of 2014 BA zones; absent in 2018/2022 — a + * no-op when there is nothing to merge. + */ +export const mergeDuplicateVoteRows = (rows: readonly CandidateVoteRow[]): CandidateVoteRow[] => + mergeByKey(rows, voteRowKey, (existing, row) => ({ ...existing, votes: existing.votes + row.votes })) + +const tallyRowKey = (row: TseDetalheApuracaoRow): string => + `${row.year}|${row.office}|${row.turn}|${row.state}|${row.cityCode}|${row.zoneNumber}` + +const SUMMABLE_TALLY_FIELDS = [ + 'aptos', + 'comparecimento', + 'abstencoes', + 'votosValidos', + 'votosNominaisValidos', + 'votosLegenda', + 'votosBranco', + 'votosNulo', + 'votosAnulados', +] as const satisfies ReadonlyArray + +/** Same trânsito-split merge as `mergeDuplicateVoteRows`, for tally rows (sums every count field). */ +export const mergeDuplicateTallyRows = ( + rows: readonly TseDetalheApuracaoRow[], +): TseDetalheApuracaoRow[] => + mergeByKey(rows, tallyRowKey, (existing, row) => { + const summed = { ...existing } + for (const field of SUMMABLE_TALLY_FIELDS) { + summed[field] = existing[field] + row[field] + } + return summed + }) + export type TseCsvRow = Record const cell = (row: TseCsvRow, key: string): string => row[key] ?? '' diff --git a/src/utilities/municipalityPotential.ts b/src/utilities/municipalityPotential.ts index f550470c..c057873b 100644 --- a/src/utilities/municipalityPotential.ts +++ b/src/utilities/municipalityPotential.ts @@ -40,7 +40,8 @@ export const projectedValidVotes = (baseline: MunicipalityFederalBaseline): numb * 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. + * the E8 plan). 2022-only by design — see `bahiaElectionAggregates.ts` for + * why the committed artifact doesn't cut 2014/2018 majoritária too. */ export const fieldCeiling = (baseline: MunicipalityFederalBaseline): number => baseline.majoritarian2022?.president.votes ?? 0 @@ -95,8 +96,8 @@ export type RollOff = { /** * 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. + * and as a share of comparecimento. 2022-only, same artifact limitation as + * `fieldCeiling` above; `null` when either tally is missing for the slug. */ export const rollOff = (baseline: MunicipalityFederalBaseline): RollOff | null => { const majoritarian = baseline.majoritarian2022?.president diff --git a/tests/unit/electionResults.unit.spec.ts b/tests/unit/electionResults.unit.spec.ts index 23c8af22..75daba05 100644 --- a/tests/unit/electionResults.unit.spec.ts +++ b/tests/unit/electionResults.unit.spec.ts @@ -17,6 +17,8 @@ import { } from '@/lib/electionResults' import { loadTseFixtureResults, loadTseFixtureResultsForYear, TSE_FIXTURE_EXPECTED } from '../helpers/tseFixtures' import { FEDERAL_ONLY_OFFICES } from '@/lib/electionResultsBuild' +import { mergeDuplicateTallyRows, mergeDuplicateVoteRows } from '@/lib/electionResultsParse' +import type { CandidateVoteRow, TseDetalheApuracaoRow } from '@/lib/electionResults' describe('electionResults municipality mapping', () => { it('resolves every canonical Bahia municipality through itself', () => { @@ -167,3 +169,87 @@ describe('electionResults fixture parse + winners', () => { expect(built.votes.every((row) => row.year === 2018)).toBe(true) }) }) + +describe('electionResults "voto em trânsito" merge (2014 presidente duplicate rows)', () => { + const baseVote: CandidateVoteRow = { + year: 2014, + office: 'presidente', + turn: '1', + state: 'BA', + cityCode: '99999', + cityName: 'Feira de Santana', + zoneNumber: 154, + candidateNumber: 13, + candidateName: 'DILMA', + coalition: null, + party: 'PT', + voteType: 'nominal', + votes: 100, + } + + it('sums votes for rows sharing the same scope/zone/candidate key (the trânsito split)', () => { + const merged = mergeDuplicateVoteRows([baseVote, { ...baseVote, votes: 40 }]) + + expect(merged).toHaveLength(1) + expect(merged[0].votes).toBe(140) + }) + + it('keeps rows with a different key separate (no false merge across zones/candidates)', () => { + const otherZone = { ...baseVote, zoneNumber: 155, votes: 10 } + const otherCandidate = { ...baseVote, candidateNumber: 45, votes: 20 } + + const merged = mergeDuplicateVoteRows([baseVote, otherZone, otherCandidate]) + + expect(merged).toHaveLength(3) + expect(merged.reduce((sum, row) => sum + row.votes, 0)).toBe(130) + }) + + it('is a no-op when there is nothing to merge (2018/2022 shape)', () => { + const rows = [baseVote, { ...baseVote, zoneNumber: 1 }] + expect(mergeDuplicateVoteRows(rows)).toEqual(rows) + }) + + const baseTally: TseDetalheApuracaoRow = { + year: 2014, + office: 'presidente', + turn: '1', + state: 'BA', + cityCode: '99999', + cityName: 'Feira de Santana', + zoneNumber: 154, + aptos: 300, + comparecimento: 200, + abstencoes: 100, + votosValidos: 180, + votosNominaisValidos: 175, + votosLegenda: 5, + votosBranco: 10, + votosNulo: 10, + votosAnulados: 0, + } + + it('sums every count field for duplicate tally rows sharing the same scope key', () => { + const merged = mergeDuplicateTallyRows([ + baseTally, + { ...baseTally, aptos: 50, comparecimento: 40, abstencoes: 10, votosValidos: 35, votosNominaisValidos: 33, votosLegenda: 2, votosBranco: 3, votosNulo: 2, votosAnulados: 0 }, + ]) + + expect(merged).toHaveLength(1) + expect(merged[0]).toMatchObject({ + aptos: 350, + comparecimento: 240, + abstencoes: 110, + votosValidos: 215, + votosNominaisValidos: 208, + votosLegenda: 7, + votosBranco: 13, + votosNulo: 12, + votosAnulados: 0, + }) + }) + + it('is a no-op when zones differ', () => { + const rows = [baseTally, { ...baseTally, zoneNumber: 155 }] + expect(mergeDuplicateTallyRows(rows)).toEqual(rows) + }) +}) From 3250300086655fd89a7ecc37a63f49b433909a0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Fri, 24 Jul 2026 22:57:40 -0300 Subject: [PATCH 3/3] =?UTF-8?q?Anota=20gatilho=20de=20defer=20p=C3=B3s-cri?= =?UTF-8?q?tique=20no=20plano=20do=20E8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit capture-review-debts (auto-confirm) triageou os achados restantes do /impeccable critique de MunicipalityGoalAccountCard: os 3×P1+P2+P3 confirmados já foram fechados nesta sessão; o resto (naming, redesenho hipotético de Captura/Share intracampo, disclosure não assentado para futuras métricas) é cosmético ou prematuro — anotado como defer+gatilho em "Adiado com gatilho" em vez de virar item novo no roadmap. --- docs/plans/conta-da-cadeira.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plans/conta-da-cadeira.md b/docs/plans/conta-da-cadeira.md index 5d238e96..5db390eb 100644 --- a/docs/plans/conta-da-cadeira.md +++ b/docs/plans/conta-da-cadeira.md @@ -130,6 +130,7 @@ Componentes: - **Delta semanal persistido.** Revisitar quando C12 entregar trajetória (gatilho: primeira semana com 2 snapshots). - **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`). +- **Modelo de disclosure do grid de diagnóstico (`GoalAccountMetric`) ainda não "assentado" para mais métricas.** O `/impeccable critique` pós-hardening (26/40; ver Follow-up #3) levantou, em "Questions to Consider", se hover/foco continua o mecanismo certo de explicação conforme **E9/E10/B13/E11/E12/E13/E14** adicionam métricas a este mesmo card, e se "Captura"/"Share intracampo" deveriam virar um único número com toggle de denominador em vez de dois números cada um explicando o outro por tooltip. Nenhum achado adicional confirmado nesta sessão (é pergunta de design, não bug) — **não registrado como item**; gatilho: revisitar quando a próxima entrega de E9–E14 for adicionar uma 5ª métrica a esta `dl`/grid (nesse ponto, decidir layout/idioma de disclosure antes de replicar `GoalAccountMetric` de novo). Largura tablet/foldable intermediária também não testada — mesmo gatilho. ## Referências