From 9443a4e1f6cd4ccd184c2f221e7b2d8394827ae2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?=
Date: Sat, 25 Jul 2026 01:35:35 -0300
Subject: [PATCH] =?UTF-8?q?E18:=20documenta=C3=A7=C3=A3o=20de=20conceitos?=
=?UTF-8?q?=20de=20intelig=C3=AAncia=20de=20campanha?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Nova rota staff-only /campanha/conceitos (glossário curado em
src/lib/campaignIntelligenceConcepts.ts, revisado junto das fórmulas
que descreve em municipalityPotential.ts/goalCoverage.ts) com
descoberta por "Saiba mais" nos tooltips do card "Conta da cadeira" e
entrada fixa no rodapé da sidebar (staffSecondaryNav). Corrige defeito
E8 de formatElectionNumber (meta fracionária lida como cem mil em
pt-BR). Ajustes pós-/simplify: MetricExplanation.conceptID obrigatório,
href da sidebar desacoplado do módulo de conteúdo (client bundle).
---
.cursor/rules/projects/nucleos-eleitorais.mdc | 2 +
AGENTS.md | 2 +
docs/plans/documentacao-conceitos-campanha.md | 20 ++-
docs/roadmap.md | 12 +-
.../campanha/(app)/conceitos/page.tsx | 133 +++++++++++++++
src/components/campaign/CampaignSidebar.tsx | 69 ++++++--
.../campaign/MunicipalityGoalAccountCard.tsx | 41 ++++-
.../campaign/MunicipalityHoverTooltip.tsx | 10 +-
src/components/campaign/nav.ts | 18 ++
src/lib/campaignIntelligenceConcepts.ts | 160 ++++++++++++++++++
src/lib/electionInsights.ts | 8 +-
tests/e2e/campaignConcepts.e2e.spec.ts | 85 ++++++++++
tests/e2e/setup.e2e.spec.ts | 1 +
.../campaignIntelligenceConcepts.unit.spec.ts | 82 +++++++++
tests/unit/goalCoverage.unit.spec.ts | 10 ++
15 files changed, 626 insertions(+), 27 deletions(-)
create mode 100644 src/app/(campaign)/campanha/(app)/conceitos/page.tsx
create mode 100644 src/lib/campaignIntelligenceConcepts.ts
create mode 100644 tests/e2e/campaignConcepts.e2e.spec.ts
create mode 100644 tests/unit/campaignIntelligenceConcepts.unit.spec.ts
diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc
index f2f334b0..633bb2e6 100644
--- a/.cursor/rules/projects/nucleos-eleitorais.mdc
+++ b/.cursor/rules/projects/nucleos-eleitorais.mdc
@@ -8,6 +8,7 @@ alwaysApply: false
## Status
- **E16 entregue em código (2026-07-25):** Dossiê do município (pré-agenda) — aba `?tab=dossie` no detalhe (`MunicipalityDossier.tsx`, server component em Suspense) compondo os loaders existentes via `municipalityDossierData.ts` (baseline TSE + rank A11, conta da cadeira E8, pledges, rede de lideranças com frescor `updatedAt`, agenda `actionPlan` `overrideAccess: false`, sinais C12, perfil IBGE A8 com caveat para zonas — reentrada do artefato órfão); caps 8 lideranças / 5 sinais / 3+2 planos com "ver tudo" por seção; novo campo staff-only `municipality.budgetNotes` ("Emendas aportadas", G11 manual-first) via migration `20260725_022155_add_municipality_budget_notes` (o `.json` da migration também sara a cadeia de snapshots pós-`campaign_goals`); form de estratégia + zod + view model estendidos; **print CSS** (chrome `print:hidden`, shells `print:h-auto print:overflow-visible`, tipografia A4 em `styles.css`) — dossiê imprime 1–2 páginas com capa autocontida ("Gerado em"). Int test `municipalityDossierData.int.spec.ts` (mock do `unstable_cache` do baseline). Gate verde (tsc/lint/knip/363 tests/build); Aikido bloqueado por sessão expirada (re-login navegador pendente). Plano: [`dossie-municipio.md`](../../../docs/plans/dossie-municipio.md).
+- **E18 entregue em código (2026-07-24):** documentação de conceitos de inteligência — rota staff-only `/campanha/conceitos` (`isCampaignStaff`, `leader` redirecionado) renderizando `src/lib/campaignIntelligenceConcepts.ts`, conteúdo curado por PR ao lado das fórmulas que descreve (não collection Payload). 7 conceitos de E8 agrupados em base do cálculo (válidos projetados, teto do campo) / diagnóstico (captura, share intracampo, roll-off) / meta e cobertura (meta e meta sugerida, cobertura da meta), cada um com o que mede, fórmula em texto, exemplo estático, por que importa e onde aparece na tela. Superfície de leitura, não cards: `article` por conceito com hairline, coluna em `max-w-prose`, índice sticky só em `lg`, `article:target` tintado para orientar quem cai por âncora. Dois caminhos de descoberta: "Saiba mais" por métrica nos tooltips do card (`MetricExplanation` ganhou `conceptID`) e link para a página inteira no Popover `CampaignInfoHint` do título — o Popover é o caminho de **teclado**, porque conteúdo de Tooltip do Radix não é tabulável; para o toque funcionar, `MunicipalityHoverTooltip` passou a tratar o próprio conteúdo como "dentro" no dismiss por `pointerdown` (antes o link era desmontado antes do clique chegar). Entrada na sidebar a pedido do usuário na mesma sessão: `staffSecondaryNav` num `SidebarGroup mt-auto` colado no rodapé da navegação — fora da `staffNav` (os 7 destinos de trabalho) e fora da barra inferior do mobile, porque é referência e não lugar de trabalhar; `getCampaignSecondaryNav` devolve vazio para `leader`. Corrigido no caminho um defeito de E8: `formatElectionNumber` renderizava a meta sugerida fracionária como "100,968" (lida como cem mil) — agora `maximumFractionDigits: 0`. Sem migration, sem collection, sem server action. Cada fatia futura do programa (E9/E10/B13/E11/E12/E13/E14) acrescenta sua seção ao array. Plano: [`documentacao-conceitos-campanha.md`](../../../docs/plans/documentacao-conceitos-campanha.md).
- **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.
@@ -87,6 +88,7 @@ alwaysApply: false
## Decisões
+- Em 2026-07-24, **glossário de inteligência (E18) mora em código e é staff-only:** as explicações são 1:1 com as fórmulas de `municipalityPotential.ts`/`goalCoverage.ts`, então vivem num módulo `src/lib` revisado no mesmo PR que muda a fórmula; toda a lista descreve campos que a liderança nunca vê, então a rota é `isCampaignStaff`. Descoberta por link contextual nos tooltips que já expõem os números, não por item na `staffNav`. **Rejeitado:** collection Payload com richText (cópia dessincroniza da fórmula sem teste nenhum pegar); leitura liberada a todo `campaignUser`; KaTeX (fórmula em texto basta para staff de campo).
- 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).
diff --git a/AGENTS.md b/AGENTS.md
index eb75e31b..22a91144 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -69,6 +69,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
**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-24):** **E18** Documentação dos conceitos de inteligência — staff-only route `/campanha/conceitos` (`isCampaignStaff`; `leader` redirected to `/campanha`) rendering `src/lib/campaignIntelligenceConcepts.ts`. The glossary is **curated content in code, not a Payload collection**: each entry (what it measures, the formula in plain text, a hand-written example, why it matters, where it appears) sits next to the formulas it documents (`municipalityPotential.ts` / `goalCoverage.ts`) and is reviewed in the same PR that changes them; a unit test guards anchor-safe unique IDs and content completeness. Seven E8 concepts in three groups — base do cálculo (válidos projetados, teto do campo), diagnóstico (captura, share intracampo, roll-off), meta e cobertura (meta/meta sugerida, cobertura da meta). Reading surface, not cards: one `article` per concept with hairline dividers, `max-w-prose` column, sticky index at `lg` only, `article:target` tint so anchor arrivals land oriented. Two discovery paths from `MunicipalityGoalAccountCard`: per-metric "Saiba mais" links inside the hover tooltips (`MetricExplanation` takes a `conceptID`) and a whole-page link in the title `CampaignInfoHint` **Popover** — the Popover is the keyboard path because Radix Tooltip content is not tabbable; for touch, `MunicipalityHoverTooltip` now treats its own content as "inside" in the `pointerdown` dismiss handler (the link used to unmount before the tap landed). Sidebar entry lives in `staffSecondaryNav` (`nav.ts`) rendered in its own `SidebarGroup` with `mt-auto` at the foot of the navigation — deliberately outside `staffNav` (the seven work destinations) and outside the mobile bottom bar, since it is reference material; `getCampaignSecondaryNav` returns nothing for `leader`. Fixed an E8 defect found while writing the docs: `formatElectionNumber` rendered a fractional suggested goal as "100,968" (read as a hundred thousand in pt-BR) — now `maximumFractionDigits: 0`. No migration, no collection, no server action; later slices of the intelligence program (E9/E10/B13/E11/E12/E13/E14) each append their section to the array. Plano: [`docs/plans/documentacao-conceitos-campanha.md`](docs/plans/documentacao-conceitos-campanha.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)
diff --git a/docs/plans/documentacao-conceitos-campanha.md b/docs/plans/documentacao-conceitos-campanha.md
index 3e40b0e3..eec4c4b6 100644
--- a/docs/plans/documentacao-conceitos-campanha.md
+++ b/docs/plans/documentacao-conceitos-campanha.md
@@ -1,6 +1,6 @@
# Documentação de conceitos de inteligência de campanha
-Status: rascunho
+Status: entregue (2026-07-24)
Atualizado em: 2026-07-24
Item do roadmap: [docs/roadmap.md](../roadmap.md) ("Demais itens abertos", **E18** — adjacente ao programa Inteligência de campanha E8–E16/B13/C12)
Impeccable: C — rota nova `/campanha/conceitos`, sem design-ref
@@ -43,7 +43,7 @@ O card "Conta da cadeira" no detalhe do município (`/campanha/municipios/
- **Conteúdo em módulo de código estático (`src/lib`), não em collection Payload.** As explicações são 1:1 com fórmulas vivas em `municipalityPotential.ts`/`goalCoverage.ts` (e, depois, nos utilities de E9–E17); um texto curado por PR ao lado do código que ele descreve fica revisável junto da mudança de fórmula. **Rejeitado:** collection admin (richText) — deixaria a cópia dessincronizar da fórmula real sem qualquer teste/lint pegando, exigiria grupo `admin.group` e access novos para um conteúdo que nenhum coordenador vai editar no `/admin`.
- **Rota nova staff-only (`isCampaignStaff`), sem acesso de `leader`.** Todo conceito documentado descreve campos staff-only (estimativas, metas, teto do campo) que a liderança nunca vê — expor o glossário a ela vazaria vocabulário sem contexto (M4 vocabulário duplo, mesmo princípio de E14). **Rejeitado:** liberar leitura a todo `campaignUser` — nada para a liderança ancorar a leitura.
-- **Descoberta via link contextual nos tooltips existentes, não item fixo na `staffNav`.** A sidebar hoje tem 7 itens fixos (`nav.ts`); glossário é material de referência, não destino de uso diário — Design Principle "Clarity under pressure". **Rejeitado:** adicionar a `staffNav` já nesta entrega (ver Adiado com gatilho).
+- **Descoberta por link contextual nos tooltips + entrada no rodapé da sidebar.** Os tooltips levam à âncora do número que a pessoa está olhando; a sidebar garante um caminho de volta sem depender de lembrar em qual card estava o link. A entrada não entra na `staffNav` (os 7 destinos de trabalho, `nav.ts`) nem na barra inferior do mobile: vai num grupo próprio colado no rodapé (`staffSecondaryNav`), material de referência separado de onde a mesa trabalha — Design Principle "Clarity under pressure". **Rejeitado:** oitavo item no meio da `staffNav`, competindo com Municípios/Lideranças/Demandas.
- **i18n e naming**: identificadores em inglês (`campaignIntelligenceConcepts.ts`, `ConceptSection`, `conceptId`), strings visíveis em pt-BR; rota `/campanha/conceitos` (Português, mesmo padrão de `/campanha/dobradinhas`/`/campanha/planos` — não é param dinâmico).
## Questões em aberto
@@ -90,9 +90,23 @@ Componentes:
## Adiado com gatilho
-- **Item fixo na `staffNav` (`nav.ts`).** Revisitar quando houver evidência de que staff navega direto para `/campanha/conceitos` (não via link contextual do tooltip) — ex. analytics de navegação ou pedido explícito em sessão.
+- ~~**Item fixo na `staffNav` (`nav.ts`).**~~ Gatilho disparado no mesmo dia (pedido explícito em sessão): entrou como grupo secundário no rodapé da sidebar, não na `staffNav` — ver Entrega.
- **Exemplo numérico "ao vivo" de município real.** Revisitar se o exemplo estático se mostrar insuficiente em uso real (feedback de campo pedindo números reais).
+## Entrega (2026-07-24)
+
+Implementado como planejado, com três desvios documentados:
+
+- **7 conceitos, não 6.** "Votos válidos projetados" entrou como conceito próprio (categoria "Base do cálculo"): a fórmula do teto projetado e a da meta sugerida citam a projeção de válidos, então descrevê-la de fora deixaria as duas explicações circulares. Categorias (`base` / `diagnostico` / `meta`) já existem no conteúdo — cada fatia futura do programa acrescenta a sua.
+- **Seções planas, sem `Card` por conceito.** É superfície de leitura: `article` por conceito com hairline entre eles, coluna capada em `max-w-prose`, e um índice sticky só em `lg` (num telefone ele empurraria as sete seções abaixo da dobra para duplicar o que rolar já dá). `article:target` recebe um tint `bg-muted/50` — é o que orienta quem chegou por âncora no meio da página. Cards empilhados seriam a resposta preguiçosa (`DESIGN.md`, "Flat-By-Default").
+- **Dois caminhos de "saiba mais", por acessibilidade.** Cada tooltip de métrica (`MetricExplanation`, prop `conceptID`) leva à âncora do conceito; o Popover `CampaignInfoHint` do título leva à página inteira. O Popover é o caminho de teclado — conteúdo de Tooltip do Radix não entra na ordem de tabulação. Para o toque funcionar, `MunicipalityHoverTooltip` passou a tratar o próprio conteúdo como "dentro" no listener de `pointerdown` (antes o link era desmontado antes do clique chegar).
+
+Ainda na mesma sessão, a pedido do usuário, a página ganhou **entrada na sidebar**: `staffSecondaryNav` (`nav.ts`) num `SidebarGroup` com `mt-auto` — colado no rodapé da navegação, acima do bloco de perfil/sair, fora da `staffNav` e fora da barra inferior do mobile. `getCampaignSecondaryNav` devolve vazio para `leader`, então a liderança não vê um link que só a devolveria para `/campanha`. O markup do item virou `CampaignSidebarLink` (dois pontos de uso: destinos e referência).
+
+Também corrigido no caminho (defeito de E8 achado ao conferir a redação contra a tela): `formatElectionNumber` usava `Intl.NumberFormat('pt-BR')` sem fixar dígitos fracionários, então a meta sugerida decomposta (fracionária) aparecia como "Meta usada na conta: 100,968" — lida como cem mil por qualquer humano na sala. Agora `maximumFractionDigits: 0`, com teste em `tests/unit/goalCoverage.unit.spec.ts`.
+
+Cobertura: `tests/unit/campaignIntelligenceConcepts.unit.spec.ts` (ids únicos/anchor-safe, toda categoria renderizada, campos obrigatórios) e `tests/e2e/campaignConcepts.e2e.spec.ts` (staff lê a página e chega nela pelo card; âncora do "Saiba mais"; `leader` é redirecionado). `/campanha/conceitos` entrou na lista de prewarm de `tests/e2e/setup.e2e.spec.ts`: sem isso o clique no link do Popover espera o dev server compilar a rota e a asserção de URL estoura antes da navegação — flake do ambiente de teste, não do produto.
+
## Referências
- `src/components/campaign/MunicipalityGoalAccountCard.tsx` — `GoalAccountMetric`, `formatRollOff`, `CampaignInfoHint` do título: fonte da redação v1 e ponto de integração dos links "saiba mais"
diff --git a/docs/roadmap.md b/docs/roadmap.md
index d6729b63..355a394a 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# Roadmap — Teqo
-Atualizado em: 2026-07-25 (**E16 ✓** dossiê do município — aba + print + `budgetNotes` G11; anterior 2026-07-24: B19 gerenciar assessores; **E8 ✓** conta da cadeira — desbloqueia E9/E10/E12/E13; **E18** documentação de conceitos registrado como adjacente ao programa; C12 ✓; C11 absorve escala plano↔demandas; B18; B17; B16; reorder DnD fora de escopo; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6)
+Atualizado em: 2026-07-25 (**E16 ✓** dossiê do município — aba + print + `budgetNotes` G11; anterior 2026-07-24: B19 gerenciar assessores; **E8 ✓** conta da cadeira — desbloqueia E9/E10/E12/E13; **E18 ✓** documentação de conceitos (`/campanha/conceitos`); 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)
@@ -89,7 +89,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
- **B17** seletor de colunas (mostrar/ocultar) na lista de municípios — Popover “Colunas” + `localStorage`; desktop only; `name` obrigatória; soft: B15 ✓ (ids) / B16 (barra slim); prepara viewport para **E9** · ~0,5–1d · Janela 1–2 · cortável · [plano](plans/seletor-colunas-lista-municipios.md)
- **B18** filtros salvos na lista de municípios — nomear o estado URL atual (`localStorage`); acesso rápido de 2º nível sob Municípios no sidebar (hover desktop / expand mobile; sticky só no filtro salvo ativo) · ~1–1,5d · Janela 1–2 · soft: B16/B17 (barra) · cortável · [plano](plans/filtros-salvos-municipios.md)
- **B19** gerenciar assessores — `/campanha/assessores` (lista + novo + detalhe): criar/editar contas `advisor`, ver/atribuir municípios, reenviar link de senha; **só Coordenador Geral e Candidato** (`isCampaignUnrestricted`); alinha access de update/phone do `candidate` · ~1,5–2d · Janela 1–2 · sem deps duras · não cortável (onboarding Onda 0 §4 — CG sem `/admin`) · [plano](plans/gerenciar-assessores.md)
-- **E18** documentação de conceitos de inteligência de campanha (`/campanha/conceitos`) — página estática staff-only explicando os números do card "Conta da cadeira" (teto do campo, captura, share intracampo, roll-off, cobertura da meta) em prosa + fórmula em texto, com links "saiba mais" a partir dos tooltips já existentes (`GoalAccountMetric`, `CampaignInfoHint`); conteúdo cresce por PR conforme E9/E10/B13/E11/E12/E13/E14 entregarem suas seções · pedido do usuário 2026-07-24 ao ler os tooltips do E8 · ~0,5–1d · Janela 1–2 · duro: E8 ✓ (satisfeito) · cortável · [plano](plans/documentacao-conceitos-campanha.md)
+- ~~**E18** documentação de conceitos de inteligência de campanha~~ — **entregue 2026-07-24** (`/campanha/conceitos` staff-only; 7 conceitos de E8 em `src/lib/campaignIntelligenceConcepts.ts` agrupados em base/diagnóstico/meta; "Saiba mais" por métrica nos tooltips + link de teclado no Popover do card; `formatElectionNumber` passou a arredondar votos) · cada fatia futura (E9/E10/B13/E11/E12/E13/E14) acrescenta sua seção ao array como parte da própria entrega · [plano](plans/documentacao-conceitos-campanha.md)
### Fill-ins abertos
@@ -131,7 +131,7 @@ flowchart TD
E4R["E4R ✓ Import planilha (seed estratégia)"]
A11n["A11 ✓ Posição em votos"]
E17n["E17 ✓ Tabela TI no Início"]
- E18n["E18 Documentação de conceitos"]
+ E18n["E18 ✓ Documentação de conceitos"]
subgraph Intel["Inteligência de campanha"]
E8i["E8 ✓ Conta da cadeira"]
@@ -179,11 +179,11 @@ flowchart TD
B8F2 -.ZE Salvador.-> B14n
```
-Paralelizáveis agora: **E9** (E8 ✓ entregue), **E10**/**E12** (E8 ✓ desbloqueou ambos), **E18** (E8 ✓ já dá conteúdo para v1), **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).
+Paralelizáveis agora: **E9** (E8 ✓ entregue), **E10**/**E12** (E8 ✓ desbloqueou ambos), **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); **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; **E18** documentação de conceitos (`/campanha/conceitos`, v1 só E8) se sobrar folga — pedido do usuário na mesma sessão do E8; **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 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; **E18 ✓** documentação de conceitos (`/campanha/conceitos`, v1 só E8) entregue 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; **E9** fila de alocação (E8 ✓ e **C12 ✓** já entregues); ~~**E16** dossiê do município~~ (entregue 2026-07-25, antecipado da janela 2); D2 se sobrar folga.
@@ -195,7 +195,7 @@ Paralelizáveis agora: **E9** (E8 ✓ entregue), **E10**/**E12** (E8 ✓ desbloq
**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); **E18** documentação de conceitos (os tooltips inline do E8 já explicam o essencial; página é reforço); ~~**E16** dossiê~~ (entregue 2026-07-25); débitos/fill-ins.
+**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); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); débitos/fill-ins.
## Bloqueadores atuais
diff --git a/src/app/(campaign)/campanha/(app)/conceitos/page.tsx b/src/app/(campaign)/campanha/(app)/conceitos/page.tsx
new file mode 100644
index 00000000..3454c6f3
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/conceitos/page.tsx
@@ -0,0 +1,133 @@
+import type { Metadata } from 'next'
+import { redirect } from 'next/navigation'
+
+import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
+import {
+ CAMPAIGN_CONCEPT_CATEGORIES,
+ campaignConceptsByCategory,
+ type CampaignIntelligenceConcept,
+} from '@/lib/campaignIntelligenceConcepts'
+import { isCampaignStaff } from '@/utilities/campaignAccess'
+import { getCampaignUser } from '@/utilities/campaignAuth'
+
+export const metadata: Metadata = {
+ title: 'Conceitos de inteligência | Campanha',
+ robots: {
+ index: false,
+ follow: false,
+ },
+}
+
+/**
+ * One documented number: what it measures, how it is calculated, why it
+ * matters, and where it shows up. Flat sections (no cards) — this is a reading
+ * surface, and the `article:target` tint is what orients a reader who arrived
+ * from a "Saiba mais" link deep in the page instead of from the top.
+ */
+const ConceptSection = ({ concept }: { concept: CampaignIntelligenceConcept }) => (
+
+
{concept.title}
+
{concept.oneLiner}
+
+
+
Como é calculado
+
{concept.formula}
+ {concept.example ? (
+ <>
+
Exemplo
+
{concept.example}
+ >
+ ) : null}
+
+
+ {/* The substance of the page, so it stays at full ink — muted is for the meta line only. */}
+
{concept.whyItMatters}
+
+ Onde aparece: {concept.whereItAppears}
+
+
+)
+
+/**
+ * E18 — reference page for the intelligence numbers the product derives, linked
+ * from the tooltips that expose them (E8's "Conta da cadeira" today; each later
+ * slice of the program appends its own concepts to
+ * `campaignIntelligenceConcepts`).
+ *
+ * Staff-only: every documented number describes a staff-only field (estimates,
+ * goals, field ceiling) that a `leader` never sees — M4's dual vocabulary.
+ */
+export default async function CampaignConceptsPage() {
+ const user = await getCampaignUser()
+ if (!user) redirect('/campanha/login')
+ if (!isCampaignStaff(user)) redirect('/campanha')
+
+ return (
+
+
+
Conceitos de inteligência
+
+ O que cada número da campanha mede e como é calculado. Só o que o produto já calcula
+ hoje — a lista cresce conforme novas análises entram.
+
+
+
+
+ {/*
+ Desktop-only index: on a phone it would push all seven sections below
+ the fold to duplicate what scrolling already gives (readers usually
+ arrive here on an anchor from a tooltip, not from the top).
+ */}
+
+
+
+
+ )
+}
diff --git a/src/components/campaign/CampaignSidebar.tsx b/src/components/campaign/CampaignSidebar.tsx
index cc18258f..21bfb7d1 100644
--- a/src/components/campaign/CampaignSidebar.tsx
+++ b/src/components/campaign/CampaignSidebar.tsx
@@ -8,7 +8,12 @@ import { logoutCampaign } from '@/app/(campaign)/campanha/actions/auth'
import { CampaignLogo } from '@/components/campaign/campaign-logo'
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { CampaignUserAvatar } from '@/components/campaign/CampaignUserAvatar'
-import { getCampaignNav, isCampaignNavActive } from '@/components/campaign/nav'
+import {
+ getCampaignNav,
+ getCampaignSecondaryNav,
+ isCampaignNavActive,
+ type CampaignNavItem,
+} from '@/components/campaign/nav'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import {
@@ -30,10 +35,34 @@ import { clearRecentVisits } from '@/utilities/recentVisits'
export type CampaignSidebarUser = CampaignUserShellView
+const CampaignSidebarLink = ({
+ item,
+ isActive,
+ onNavigate,
+}: {
+ item: CampaignNavItem
+ isActive: boolean
+ onNavigate: () => void
+}) => (
+
+
+
+
+ {item.title}
+
+
+
+)
+
export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
const pathname = usePathname()
const { isMobile, setOpenMobile } = useSidebar()
const [isLoggingOut, setIsLoggingOut] = useState(false)
+ const secondaryNav = getCampaignSecondaryNav(user.role)
+
+ const closeMobileSidebar = () => {
+ if (isMobile) setOpenMobile(false)
+ }
const handleLogout = async (event: FormEvent) => {
event.preventDefault()
@@ -64,23 +93,35 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
{getCampaignNav(user.role).map((item) => (
-
-
- {
- if (isMobile) setOpenMobile(false)
- }}
- >
-
- {item.title}
-
-
-
+
))}
+
+ {/* Reference, not a destination: pinned to the foot of the nav so it
+ never competes with the places the mesa works every day. */}
+ {secondaryNav.length > 0 ? (
+
+
+
+ {secondaryNav.map((item) => (
+
+ ))}
+
+
+
+ ) : null}
diff --git a/src/components/campaign/MunicipalityGoalAccountCard.tsx b/src/components/campaign/MunicipalityGoalAccountCard.tsx
index 2a118d7f..e133f540 100644
--- a/src/components/campaign/MunicipalityGoalAccountCard.tsx
+++ b/src/components/campaign/MunicipalityGoalAccountCard.tsx
@@ -1,9 +1,15 @@
+import Link from 'next/link'
import type { ReactNode } from 'react'
import { CampaignInfoHint } from '@/components/campaign/CampaignInfoHint'
import { MunicipalityHoverTooltip } from '@/components/campaign/MunicipalityHoverTooltip'
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/MunicipalityListExpectedVotesControl'
import { Progress } from '@/components/ui/Progress'
+import {
+ CAMPAIGN_CONCEPTS_PATH,
+ campaignConceptHref,
+ type CampaignConceptId,
+} from '@/lib/campaignIntelligenceConcepts'
import { formatElectionNumber } from '@/lib/electionInsights'
import {
formatGoalCoverageDeficitLabel,
@@ -65,11 +71,29 @@ const GoalAccountMetric = ({
* sentence with three technical terms didn't resolve the coordinator's
* confusion. `formula` sits at `text-background/70` (tooltip content is
* dark, so this lightens rather than muting against a light background).
+ *
+ * `conceptID` appends E18's "Saiba mais" deep link (every caller documents
+ * one, so this is required, not optional). The tooltip content is hoverable
+ * (Radix keeps it open while the pointer is inside) and its own touch-dismiss
+ * ignores taps on the content, so mouse and touch both reach the link; a
+ * keyboard user can't tab into tooltip content, which is why the card
+ * title's `CampaignInfoHint` Popover carries the same link.
*/
-const MetricExplanation = ({ lead, formula }: { lead: ReactNode; formula?: ReactNode }) => (
+const MetricExplanation = ({
+ lead,
+ formula,
+ conceptID,
+}: {
+ lead: ReactNode
+ formula?: ReactNode
+ conceptID: CampaignConceptId
+}) => (
{lead}
{formula ?
{formula}
: null}
+
+ Saiba mais
+
)
@@ -133,6 +157,17 @@ export const MunicipalityGoalAccountCard = ({
O roll-off e o teto do campo (abaixo) só usam 2022 — a majoritária de 2014/2018 já
foi importada, mas ainda não entra nessas contas.
+ {/*
+ Keyboard path into E18's documentation: Popover content is
+ tabbable, tooltip content is not, so this link (unlike the
+ per-metric ones) is reachable without a pointer.
+ */}
+
+ Como cada número é calculado
+
@@ -186,6 +221,7 @@ export const MunicipalityGoalAccountCard = ({
>
}
formula="Fórmula: votos do presidencial do campo em 2022 (1º turno), ajustados pelo crescimento de comparecimento projetado para a disputa de deputado federal."
+ conceptID="teto-do-campo"
/>
}
/>
@@ -201,6 +237,7 @@ export const MunicipalityGoalAccountCard = ({
>
}
formula="Fórmula: votos de Solla (2022) ÷ teto do campo (presidencial 2022). Só diagnóstico — não entra na meta."
+ conceptID="captura"
/>
}
/>
@@ -216,6 +253,7 @@ export const MunicipalityGoalAccountCard = ({
>
}
formula="O denominador é o próprio campo nessa disputa — não o teto presidencial de 2022, que é o denominador da Captura."
+ conceptID="share-intracampo"
/>
}
/>
@@ -231,6 +269,7 @@ export const MunicipalityGoalAccountCard = ({
>
}
formula="Fórmula: (brancos + nulos, deputado federal 2022) − (brancos + nulos, presidencial 2022)."
+ conceptID="roll-off"
/>
}
/>
diff --git a/src/components/campaign/MunicipalityHoverTooltip.tsx b/src/components/campaign/MunicipalityHoverTooltip.tsx
index 501ca199..86c4a68e 100644
--- a/src/components/campaign/MunicipalityHoverTooltip.tsx
+++ b/src/components/campaign/MunicipalityHoverTooltip.tsx
@@ -20,7 +20,9 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
* 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.
+ * what keeps at most one of these tooltips open at a time. The content itself
+ * counts as "inside": it may hold a link (E18's "Saiba mais"), and closing on
+ * `pointerdown` would unmount that link before its click ever landed.
*/
type TappableElement = ReactElement<{
onPointerUp?: (event: PointerEvent) => void
@@ -42,12 +44,15 @@ export const MunicipalityHoverTooltip = ({
}) => {
const [open, setOpen] = useState(false)
const triggerRef = useRef(null)
+ const contentRef = 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
+ const target = event.target as Node
+ if (triggerRef.current?.contains(target)) return
+ if (contentRef.current?.contains(target)) return
setOpen(false)
}
document.addEventListener('pointerdown', closeIfOutside)
@@ -69,6 +74,7 @@ export const MunicipalityHoverTooltip = ({
{trigger}
)
}
+export const getCampaignSecondaryNav = (role: CampaignUser['role']): CampaignNavItem[] =>
+ isCampaignStaffRole(role) ? staffSecondaryNav : []
+
/**
* Compact set for the mobile bottom bar (max 5 items with a home slot).
*/
diff --git a/src/lib/campaignIntelligenceConcepts.ts b/src/lib/campaignIntelligenceConcepts.ts
new file mode 100644
index 00000000..dae8a79b
--- /dev/null
+++ b/src/lib/campaignIntelligenceConcepts.ts
@@ -0,0 +1,160 @@
+/**
+ * E18 — curated documentation of the campaign-intelligence numbers the product
+ * derives, rendered by `/campanha/conceitos` and linked from the tooltips that
+ * expose each number.
+ *
+ * Content lives here, in code, next to the formulas it describes (E8:
+ * `municipalityPotential.ts` / `goalCoverage.ts`) so a change to a formula and
+ * the change to its explanation land in the same review — a Payload collection
+ * would let the copy drift silently. Every entry must describe what the code
+ * actually computes; when a formula changes, this text changes with it.
+ *
+ * v1 covers the E8 "conta da cadeira" numbers. Each later slice of the
+ * intelligence program (E9 fila, E10 classificação relativa, B13 escala do
+ * mapa, E11 sugestões, E12 TI, E13 giros, E14 níveis) appends its own entries
+ * as part of its own delivery.
+ */
+
+export type CampaignConceptId =
+ | 'votos-validos-projetados'
+ | 'teto-do-campo'
+ | 'captura'
+ | 'share-intracampo'
+ | 'roll-off'
+ | 'meta'
+ | 'cobertura-da-meta'
+
+export type CampaignConceptCategoryId = 'base' | 'diagnostico' | 'meta'
+
+export type CampaignIntelligenceConcept = {
+ id: CampaignConceptId
+ categoryID: CampaignConceptCategoryId
+ title: string
+ /** One sentence: what the number measures. */
+ oneLiner: string
+ /** How it is calculated, in plain text (never rendered as math notation). */
+ formula: string
+ /** Hand-written illustration; never computed from live data. */
+ example?: string
+ /** What decision the number supports — including when it supports none. */
+ whyItMatters: string
+ /** Where in `/campanha` the number is shown. */
+ whereItAppears: string
+}
+
+export const CAMPAIGN_CONCEPT_CATEGORIES: ReadonlyArray<{
+ id: CampaignConceptCategoryId
+ label: string
+}> = [
+ { id: 'base', label: 'Base do cálculo' },
+ { id: 'diagnostico', label: 'Diagnóstico' },
+ { id: 'meta', label: 'Meta e cobertura' },
+]
+
+export const campaignIntelligenceConcepts: ReadonlyArray = [
+ {
+ id: 'votos-validos-projetados',
+ categoryID: 'base',
+ title: 'Votos válidos projetados',
+ oneLiner:
+ 'Quantos votos válidos a disputa de deputado federal deve ter no município em 2026.',
+ formula:
+ 'válidos projetados = (válidos 2014 + válidos 2018 + 2 × válidos 2022) ÷ 4, sobre o 1º turno de deputado federal.',
+ example:
+ 'Um município com 20.000 válidos em 2014, 22.000 em 2018 e 24.000 em 2022 projeta (20.000 + 22.000 + 2 × 24.000) ÷ 4 = 22.500.',
+ whyItMatters:
+ 'É a régua de tamanho do município: entra no teto do campo projetado e, por consequência, na meta sugerida. 2022 pesa o dobro por ser a eleição mais próxima. É uma fórmula fixa e auditável, não uma previsão estatística — o produto não faz modelo de votos.',
+ whereItAppears:
+ 'Não aparece como número na tela; sustenta o teto do campo projetado e a meta sugerida.',
+ },
+ {
+ id: 'teto-do-campo',
+ categoryID: 'base',
+ title: 'Teto do campo (projetado)',
+ oneLiner:
+ 'Quantos votos o campo (PT e aliados de esquerda) pode alcançar no município em 2026.',
+ formula:
+ 'teto 2022 = votos do presidencial do campo no município (1º turno de 2022). Teto projetado = teto 2022 × (válidos projetados ÷ válidos de 2022).',
+ example:
+ 'Teto de 12.000 votos em 2022 num município que projeta crescer 5% de válidos vira um teto projetado de 12.600.',
+ whyItMatters:
+ 'A disputa majoritária mostra o tamanho real do campo no município — muito maior do que a eleição fragmentada de deputado federal consegue revelar. É o denominador da captura e o peso usado para decompor a meta estadual. Usa só 2022: a majoritária de 2014 e 2018 já foi importada, mas ainda não entra nestas contas.',
+ whereItAppears:
+ 'Card "Conta da cadeira", no detalhe do município ("Teto do campo (proj.)").',
+ },
+ {
+ id: 'captura',
+ categoryID: 'diagnostico',
+ title: 'Captura (2022)',
+ oneLiner:
+ 'Quanto do teto do campo o Jorge Solla efetivamente conquistou no município em 2022.',
+ formula: 'captura = votos de Solla para deputado federal em 2022 ÷ teto do campo de 2022.',
+ example: '1.200 votos de Solla sobre um teto de 12.000 = 10% de captura.',
+ whyItMatters:
+ 'Separa "município pequeno" de "município onde o campo é grande e a campanha não chegou". Captura baixa com teto alto é espaço a ocupar; captura alta é base a defender. É só diagnóstico: não entra no cálculo da meta, para não premiar o que já está consolidado nem punir onde ainda não se trabalhou.',
+ whereItAppears: 'Card "Conta da cadeira", no detalhe do município ("Captura (2022)").',
+ },
+ {
+ id: 'share-intracampo',
+ categoryID: 'diagnostico',
+ title: 'Share intracampo',
+ oneLiner:
+ 'Dos votos que o campo recebeu para deputado federal naquele ano, a fração que foi para Jorge Solla.',
+ formula:
+ 'share intracampo = votos de Solla no ano ÷ votos de todos os partidos do campo para deputado federal no mesmo ano.',
+ whyItMatters:
+ 'É a leitura do fogo amigo: o denominador é o próprio campo na mesma disputa, então o número mostra a divisão interna do voto de esquerda no município, ano a ano (2014, 2018, 2022). Diferente da captura, cujo denominador é o teto presidencial de 2022. O campo é uma lista curada de siglas por ano (2022 e 2026: PT, PC do B, PV), não a coligação registrada no TSE — coligações proporcionais juntam aliados sem afinidade só para somar quociente.',
+ whereItAppears: 'Card "Conta da cadeira", no detalhe do município ("Share intracampo").',
+ },
+ {
+ id: 'roll-off',
+ categoryID: 'diagnostico',
+ title: 'Roll-off (2022)',
+ oneLiner:
+ 'Quanto voto em branco e nulo a disputa de deputado federal atraiu a mais que a presidencial, no mesmo comparecimento.',
+ formula:
+ 'roll-off = (brancos + nulos de deputado federal em 2022) − (brancos + nulos do presidencial em 2022). O percentual mostra esse saldo sobre o comparecimento de deputado federal.',
+ whyItMatters:
+ 'Mede quanto eleitor comparece, vota para presidente e desiste de escolher deputado. Roll-off alto indica eleitorado disponível para quem chegar com nome e razão para votar — mas é diagnóstico de contexto, não um reservatório de votos garantidos. Também usa só 2022.',
+ whereItAppears: 'Card "Conta da cadeira", no detalhe do município ("Roll-off (2022)").',
+ },
+ {
+ id: 'meta',
+ categoryID: 'meta',
+ title: 'Meta e meta sugerida',
+ oneLiner: 'Quantos votos a campanha quer no município no cenário escolhido.',
+ formula:
+ 'meta = estimativa da mesa para o cenário (pessimista, média ou otimista), quando existe; sem estimativa, usa a meta sugerida. Meta sugerida = meta estadual × (teto do campo projetado do município ÷ soma dos tetos projetados dos 435 municípios).',
+ example:
+ 'Com meta estadual de 150.000 votos, um município que responde por 2% da soma dos tetos projetados recebe uma meta sugerida de 3.000.',
+ whyItMatters:
+ 'A estimativa da mesa sempre vence a sugestão: quem conhece o município decide. A meta sugerida existe para que nenhum dos 435 municípios fique sem régua enquanto a mesa não preencher. A decomposição é proporcional só ao teto projetado — captura e share ficam de fora de propósito, como diagnóstico. A meta estadual (e a margem de segurança) é editada em Metas da campanha.',
+ whereItAppears:
+ 'Card "Conta da cadeira" ("Meta usada na conta") e o campo de votos estimados no detalhe e na lista de municípios.',
+ },
+ {
+ id: 'cobertura-da-meta',
+ categoryID: 'meta',
+ title: 'Cobertura da meta',
+ oneLiner:
+ 'Quanto da meta já está coberto por compromissos de lideranças — não por expectativa da mesa.',
+ formula:
+ 'cobertura = comprometido ÷ meta. Comprometido = soma dos votos das lideranças do município (estimativa do cenário quando existe, senão o número declarado pela liderança). Déficit = meta − comprometido.',
+ example: 'Meta de 3.000 com 1.200 votos comprometidos em lideranças = 40% de cobertura, déficit de 1.800.',
+ whyItMatters:
+ 'É a diferença entre querer e ter: a expectativa da mesa define a meta, mas nunca conta como compromisso — se contasse, todo município com estimativa preenchida apareceria coberto sem uma única liderança por trás. O déficit descoberto é o que ordena onde a campanha precisa de mais rede.',
+ whereItAppears:
+ 'Início (staff), visão geral e coluna "Cobertura da meta" na lista de municípios, e o card "Conta da cadeira" no detalhe.',
+ },
+]
+
+export const campaignConceptsByCategory = (
+ categoryID: CampaignConceptCategoryId,
+): CampaignIntelligenceConcept[] =>
+ campaignIntelligenceConcepts.filter((concept) => concept.categoryID === categoryID)
+
+export const CAMPAIGN_CONCEPTS_PATH = '/campanha/conceitos'
+
+/** Anchor href for a documented concept — typed so a renamed id can't leave a dead link behind. */
+export const campaignConceptHref = (id: CampaignConceptId): string =>
+ `${CAMPAIGN_CONCEPTS_PATH}#${id}`
diff --git a/src/lib/electionInsights.ts b/src/lib/electionInsights.ts
index 4fa379b7..207e948a 100644
--- a/src/lib/electionInsights.ts
+++ b/src/lib/electionInsights.ts
@@ -138,7 +138,13 @@ export type GapVs2022Baseline = {
candidate: { votes: number }
} | null
-const numberFormatter = new Intl.NumberFormat('pt-BR')
+/**
+ * Vote counts are whole votes, including the derived ones (E8's decomposed
+ * suggested goal and its deficit are fractional). Without pinning the fraction
+ * digits, pt-BR renders a goal of 100.968 votes as "100,968" — read as a
+ * hundred thousand by every human in the room.
+ */
+const numberFormatter = new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 0 })
export const formatElectionNumber = (value: number): string => numberFormatter.format(value)
diff --git a/tests/e2e/campaignConcepts.e2e.spec.ts b/tests/e2e/campaignConcepts.e2e.spec.ts
new file mode 100644
index 00000000..ec453e36
--- /dev/null
+++ b/tests/e2e/campaignConcepts.e2e.spec.ts
@@ -0,0 +1,85 @@
+import { campaignConceptHref } from '../../src/lib/campaignIntelligenceConcepts.js'
+import { expect, test } from './fixtures/campaignE2EFixtures.js'
+
+/**
+ * E18 — `/campanha/conceitos` documents staff-only numbers (estimates, goals,
+ * field ceiling), so the route gate and the "Saiba mais" path out of the
+ * "Conta da cadeira" card are what these tests protect.
+ */
+test.describe('Conceitos de inteligência', () => {
+ test('staff reads the concepts page and reaches it from the goal-account card', async ({
+ campaign,
+ page,
+ }) => {
+ const { fixtures } = campaign
+ const password = fixtures.value('senha')
+ const email = `${fixtures.value('conceitos-coordinator')}@example.com`
+ await campaign.payload.create({
+ collection: 'campaignUser',
+ data: {
+ name: fixtures.value('Coordenadora Conceitos'),
+ email,
+ password,
+ role: 'coordinator',
+ },
+ depth: 0,
+ })
+ const municipality = await fixtures.claimMunicipality()
+
+ await campaign.login(page, email, password)
+
+ // Always-visible way in, at the foot of the sidebar.
+ await page.getByRole('link', { name: 'Conceitos', exact: true }).click()
+ await expect(page).toHaveURL(/\/campanha\/conceitos$/)
+
+ await page.goto(`/campanha/municipios/${municipality.slug}`)
+ const goalAccount = page.getByRole('region', { name: 'Conta da cadeira' })
+ await expect(goalAccount).toBeVisible()
+
+ // Per-metric tooltips carry a deep link to the matching anchor.
+ await goalAccount.getByRole('button', { name: /Captura \(2022\): mais informações/ }).hover()
+ // Radix mirrors tooltip content for screen readers, so the link renders twice.
+ await expect(page.getByRole('link', { name: 'Saiba mais' }).first()).toHaveAttribute(
+ 'href',
+ campaignConceptHref('captura'),
+ )
+ // The card-level Popover is the keyboard-reachable path (tooltip content is not tabbable).
+ await goalAccount.getByRole('button', { name: 'Sobre a conta da cadeira' }).click()
+ await page.getByRole('link', { name: 'Como cada número é calculado' }).click()
+
+ await expect(page).toHaveURL(/\/campanha\/conceitos$/)
+ await expect(page.getByRole('heading', { level: 1, name: 'Conceitos de inteligência' })).toBeVisible()
+ await expect(page.getByRole('heading', { name: 'Cobertura da meta' })).toBeVisible()
+ await expect(page.getByRole('heading', { name: 'Teto do campo (projetado)' })).toBeVisible()
+
+ // Deep link lands on the documented concept, not just on the page.
+ await page.goto(campaignConceptHref('captura'))
+ await expect(page.locator('article:target')).toHaveAttribute('id', 'captura')
+ })
+
+ test('leader cannot open the concepts page', async ({ campaign, page }) => {
+ const { fixtures } = campaign
+ const password = fixtures.value('senha')
+ const phone = fixtures.phone()
+ await campaign.payload.create({
+ collection: 'campaignUser',
+ data: {
+ name: fixtures.value('Liderança Conceitos'),
+ username: phone,
+ password,
+ role: 'leader',
+ },
+ depth: 0,
+ })
+
+ await campaign.login(page, phone, password)
+ await expect(page.getByRole('link', { name: 'Conceitos', exact: true })).toHaveCount(0)
+
+ await page.goto('/campanha/conceitos')
+
+ await expect(page).toHaveURL(/\/campanha\/?$/)
+ await expect(
+ page.getByRole('heading', { level: 1, name: 'Conceitos de inteligência' }),
+ ).toHaveCount(0)
+ })
+})
diff --git a/tests/e2e/setup.e2e.spec.ts b/tests/e2e/setup.e2e.spec.ts
index 0f860a0b..c33abf37 100644
--- a/tests/e2e/setup.e2e.spec.ts
+++ b/tests/e2e/setup.e2e.spec.ts
@@ -9,6 +9,7 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
'/campanha/municipios',
'/campanha/municipios/e2e-prewarm',
'/campanha/demandas',
+ '/campanha/conceitos',
'/campanha/convite/e2e-prewarm',
'/',
]) {
diff --git a/tests/unit/campaignIntelligenceConcepts.unit.spec.ts b/tests/unit/campaignIntelligenceConcepts.unit.spec.ts
new file mode 100644
index 00000000..e9429a4b
--- /dev/null
+++ b/tests/unit/campaignIntelligenceConcepts.unit.spec.ts
@@ -0,0 +1,82 @@
+// @vitest-environment node
+
+import { describe, expect, it } from 'vitest'
+
+import {
+ getCampaignBottomNav,
+ getCampaignNav,
+ getCampaignSecondaryNav,
+} from '@/components/campaign/nav'
+import {
+ CAMPAIGN_CONCEPT_CATEGORIES,
+ CAMPAIGN_CONCEPTS_PATH,
+ campaignConceptHref,
+ campaignConceptsByCategory,
+ campaignIntelligenceConcepts,
+} from '@/lib/campaignIntelligenceConcepts'
+
+/**
+ * The concept ids are URL anchors linked from tooltips across `/campanha`
+ * (`campaignConceptHref`), so a duplicate or anchor-unsafe id ships a dead
+ * "Saiba mais" link. Every concept must also belong to a rendered category —
+ * otherwise the page silently drops it.
+ */
+describe('campaignIntelligenceConcepts', () => {
+ it('has unique, anchor-safe ids', () => {
+ const ids = campaignIntelligenceConcepts.map((concept) => concept.id)
+ expect(new Set(ids).size).toBe(ids.length)
+ for (const id of ids) {
+ expect(id).toMatch(/^[a-z0-9-]+$/)
+ }
+ })
+
+ it('assigns every concept to a rendered category', () => {
+ const categoryIDs = new Set(CAMPAIGN_CONCEPT_CATEGORIES.map((category) => category.id))
+ for (const concept of campaignIntelligenceConcepts) {
+ expect(categoryIDs).toContain(concept.categoryID)
+ }
+ const grouped = CAMPAIGN_CONCEPT_CATEGORIES.flatMap((category) =>
+ campaignConceptsByCategory(category.id),
+ )
+ expect(grouped).toHaveLength(campaignIntelligenceConcepts.length)
+ })
+
+ it('documents what each number measures, how it is calculated, and where it shows up', () => {
+ for (const concept of campaignIntelligenceConcepts) {
+ expect(concept.title.length).toBeGreaterThan(0)
+ expect(concept.oneLiner.length).toBeGreaterThan(0)
+ expect(concept.formula.length).toBeGreaterThan(0)
+ expect(concept.whyItMatters.length).toBeGreaterThan(0)
+ expect(concept.whereItAppears.length).toBeGreaterThan(0)
+ }
+ })
+
+ it('builds anchor hrefs into the concepts page', () => {
+ expect(campaignConceptHref('cobertura-da-meta')).toBe('/campanha/conceitos#cobertura-da-meta')
+ })
+})
+
+/**
+ * The sidebar entry is the only always-visible way in, so it carries the same
+ * staff-only rule as the route itself — a `leader` offered the link would just
+ * be bounced back to `/campanha`.
+ */
+describe('concepts sidebar entry', () => {
+ it('is offered to staff and hidden from leaders', () => {
+ expect(getCampaignSecondaryNav('coordinator').map((item) => item.href)).toEqual([
+ CAMPAIGN_CONCEPTS_PATH,
+ ])
+ expect(getCampaignSecondaryNav('advisor')).not.toHaveLength(0)
+ expect(getCampaignSecondaryNav('candidate')).not.toHaveLength(0)
+ expect(getCampaignSecondaryNav('leader')).toHaveLength(0)
+ })
+
+ it('stays out of the work destinations and the mobile bottom bar', () => {
+ const hrefs = [
+ ...getCampaignNav('coordinator'),
+ ...getCampaignBottomNav('coordinator'),
+ ].map((item) => item.href)
+
+ expect(hrefs).not.toContain(CAMPAIGN_CONCEPTS_PATH)
+ })
+})
diff --git a/tests/unit/goalCoverage.unit.spec.ts b/tests/unit/goalCoverage.unit.spec.ts
index 54b7e90a..ab6ed1e9 100644
--- a/tests/unit/goalCoverage.unit.spec.ts
+++ b/tests/unit/goalCoverage.unit.spec.ts
@@ -4,6 +4,7 @@ import {
aggregateGoalCoverage,
computeGoalCoverage,
computeGoalCoverageByScenario,
+ formatGoalCoverageDeficitLabel,
} from '@/utilities/goalCoverage'
import { createEmptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeData'
@@ -75,6 +76,15 @@ describe('computeGoalCoverage', () => {
})
})
+describe('goal coverage labels', () => {
+ it('renders a fractional decomposed goal as whole votes', () => {
+ // A suggested goal of 100.968 votes must not read as "100,968" in pt-BR.
+ const aggregate = createEmptyMunicipalityPledgeAggregate()
+ const coverage = computeGoalCoverage(null, 100.968, aggregate, 'central')
+ expect(formatGoalCoverageDeficitLabel(coverage)).toBe('Faltam 101 votos para a meta')
+ })
+})
+
describe('aggregateGoalCoverage', () => {
it('sums goal and committed across municipalities and recomputes the ratio', () => {
const aggregate = aggregateGoalCoverage([