Merge branch 'cursor/c6ecf9ef'
This commit is contained in:
commit
0cc91ac344
15 changed files with 626 additions and 27 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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/<slug>
|
|||
|
||||
- **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"
|
||||
|
|
|
|||
|
|
@ -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<br/>(seed estratégia)"]
|
||||
A11n["A11 ✓ Posição em votos"]
|
||||
E17n["E17 ✓ Tabela TI no Início"]
|
||||
E18n["E18 Documentação<br/>de conceitos"]
|
||||
E18n["E18 ✓ Documentação<br/>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
|
||||
|
||||
|
|
|
|||
133
src/app/(campaign)/campanha/(app)/conceitos/page.tsx
Normal file
133
src/app/(campaign)/campanha/(app)/conceitos/page.tsx
Normal file
|
|
@ -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 }) => (
|
||||
<article
|
||||
id={concept.id}
|
||||
// -mx-3 px-3: the target tint bleeds past the text column while the copy
|
||||
// stays aligned with the rest of the page.
|
||||
className="-mx-3 scroll-mt-6 rounded-lg px-3 py-5 target:bg-muted/50"
|
||||
>
|
||||
<h3 className="text-base font-semibold tracking-tight">{concept.title}</h3>
|
||||
<p className="mt-1">{concept.oneLiner}</p>
|
||||
|
||||
<dl className="mt-3 rounded-md bg-muted/60 p-3">
|
||||
<dt className="text-xs font-medium text-muted-foreground">Como é calculado</dt>
|
||||
<dd className="mt-1 font-mono text-xs leading-relaxed">{concept.formula}</dd>
|
||||
{concept.example ? (
|
||||
<>
|
||||
<dt className="mt-3 text-xs font-medium text-muted-foreground">Exemplo</dt>
|
||||
<dd className="mt-1 text-xs leading-relaxed">{concept.example}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
|
||||
{/* The substance of the page, so it stays at full ink — muted is for the meta line only. */}
|
||||
<p className="mt-3">{concept.whyItMatters}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium">Onde aparece:</span> {concept.whereItAppears}
|
||||
</p>
|
||||
</article>
|
||||
)
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Conceitos de inteligência</h1>
|
||||
<p className="max-w-prose text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="lg:grid lg:grid-cols-[minmax(0,1fr)_15rem] lg:items-start lg:gap-10">
|
||||
{/*
|
||||
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).
|
||||
*/}
|
||||
<nav
|
||||
aria-label="Conceitos nesta página"
|
||||
className="hidden lg:sticky lg:top-0 lg:col-start-2 lg:row-start-1 lg:block"
|
||||
>
|
||||
<p className="text-sm font-medium">Nesta página</p>
|
||||
<div className="mt-3 flex flex-col gap-4">
|
||||
{CAMPAIGN_CONCEPT_CATEGORIES.map((category) => (
|
||||
<div key={category.id}>
|
||||
<p className="text-xs text-muted-foreground">{category.label}</p>
|
||||
{/* The public-site base `ul` is a bulleted, indented article list. */}
|
||||
<ul className="mt-1 mb-0 ml-0 flex list-none flex-col pl-0 [&>li]:mt-0">
|
||||
{campaignConceptsByCategory(category.id).map((concept) => (
|
||||
<li key={concept.id}>
|
||||
<a
|
||||
href={`#${concept.id}`}
|
||||
className="block py-1 text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||
>
|
||||
{concept.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="flex max-w-prose flex-col gap-8 lg:col-start-1 lg:row-start-1">
|
||||
{CAMPAIGN_CONCEPT_CATEGORIES.map((category) => (
|
||||
<section key={category.id} aria-labelledby={`concept-category-${category.id}`}>
|
||||
<h2
|
||||
id={`concept-category-${category.id}`}
|
||||
className="text-lg font-semibold tracking-tight"
|
||||
>
|
||||
{category.label}
|
||||
</h2>
|
||||
<div className="[&>article+article]:border-t">
|
||||
{campaignConceptsByCategory(category.id).map((concept) => (
|
||||
<ConceptSection key={concept.id} concept={concept} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}) => (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={isActive}>
|
||||
<Link href={item.href} onClick={onNavigate}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
|
||||
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<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
|
@ -64,23 +93,35 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
|
|||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{getCampaignNav(user.role).map((item) => (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton asChild isActive={isCampaignNavActive(pathname, item.href)}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={() => {
|
||||
if (isMobile) setOpenMobile(false)
|
||||
}}
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<CampaignSidebarLink
|
||||
key={item.href}
|
||||
item={item}
|
||||
isActive={isCampaignNavActive(pathname, item.href)}
|
||||
onNavigate={closeMobileSidebar}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{/* 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 ? (
|
||||
<SidebarGroup className="mt-auto">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{secondaryNav.map((item) => (
|
||||
<CampaignSidebarLink
|
||||
key={item.href}
|
||||
item={item}
|
||||
isActive={isCampaignNavActive(pathname, item.href)}
|
||||
onNavigate={closeMobileSidebar}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
) : null}
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="border-t border-sidebar-border">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p>{lead}</p>
|
||||
{formula ? <p className="text-background/70">{formula}</p> : null}
|
||||
<Link href={campaignConceptHref(conceptID)} className="font-medium underline underline-offset-2">
|
||||
Saiba mais
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
|
@ -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.
|
||||
</p>
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<Link
|
||||
href={CAMPAIGN_CONCEPTS_PATH}
|
||||
className="font-medium text-primary underline underline-offset-4"
|
||||
>
|
||||
Como cada número é calculado
|
||||
</Link>
|
||||
</div>
|
||||
</CampaignInfoHint>
|
||||
</div>
|
||||
|
|
@ -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"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement | null>(null)
|
||||
const contentRef = useRef<HTMLDivElement | null>(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 = ({
|
|||
<Tooltip open={open} onOpenChange={setOpen}>
|
||||
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
ref={contentRef}
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
BookOpenIcon,
|
||||
CalendarDaysIcon,
|
||||
HandshakeIcon,
|
||||
HomeIcon,
|
||||
|
|
@ -29,6 +30,20 @@ const staffNav: CampaignNavItem[] = [
|
|||
{ title: 'Apoiadores', href: '/campanha/apoiadores', icon: UsersIcon },
|
||||
]
|
||||
|
||||
/**
|
||||
* Reference material, not a place to work: sits in its own group at the foot
|
||||
* of the sidebar, below the destinations, and never in the mobile bottom bar.
|
||||
* Staff-only — every documented number is one a `leader` never sees.
|
||||
*
|
||||
* The href is a literal, not an import from `campaignIntelligenceConcepts.ts`:
|
||||
* this module reaches client components (`CampaignSidebar`), and that one
|
||||
* carries the full glossary content — pulling it in for a single path string
|
||||
* would put the whole glossary in the client bundle.
|
||||
*/
|
||||
const staffSecondaryNav: CampaignNavItem[] = [
|
||||
{ title: 'Conceitos', href: '/campanha/conceitos', icon: BookOpenIcon },
|
||||
]
|
||||
|
||||
const leaderNav: CampaignNavItem[] = [
|
||||
// The leader home IS the contact tool — one entry, one href (duplicate
|
||||
// hrefs previously produced duplicate React keys in the sidebar).
|
||||
|
|
@ -43,6 +58,9 @@ export const getCampaignNav = (role: CampaignUser['role']): CampaignNavItem[] =>
|
|||
)
|
||||
}
|
||||
|
||||
export const getCampaignSecondaryNav = (role: CampaignUser['role']): CampaignNavItem[] =>
|
||||
isCampaignStaffRole(role) ? staffSecondaryNav : []
|
||||
|
||||
/**
|
||||
* Compact set for the mobile bottom bar (max 5 items with a home slot).
|
||||
*/
|
||||
|
|
|
|||
160
src/lib/campaignIntelligenceConcepts.ts
Normal file
160
src/lib/campaignIntelligenceConcepts.ts
Normal file
|
|
@ -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<CampaignIntelligenceConcept> = [
|
||||
{
|
||||
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}`
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
85
tests/e2e/campaignConcepts.e2e.spec.ts
Normal file
85
tests/e2e/campaignConcepts.e2e.spec.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
'/',
|
||||
]) {
|
||||
|
|
|
|||
82
tests/unit/campaignIntelligenceConcepts.unit.spec.ts
Normal file
82
tests/unit/campaignIntelligenceConcepts.unit.spec.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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([
|
||||
|
|
|
|||
Loading…
Reference in a new issue