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). + */} + + +
+ {CAMPAIGN_CONCEPT_CATEGORIES.map((category) => ( +
+

+ {category.label} +

+
+ {campaignConceptsByCategory(category.id).map((concept) => ( + + ))} +
+
+ ))} +
+
+
+ ) +} 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([