E8 follow-up: touch-friendly metric tooltips, 2014/2018 majoritarian seed, E18 docs plan

- MunicipalityHoverTooltip/MunicipalityGoalAccountCard: hover/focus tooltips on
  the 4 "Conta da cadeira" diagnostic numbers, hardened per /impeccable
  critique (touch dead-end fix via controlled open + pointerup toggle +
  outside-tap dismiss, tooltip no longer covers "Cobertura da meta", clearer
  two-tier copy, distinguishable button trigger, thicker 0% progress bar).
- seed-tse-results.mjs/electionResultsBuild/electionResultsParse: seed also
  imports presidente/governador for 2014/2018 (previously deputado federal
  only), with vote-em-transito duplicate merging so the unique constraints
  hold; artifact/formulas stay 2022-only by design.
- Registered E18 (documentacao-conceitos-campanha) roadmap item/plan for a
  future /campanha/conceitos glossary page.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-24 22:52:04 -03:00
parent 5a4a9033ed
commit 717d584f6d
13 changed files with 632 additions and 62 deletions

View file

@ -0,0 +1,96 @@
---
target: Conta da cadeira card (/campanha/municipios/<slug>) — MunicipalityGoalAccountCard.tsx tooltips
total_score: 26
p0_count: 0
p1_count: 3
timestamp: 2026-07-25T01-04-34Z
slug: omponents-campaign-municipalitygoalaccountcard-tsx
---
Method: dual-agent (A: 92b63fbf-f200-4a00-9cc5-81db3f317b57 · B: 35312920-2be2-4bac-92aa-0fbb62c2a571)
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| 1 | Visibility of System Status | 3 | Coverage %, deficit text, and provenance ("estimativa da mesa") are all stated plainly; the 0%-coverage `Progress` bar is a near-invisible 4px hairline, understating urgency at the most common early-campaign state. |
| 2 | Match Between System and Real World | 2 | "Teto do campo," "share intracampo," "roll-off," "campo" are coined composites — confirmed by the product's own coordinator calling these "conceitos complicados" *after* this exact tooltip fix shipped (`docs/plans/documentacao-conceitos-campanha.md`). |
| 3 | User Control and Freedom | 3 | Popover/tooltip dismiss cleanly (Escape, click-outside, blur); no traps. |
| 4 | Consistency and Standards | 2 | Two disclosure idioms collide in one card: `CampaignInfoHint` opens-on-click-and-stays-open; the metric `Tooltip` closes-on-click and never opens on click. A user primed by the "?" icon one line above will click a metric next and get nothing, or dismiss an open tooltip. |
| 5 | Error Prevention | 3 | n/a mostly (read-only diagnostics); sane zero/negative fallbacks ("Sem meta definida," "Sem teto do campo projetado..."). |
| 6 | Recognition Rather Than Recall | 2 | Dotted-underline is a fair desktop cue, but on touch there is **no cue at all** — confirmed by both assessments (Radix's own touch-suppression logic, and no tap affordance reachable at 390×844). |
| 7 | Flexibility and Efficiency of Use | 3 | `Tab` reaches every metric and opens its tooltip on focus with a visible ring — solid keyboard path, measured live. |
| 8 | Aesthetic and Minimalist Design | 3 | Restrained, on-brand, correctly dense for a staff surface; undermined live by the tooltip/popover collision covering sibling content (screenshotted at both desktop and 390px). |
| 9 | Error Recovery | 3 | n/a — no error states exercised; fallback copy is clear and non-technical. |
| 10 | Help and Documentation | 2 | Good *density* of contextual help (card popover + 4 inline tooltips), not yet *effective* — field evidence says the explanations didn't resolve confusion, and there's no link to deeper docs yet (that's roadmap E18). |
| **Total** | | **26/40** | **Acceptable — significant improvements needed before users are happy** |
## Anti-Patterns Verdict
**LLM assessment**: Not AI slop. This is a real derived-data card built on the existing token system — flat `rounded-xl` card, hairline border, stone/red restraint, `tabular-nums`, dotted-underline instead of a fifth icon button. No purple-gradient/hero-metric/generic-SaaS tells. The actual risk is the opposite: the tooltip follow-up added this session has the texture of a fast patch (dense unbroken-paragraph copy, a disclosure-pattern collision) rather than a considered craft pass.
**Deterministic scan**: `detect.mjs --json` against the three target files (`MunicipalityGoalAccountCard.tsx`, `CampaignInfoHint.tsx`, `MunicipalityHoverTooltip.tsx`) returned **exit 0, zero findings** — the code itself carries no static anti-pattern signatures. The bundled live-overlay injection (`live-server.mjs` + `detect.js`) separately surfaced 7 **page-wide** console findings (line-length ~135 chars/line, overused/single-font Inter, one `layout-transition: height`, and `nested-cards` ×3) — these scan the whole rendered page (header/sidebar included) and are **not confirmed scoped to the 3 target files**; treat as pre-existing shell-level context, not new debt from this session's work. One `nested-cards` hit plausibly matches this card's own "Votos estimados" `rounded-lg bg-muted/40` box sitting inside the card's `rounded-xl border` section, but this wasn't isolated with certainty.
**Visual overlays**: No standing overlay was left running — the live-server was started, injected, read, and stopped as part of the evidence-gathering process; nothing persists in your browser to check.
## Overall Impression
The card itself is well-crafted and on-brand — restrained, honest about data provenance, and genuinely keyboard-accessible. But the tooltip follow-up built this session to fix a real comprehension gap **does not work at all on touch**, and where it does work, the bubble physically covers the "Cobertura da meta" figure it's meant to clarify. Both are P1s confirmed with hard evidence (Radix's own source, live screenshots at two viewports), not opinion. The biggest opportunity is recognizing that hover/focus disclosure may be the wrong mechanism for this density of explanation in the first place — worth weighing before this pattern gets copied onto more metrics as E9–E17 land.
## What's Working
1. **`CampaignInfoHint`'s copy is intellectually honest**: "Comprometido é só a soma das declarações de lideranças — nunca a meta," plus the explicit 2022-only caveat for roll-off/teto do campo. It pre-answers the obvious follow-up and admits data limitations instead of hiding them.
2. **Keyboard accessibility of the metric tooltips is genuinely solid, not a token gesture**: `tabIndex={0}` + a visible Mandate-Red `focus-visible` ring + tooltip opening on focus with proper `aria-describedby` — verified live via real `Tab` navigation.
3. **On-brand restraint**: flat card, hairline border, `tabular-nums`, dotted-underline instead of a fifth "?" icon — respects the Signal Red Rule (red stays on "Editar") and Flat-By-Default. Tooltip contrast measured at 17.49:1 (white on near-black) — no readability issue once a tooltip is actually visible.
## Priority Issues
**[P1] Touch users cannot open the metric tooltips at all.**
- **Why it matters**: PRODUCT.md names "used in the field and on the go" as this surface's defining context, and this exact follow-up exists for comprehension. It's unreachable on the device class that context implies.
- **What/evidence**: `@radix-ui/react-tooltip@1.2.8`'s `TooltipTrigger` explicitly ignores touch pointer events and gates focus-open on a pointer-down ref that a tap sets first — confirmed against the installed source, not inference. At 390×844, no tap affordance exists for any of the 4 metrics; the adjacent "Editar votos estimados" control works fine at the same width, so this reads as an oversight, not a deliberate mobile choice.
- **Fix**: Add an explicit tap trigger — drop `asChild` for a focusable/clickable element with its own `onClick` toggling open, or give each metric a small click-openable affordance like `CampaignInfoHint`'s.
- **Suggested command**: `/impeccable harden`
**[P1] Tooltips render on top of the number/coverage block they explain.**
- **Why it matters**: The moment someone seeks clarification is the moment the clarified figure disappears — a direct "one thing at a time" violation at the card's highest-stakes row.
- **What/evidence**: Reproduced independently by both assessments via hover, real keyboard focus, and screenshots at 1280×720 and 390×844 — the bubble (Radix collision-flips to `data-side="top"` since the trigger sits near the viewport bottom) fully covers "Cobertura da meta" / the progress bar / "Faltam X votos para a meta" at both widths.
- **Fix**: Force collision-safe placement for this usage (`side="top"` with generous `sideOffset` plus bottom padding, or reorder the diagnostic row above the coverage block so there's room for the tooltip to open without overlap).
- **Suggested command**: `/impeccable layout`
**[P1] Tooltip/popover copy is dense, cross-referential jargon — and the real user already said it didn't help.**
- **Why it matters**: `docs/plans/documentacao-conceitos-campanha.md` quotes the actual coordinator calling these "conceitos complicados" *after* these exact tooltips shipped — the fix under critique didn't fix the problem it was built to fix.
- **What/evidence**: e.g. the "Teto do campo" explanation is one ~40-word sentence with three technical terms and no break; "Share intracampo"'s explanation requires recalling "Captura"'s definition mid-read, but the two tooltips can never be open at the same time (opening one closes the other) — a genuine working-memory violation.
- **Fix**: Split each explanation into a short lead sentence (what it measures) + a visually secondary formula line; bold only the one term each tooltip defines; stop cross-referencing metrics that can't be open simultaneously — state the difference inline instead of by reference.
- **Suggested command**: `/impeccable clarify`
**[P2] Two incompatible disclosure idioms coexist in one card.**
- **Why it matters**: Breaking Consistency *within* a single component teaches the wrong mental model in real time — a user who just learned "click the small icon" naturally tries clicking a metric next and gets nothing or an unwanted dismissal.
- **What/evidence**: `CampaignInfoHint` = click-to-open-and-stay-open Popover; `GoalAccountMetric` = hover/focus Tooltip that closes on click (Radix default), never opens on click.
- **Fix**: Pick one idiom for this card, or make the "click for detail" vs. "hover/focus for definition" visual distinction stronger than dotted-underline vs. circle-icon alone.
- **Suggested command**: `/impeccable clarify`
**[P3] The 0%-coverage progress bar is nearly invisible.**
- **Why it matters**: Understates urgency exactly when it should read loudest (0% coverage is the most common early-campaign state).
- **Fix**: Give the near-zero state deliberate visual weight instead of relying solely on the adjacent "0%" text.
- **Suggested command**: `/impeccable polish`
## Persona Red Flags
**Alex (Impatient Power User — the coordinator in the weekly meeting)**: Wants "does the account close" in one glance; instead has to learn two disclosure idioms in the same card, and hovering a diagnostic metric to double-check a number before reporting it upward covers that exact number with the tooltip bubble.
**Sam (Accessibility-Dependent, keyboard/screen reader)**: Genuinely well-served on keyboard (visible focus ring, `Tab` reaches every metric, proper `aria-describedby`). Red flag: nothing in the accessible name/role of the `tabIndex={0}` wrapper signals "this one has extra content" in advance — unlike `CampaignInfoHint`, which is an explicitly-named button — so a screen-reader user has to tab-and-listen speculatively.
**Casey (Distracted Mobile User — "field and on the go")**: Confirmed dead end. Tapping any of the 4 metrics mid-conversation with a leadership contact produces silence — no tooltip, no feedback, no error — verified against Radix's own touch-suppression logic and a live 390×844 render. The adjacent "Editar votos estimados" control works fine at the same width, so the gap reads as an oversight, not a deliberate choice.
## Minor Observations
- Inconsistent naming for the same race across the card: "deputado federal," "DF," and the card's own title "cadeira" all mean the same office; a first-time reader must infer the synonymy.
- `CampaignInfoHint`'s popover runs three distinct ideas (meta/comprometido/2022-only caveat) into one unbroken `<p>` — a blank line between them would help scanning under pressure.
- Measured: the "?" button is exactly 44×44px (meets the enhanced WCAG 2.2 target size); the 4 metric touch-target wrappers measured only 36px tall — below that same bar, compounding the touch dead-end above rather than causing it.
- Measured: metric label contrast (`text-muted-foreground`, 12px) is ≈4.83:1 — passes AA (≥4.5:1) but with a thin 0.33 margin; tooltip content itself measured 17.49:1, no issue there.
- `sm:grid-cols-4` collapses to 2×2 below `sm`; mid-size tablet/foldable widths weren't tested and are worth a pass as more metrics join this row (E9–E17).
## Questions to Consider
- If the coordinator needed a *separate* documentation surface (E18) days after these exact tooltips shipped specifically because they didn't resolve the confusion, is hover/focus disclosure the right mechanism for this density of explanation at all — or does the card need one worked example inline before a glossary page ever ships?
- "Captura" and "Share intracampo" exist as two numbers whose entire tooltip copy is dedicated to explaining how they differ from each other — should this be one metric with a denominator toggle instead of two numbers that each require reading the other's footnote?
- As E9–E17 add more diagnostic metrics to this row, does the card need a settled disclosure model *now* — before there are 8 metrics fighting over two incompatible idioms instead of 4?

View file

@ -32,6 +32,12 @@ Todas as 6 fases implementadas conforme o plano de execução, com os ajustes da
- **Verificação:** `tsc --noEmit`, `pnpm lint`, `pnpm exec knip`, unit + int (incl. `tests/int/campaignGoalsAccess.int.spec.ts` cobrindo a matriz de acesso por papel) e `pnpm build` verdes contra o banco local; `pnpm test:e2e` verde quando executado isolado (flakiness de timeout/`ECONNRESET` observada só sob contenção de recursos do sandbox com múltiplos dev servers em paralelo — não é regressão de código; 1 assertion ajustada para um seletor Playwright não-ambíguo pós-mudança de UI).
- **Divergências do plano original:** nenhuma decisão de produto mudou; os 6 achados da auditoria (seção acima) e as decisões travadas na sessão foram seguidos à risca. `territoryWarnings` (sanity check por TI) ficam computados e disponíveis em `municipalityPotential.ts`/`computeStatewideGoalDecomposition`, mas **não** ganharam superfície de UI nesta entrega — o brief de shape aprovado (Fase 4a) cobriu só as 4 integrações (dashboard, overview, lista, detalhe); exibir os avisos por TI é candidato natural para E12 (camada TI) ou um fill-in futuro.
### Follow-up pós-entrega (mesmo dia, feedback do usuário)
1. **Tooltips nos 4 números de diagnóstico.** O card `MunicipalityGoalAccountCard.tsx` só tinha o `CampaignInfoHint` ("?") no título; os 4 números do bloco de diagnóstico (teto do campo, captura, share intracampo, roll-off) ficaram sem explicação inline. Adicionado `GoalAccountMetric` (Tooltip do shadcn/ui, hover + foco por teclado, sem ícone de interrogação — pedido explícito do usuário) envolvendo cada par `dt`/`dd`.
2. **Majoritária 2014/2018: útil, seed expandido.** A auditoria (linha 12 acima) tinha fechado o rabbit hole do roll-off "só para 2022" citando que 2014/2018 só tinham deputado federal seedado. Decisão: essa lacuna é útil de fechar (diagnóstico histórico futuro), então `scripts/seed-tse-results.mjs` passou a importar presidente + governador também para 2014/2018 (`HISTORICAL_BASELINE_OFFICES` em `electionResultsBuild.ts`), carregando sempre os arquivos BR (nacionais) de voto/apuração — os votos presidenciais por município da Bahia vivem nesses arquivos, não nos BA. Isso expôs duplicatas de "voto em trânsito" (só existem no cargo presidente) que colidiam com o índice único de `ElectionCandidateVote`/`electionTally` em 2014 (BA, zona 154, e outras); corrigido somando os splits (`mergeDuplicateVoteRows`/`mergeDuplicateTallyRows` em `electionResultsParse.ts`). **Escopo mantido:** só o banco local ganhou os dados; o artefato commitado (`bahia-federal-baseline.json`) e as fórmulas de `municipalityPotential.ts` (teto do campo, roll-off) continuam 2022-only por design — estender isso é trabalho futuro, não pedido nesta sessão. Textos na UI/comentários que diziam "não seedado" foram corrigidos para não ficarem factualmente errados.
3. **`/impeccable critique` no card + hardening completo (mesmo dia).** Critique sobre `MunicipalityGoalAccountCard.tsx`/`CampaignInfoHint.tsx`/`MunicipalityHoverTooltip.tsx` (persistido em `.impeccable/critique/2026-07-25T01-04-34Z__omponents-campaign-municipalitygoalaccountcard-tsx.md`, 26/40) achou 2×P1 confirmados com evidência de browser real (Playwright, 1280×720 e 390×844): (a) as 4 tooltips de métrica eram **inalcançáveis por toque** — `@radix-ui/react-tooltip` ignora `pointerType: 'touch'` de propósito, e o card é usado "no campo"; (b) a bolha colidia (`side` flip do Radix) e cobria a linha "Cobertura da meta" logo abaixo do título. Mais 2×P2 (idiomas de disclosure incompatíveis no mesmo card — Popover clica-e-fica-aberto vs. Tooltip hover/foco que nunca abre no clique; cópia densa com referência cruzada entre "Captura" e "Share intracampo" que não podem estar abertas ao mesmo tempo) e 1×P3 (barra de progresso a 0% quase invisível, `h-1`). Todos endereçados na mesma sessão: `MunicipalityHoverTooltip.tsx` ganhou estado controlado com um `onPointerUp` que só reage a `pointerType === 'touch'` (mouse/teclado continuam 100% no hover/foco nativo do Radix — sem o bug simétrico de "clique fecha o que o hover acabou de abrir") e um listener de `pointerdown` fora do gatilho que fecha a bolha (Radix não tem dismiss-layer para Tooltip, só para Popover) — abrir a métrica B fecha a métrica A "de graça", já que é só um "toque fora" do ponto de vista de A. `GoalAccountMetric` trocou o `div tabIndex` por um `<button>` real com `aria-label` (toque confiável + nome acessível anunciado antes de abrir, achado da persona Sam) e `side="right" align="start"` (a bolha nunca mais colide no eixo vertical com "Cobertura da meta" — cresce para o lado e para baixo, não para cima). Cópia das 4 métricas dividida em frase-líder (termo em negrito) + linha de fórmula secundária, sem mais cross-reference (Share intracampo agora reexplica o denominador inline). Barra de progresso local (`className="h-2"`, só nesta instância — `Progress` é compartilhado com planos/lista/dashboard, não alterado globalmente). Verificado com toque sintético via Playwright (`PointerEvent` com `pointerType: 'touch'`) em 1280×720 e 390×844: abre no toque, troca de métrica fecha a anterior, toque fora fecha, nunca cobre a linha de cobertura. `tsc`/`lint`/`knip`/unit (323 testes)/`pnpm build`/Aikido verdes; só os 2 arquivos de componente mudaram.
## Design (Impeccable)
Âncoras: `PRODUCT.md` (princípio 5 — métricas relativas e locais; anti-goal % estadual absoluto) / `DESIGN.md` (register `product`) · tema `data-theme='campaign'`.
@ -70,7 +76,7 @@ O relatório de discovery ([§5.1](../research/relatorio-entrevista-persona-camp
- **Projeção de válidos: média simples da série ou tendência?** **Resolvido (auditoria 2026-07-24):** média ponderada com peso 2 para 2022 — `(v2014 + v2018 + 2×v2022) / 4`. Barata, estável, documentada como não-previsão (fora de escopo regressão/tendência).
- **Teto do campo: presidencial ou governador?** **Assumido (validar com produto):** presidencial do campo T1 2022 (#13) como série primária; governador (também #13, mesmo campo) como leitura secundária de diagnóstico — presidencial tem participação mais homogênea entre municípios pequenos e grandes.
- **Forma da decomposição meta→município?** **Resolvido:** proporcional ao teto do campo projetado — `suggestedGoal_i = metaEfetiva × tetoProjetado_i / Σ tetoProjetado` — não uma fórmula composta com captura/share/roll-off como pesos (isso viraria um modelo preditivo multifatorial, rejeitado). Captura, share intracampo e roll-off ficam como **colunas de diagnóstico**, não como entradas da conta.
- **Roll-off: quais anos?** **Resolvido:** só 2022 (única eleição seedada com majoritária T1 completa por zona); nota na UI explicando a limitação em vez de calcular para 2014/2018.
- **Roll-off: quais anos?** **Resolvido:** só 2022 por design (artefato/fórmulas não cortam a fatia majoritária para outros anos); nota na UI explicando a limitação em vez de calcular para 2014/2018. O seed local passou a importar majoritária 2014/2018 também (ver "Follow-up pós-entrega" acima), mas isso não muda esta decisão — é dado disponível para uso futuro, não uma extensão da conta atual.
## Abordagem proposta
@ -118,7 +124,7 @@ Componentes:
- **Projeção virar modelo preditivo.** Se alguém "melhorar" com regressão/ML: viola decisão de escopo do ciclo. **Mitigação:** média ponderada fixa, documentada como não-previsão.
- **Decomposição automática sobrescrever `expectedVotes`.** **Mitigação:** meta sugerida é coluna separada; manual sempre vence; nunca escrever em `expectedVotes` por job.
- **Roll-off por zona de Salvador sem tally do majoritário na mesma malha.** **Fechado por evidência (auditoria 2026-07-24):** o banco local seedado tem presidente/governador T1 e T2 por zona para 2022 (450 células por escopo, cobrindo as 19 zonas de Salvador); não existe para 2014/2018 (só deputado federal foi seedado nesses anos) — roll-off fica 2022-only, com nota na UI.
- **Roll-off por zona de Salvador sem tally do majoritário na mesma malha.** **Fechado por evidência (auditoria 2026-07-24):** o banco local seedado tem presidente/governador T1 e T2 por zona para 2022 (450 células por escopo, cobrindo as 19 zonas de Salvador). Mais tarde no mesmo dia o seed passou a cobrir presidente/governador para 2014/2018 também (ver "Follow-up pós-entrega"), mas roll-off continua 2022-only — decisão de escopo da fórmula, não limitação de dado.
## Adiado com gatilho

View file

@ -0,0 +1,107 @@
# Documentação de conceitos de inteligência de campanha
Status: rascunho
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
Appetite: ~0,5–1 dia eng (página estática + módulo de conteúdo + 4 links "saiba mais" no card existente)
Responsável: —
## Design (Impeccable)
Âncoras: `PRODUCT.md` (register `product`) · `DESIGN.md` (tema `data-theme='campaign'`).
Na implementação (`implement-roadmap-item`): shape compacto (já feito por este brief) → craft → critique → polish. `harden`/`optimize` só se o Passo 8 do `implement-roadmap-item` acionar gatilho.
Brief compacto:
- **Persona / contexto:** Alex (CG) ou assessor lendo um card de inteligência (ex.: "Conta da cadeira") e não lembrando o que "teto do campo", "captura" ou "roll-off" significam — quer a explicação sem sair do fluxo, e sem precisar perguntar no grupo.
- **Job principal:** explicar, em prosa e fórmula, o que cada número de inteligência do produto mede e como é calculado — não é um FAQ geral do app.
- **Estratégia de cor:** Restrained (default) — página de leitura, sem hero/gauge.
- **Edit where you see:** não se aplica (conteúdo é read-only, curado por PR — ver Decisões travadas).
- **Anti-goals:** wiki geral do `/campanha` (onboarding, "como criar um plano"); FAQ de produto; fórmula renderizada matematicamente (KaTeX) para um público que não é acadêmico; segunda navegação paralela na sidebar.
## Dados → decisão → apresentação
- **Vou apresentar dados?** Não (N/A). A página só explica em prosa fórmulas já expostas em E8 (e, com o tempo, E9–E17); no máximo um exemplo numérico ilustrativo **estático** (ex.: o par hipotético "Feira de Santana: meta 3.000 / comprometido 1.200" já usado nesta sessão), nunca um cálculo ao vivo sobre um município real.
- **Decisões desbloqueadas:** nenhuma — este item explica decisões que **outras** superfícies (E8 hoje; E9–E17 depois) já habilitam. Não é vaidade porque resolve confusão de vocabulário real e evidenciada nesta própria sessão, mas não é ele quem decide alocação.
- **Forma escolhida:** prosa + fórmula em texto monospace, seção por conceito (degrau mais pobre da escada). **Rejeitado:** exemplo vivo computado de município real (2ª superfície de dados dentro da doc, foco de manutenção — ver Rabbit holes); fórmula em KaTeX/notação matemática (lib nova para um único consumidor, público não é acadêmico).
- **Anti-goals de dado:** N/A (sem dado).
## Contexto
O card "Conta da cadeira" no detalhe do município (`/campanha/municipios/<slug>`, `MunicipalityGoalAccountCard.tsx`) ganhou nesta mesma sessão tooltips de hover (`GoalAccountMetric`) explicando os 4 números de diagnóstico — teto do campo projetado, captura 2022, share intracampo e roll-off — depois que o usuário relatou não entender a que cada número se referia. Depois de ler as explicações, o próprio usuário reconheceu: "são análises muito interessantes […] mas são conceitos complicados […] importância de criarmos uma página de documentação para explicar cada conceito de inteligência usado na vertical `/campanha` e como estes valores são calculados." Isso é evidência de campo concreta e datada (2026-07-24) do mesmo tipo que a questão em aberto **O3** ("jargão") do plano-mestre [`inteligencia-campanha.md`](inteligencia-campanha.md) e do roadmap pedia antes de investir além do R6 — mas o produto ainda não tem lugar para acumular essas explicações fora do próprio tooltip pontual, e o programa de inteligência (E9–E17) vai adicionar mais conceitos (LQ, quantis, níveis N0–N4, padrões de sugestão P1–P8, elegibilidade de giro) que vão precisar da mesma explicação.
## Objetivos
- Página staff-only `/campanha/conceitos` documentando, em v1, os conceitos já entregues em **E8**: teto do campo projetado, captura, share intracampo, roll-off, meta/meta sugerida e cobertura da meta — a mesma lista já coberta pelos tooltips de `MunicipalityGoalAccountCard.tsx` e pelo `CampaignInfoHint` do card.
- Cada conceito documentado com: nome, uma frase do que mede, fórmula em texto, "por que isso importa" (ligação com a decisão que ele habilita — ex.: captura é só diagnóstico, não entra na meta).
- Links contextuais "saiba mais" a partir dos tooltips/`CampaignInfoHint` existentes que já mencionam esses conceitos, apontando para a âncora certa da página (`/campanha/conceitos#<id>`) — sem duplicar o texto do tooltip, só estendê-lo.
- Sem migration, sem collection, sem Consent, sem server action.
## Decisões travadas
- **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).
- **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
- **Escopo de v1: só E8 ou esperar o programa fechar?** **Opções:** A) documentar só os conceitos já entregues (E8) | B) esperar E9–E17 fecharem para lançar uma página completa. **Recomendação:** A — a confusão evidenciada agora é sobre E8; cada item subsequente do programa (E9, E10, B13, E11, E12, E13, E14) ganha sua própria seção como parte do checklist de verificação desse item (`implement-roadmap-item` Passo de verificação), evitando um E18-v2 que espera 6 outros itens não construídos.
- **Onde mora o conteúdo (TSX puro vs MDX)?** **Opções:** A) array TS de "conceito" renderizado por um componente | B) arquivos MDX. **Recomendação:** A — reaproveita `Card`/tipografia shadcn já no produto; MDX adiciona toolchain nova para um único consumidor.
- **Fórmulas com notação matemática (KaTeX)?** **Opções:** A) texto monospace/pseudo-notação (`captura = pledges ÷ tetoDoCampo`) | B) lib de renderização matemática. **Recomendação:** A — nenhum outro lugar do produto usa notação matemática; público é staff de campo, não acadêmico.
## Abordagem proposta
```mermaid
flowchart LR
Content["campaignIntelligenceConcepts.ts<br/>(array de conceitos, src/lib)"]
Page["/campanha/conceitos<br/>page.tsx (staff-only)"]
Card["MunicipalityGoalAccountCard.tsx<br/>GoalAccountMetric + CampaignInfoHint"]
Content --> Page
Card -.link saiba mais.-> Page
```
Componentes:
- **`src/lib/campaignIntelligenceConcepts.ts`** (novo, client-safe, dado puro): array de `{ id, category, title, oneLiner, formula, whyItMatters }` — v1 com os ~6 conceitos de E8 (teto do campo, captura, share intracampo, roll-off, meta/meta sugerida, cobertura da meta). Texto extraído das explicações já escritas em `MunicipalityGoalAccountCard.tsx` e no `CampaignInfoHint` do card, sem reinventar a redação.
- **`src/app/(campaign)/campanha/(app)/conceitos/page.tsx`** (novo, server component): gate `getCampaignUser()` + `isCampaignStaff` + `redirect('/campanha')` para `leader` — mesmo padrão de `.../planos/page.tsx`; renderiza `CampaignPageShell` com uma lista de `Card` por conceito, `id={concept.id}` no elemento da seção para permitir `#anchor`.
- **`MunicipalityGoalAccountCard.tsx`**: cada `GoalAccountMetric` e o `CampaignInfoHint` do título ganham um `Link` "Saiba mais" para `/campanha/conceitos#<id>` ao final do texto explicativo — não substitui o tooltip, só estende.
- **Sem migration, sem collection, sem server action, sem Consent.**
## Dependências
- Duro: **E8 ✓** (os conceitos a documentar em v1 já existem e estão entregues).
- Suave: cresce conforme **E9/E10/B13/E11/E12/E13/E14** entregarem — cada um adiciona sua seção ao array de conceitos e seus próprios links "saiba mais", sem exigir um re-trabalho deste item.
- Nenhuma dependência de site público/admin.
## Não escopo
- Documentar conceitos de itens ainda não construídos (E9–E17) — cada item adiciona sua própria seção quando entregar (ver Questões em aberto).
- Glossário inline automático / detecção de jargão em toda a UI — é a hipótese **O3** do roadmap, ainda sem evidência de Bloco B suficiente para um mecanismo geral; este item resolve o caso concreto já evidenciado (E8) e fixa o padrão, mas não é um sistema automático.
- Onboarding geral do `/campanha` ("como criar um plano", "como declarar um pledge") — fora do escopo de "conceitos de inteligência"; se pedido, é outro item.
- Fórmulas renderizadas matematicamente (KaTeX/rehype-math) — texto monospace basta (Questões em aberto).
## Rabbit holes
- **Exemplo "ao vivo" com dado real de um município.** Se alguém "só completar" com um cálculo dinâmico usando um município real: isso cria uma 2ª superfície de dados dentro da doc (loader, cache, desatualiza se a fórmula mudar sem o exemplo acompanhar). **Mitigação:** exemplos estáticos escritos à mão no conteúdo (como o par hipotético Feira de Santana já usado nesta sessão).
- **Página virar wiki geral do produto.** Se alguém "só adicionar" onboarding/FAQ ao lado dos conceitos: explode escopo e mistura dois jobs. **Mitigação:** escopo travado em "conceitos de inteligência de campanha" (título da página, seções por ID do programa E8–E17), qualquer outra dúvida vira R6/O3.
## 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.
- **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).
## 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"
- `src/components/campaign/CampaignInfoHint.tsx` — padrão de Popover "?" já usado no título do card (precedente de affordance, distinto do Tooltip inline)
- `src/components/campaign/nav.ts` — `staffNav`/`getCampaignNav`: por que este item não entra ali agora (Adiado com gatilho)
- `src/components/campaign/CampaignPageShell.tsx` — shell de página a reusar
- `src/utilities/municipalityPotential.ts`, `src/utilities/goalCoverage.ts` — fórmulas reais a descrever com precisão
- `src/lib/campoParties.ts` — curadoria "campo por ano" (conceito "share intracampo")
- `src/app/(campaign)/campanha/(app)/planos/page.tsx` — precedente do gate staff-only (`getCampaignUser` + `isCampaignStaff` + `redirect`)
- `docs/plans/conta-da-cadeira.md` — plano de E8, seção "Follow-up pós-entrega" com a origem exata deste pedido
- `docs/plans/inteligencia-campanha.md` — plano-mestre do programa; cada fatia futura (E9–E17) estende o array de conceitos deste item
- AGENTS.md — naming em inglês/strings pt-BR, `src/lib` client-safe vs `src/utilities` server-coupled, Campaign auth (`isCampaignStaff`)

View file

@ -1,7 +1,7 @@
# Inteligência de campanha — do relatório de discovery ao produto (programa E8–E16 + B13 + C12; adjacentes A11/E17)
# Inteligência de campanha — do relatório de discovery ao produto (programa E8–E16 + B13 + C12; adjacentes A11/E17/E18)
Status: rascunho (plano-mestre; itens fatiados individualmente)
Atualizado em: 2026-07-24 (referências sincronizadas pós-remodelagem Municípios + hardening; remodelagem em produção desde 2026-07-23; validação de campo 2026-07-23 incorporada — G11, E16, âncora % da própria votação)
Atualizado em: 2026-07-24 (referências sincronizadas pós-remodelagem Municípios + hardening; remodelagem em produção desde 2026-07-23; validação de campo 2026-07-23 incorporada — G11, E16, âncora % da própria votação; **E18** registrado — documentação dos conceitos, gatilhado pela própria confusão do usuário ao ler os tooltips do E8)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Inteligência de campanha"; trilhas E/B/C)
Impeccable: misto por item — E15 = A; E8/E9/C12/E10/B13/E14/E12/E16 = B; E11/E13 = C (classe final no plano de cada fatia)
Appetite: programa ~2–3 semanas eng no total, fatiado em slices de ~1–3 dias
@ -47,6 +47,8 @@ Uma única fila ordena "todos" heterogêneos (gatilhos do playbook, demandas, mu
Regras transversais (de §6.2/§6.4, viram comportamento do produto): **barato-antes-de-caro** (todo gatilho gera primeiro a checagem de 48h para o assessor; só gatilho persistente/triangulado sobe para a reunião de realocação); **desempate por votos em jogo**; **histerese** (realocação cara exige 2 semanas de persistência; nível 1 fura a fila); **gatilho de ausência dispara auditoria de registro, nunca ação de campo**. A fila tem duas visões: _do assessor_ (checagens e tarefas dos seus municípios) e _do coordenador_ (decisões de recurso caro, pauta da reunião quinzenal).
**E18 (adjacente, registrado 2026-07-24):** ao ler os tooltips de diagnóstico do E8 pela primeira vez, o usuário reconheceu que os conceitos ("teto do campo", "captura", "share intracampo", "roll-off") são interessantes mas complicados, e pediu uma página de documentação explicando cada conceito de inteligência da vertical e como é calculado — [documentacao-conceitos-campanha.md](documentacao-conceitos-campanha.md). v1 cobre só os conceitos já entregues (E8); cada fatia subsequente da tabela abaixo estende o conteúdo quando entregar.
## Fatias (funcionalidades faltantes)
| ID | Fatia (plano) | Entrega | OST | Classe | Appetite | Janela |

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-24 (B19 gerenciar assessores; **E8 ✓** conta da cadeira — desbloqueia E9/E10/E12/E13; E18 registrado; C12 ✓; C11 absorve escala plano↔demandas; B18; B17; B16; reorder DnD fora de escopo; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6)
Atualizado em: 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)
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)
@ -51,7 +51,7 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md](
## Próximos — Campanha (`/campanha`)
### Programa Inteligência de campanha (E8–E16, B13, C12 · adjacentes A11/E17)
### Programa Inteligência de campanha (E8–E16, B13, C12 · adjacentes A11/E17/E18)
O discovery literatura→persona→entrevista ([relatório aprovado](research/relatorio-entrevista-persona-campanha.md); compêndio com ~67 fontes em [docs/research/](research/)) fixou o kernel: a disputa de DF é conta de quociente fragmentada; o gargalo é converter lealdade de campo em voto nominal município a município via rede; % estadual absoluto é anti-métrica. A **sessão real com o Coordenador Geral (2026-07-23 — [CUSTOMER.md](CUSTOMER.md))** confirmou as apostas centrais (fogo amigo intra-PT como ameaça nº 1; canal = ZAP sem registro datado; "coluna da vergonha" validada — Salvador cobrado 10×) e calibrou as âncoras: a leitura relativa da mesa é **% da própria votação** (concentração da captura própria — critério rígido de prioridade dele), não % do eleitorado local; a restrição dominante é **"perna"/estrutura**, não dinheiro; piso projetado **150 mil** (2022: 129k). O produto deve entregar **inteligência, não planilha chique**: metas derivadas, leitura relativa, fila de decisão priorizada, sugestões dado→decisão com humano no loop, registro ex-ante auditável. Plano-mestre: [inteligencia-campanha.md](plans/inteligencia-campanha.md) (incl. gaps de dados G1–G11 e desenho canônico da fila).
@ -89,6 +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)
### Fill-ins abertos
@ -130,6 +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"]
subgraph Intel["Inteligência de campanha"]
E8i["E8 ✓ Conta da cadeira"]
@ -146,6 +148,7 @@ flowchart TD
end
E4R -.metas seedadas.-> E8i
E8i -.conceitos a documentar.-> E18n
B15n -.contrato sort/dir.-> A11n
B15n -.header sort.-> B16n
B15n -.ids de coluna.-> B17n
@ -176,11 +179,11 @@ flowchart TD
B8F2 -.ZE Salvador.-> B14n
```
Paralelizáveis agora: **E9** (E8 ✓ entregue), **E10**/**E12** (E8 ✓ desbloqueou ambos), **E16** (compõe o existente), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), **B16** (filtros no header; soft B15 ✓ já entregue), **B17** (seletor de colunas; soft B15/B16 — pousa na barra atual ou slim), **B18** (filtros salvos + submenu Municípios; soft B16/B17 — botão na barra), **B19** (gerenciar assessores — onboarding sem `/admin`; sem deps duras), fill-ins (ícone de prioridade na lista, Cenário junto aos filtros — encaixa na barra slim de B16 —, O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12 ✓) não absorver os deltas do ZAP (não compete com E9).
Paralelizáveis agora: **E9** (E8 ✓ entregue), **E10**/**E12** (E8 ✓ desbloqueou ambos), **E16** (compõe o existente), **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).
### 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; **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) 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 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; **E9** fila de alocação (E8 ✓ e **C12 ✓** já entregues); **E16** dossiê do município (pedido O6 — compõe o existente, melhora com E8 ✓/C12 ✓); D2 se sobrar folga.
@ -192,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); **E16** dossiê (último dos extras — pedido explícito de campo); 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 (os tooltips inline do E8 já explicam o essencial; página é reforço); **E16** dossiê (último dos extras — pedido explícito de campo); débitos/fill-ins.
## Bloqueadores atuais

View file

@ -1,7 +1,12 @@
/**
* Seeds electionTally / electionCandidateVote / electionCandidate from TSE
* open data (Bahia scope). Default year: 2022 (full ticket). Historical years
* 2014/2018 import only deputado_federal turno 1 (E2 series).
* 2014/2018 import deputado_federal + presidente + governador turno 1 (E2
* federal trend series + E8 majoritarian baseline for teto do campo/roll-off
* validation across cycles). Skips deputado_estadual for 2014/2018 — no
* consumer needs the state-assembly race historically. The country-wide
* (non-BA) files are always loaded too — see the inline comment above
* `voteBa`/`voteBr` below for why (presidente is BR-scope in TSE's data).
*
* Provenance (do not strip):
* - Portal 2022: https://dadosabertos.tse.jus.br/dataset/resultados-2022
@ -41,7 +46,7 @@ loadEnv({ path: '.env' })
const config = (await import('../src/payload.config.ts')).default
const { parseTseCsvBuffer } = await import('../src/lib/electionResultsCsv.ts')
const { buildElectionResultsFromCsvRows, FEDERAL_ONLY_OFFICES } = await import(
const { buildElectionResultsFromCsvRows, HISTORICAL_BASELINE_OFFICES } = await import(
'../src/lib/electionResultsBuild.ts'
)
const { downloadToBuffer, readZipEntry } = await import('../src/lib/electionResultsZip.ts')
@ -151,7 +156,9 @@ const main = async () => {
const sources = sourcesForYear(year)
const [votacaoZip, detalheZip, candZip] = await Promise.all(sources.map(ensureCachedZip))
console.log(`[seed:tse] year=${year} scope=${historicalOnly ? 'deputado_federal T1 BA' : 'full ticket BA'}`)
console.log(
`[seed:tse] year=${year} scope=${historicalOnly ? 'deputado_federal + presidente + governador T1 BA' : 'full ticket BA'}`,
)
console.log('[seed:tse] provenance:')
for (const source of [votacaoZip, detalheZip, candZip]) {
console.log(` - ${source.url}`)
@ -161,12 +168,17 @@ const main = async () => {
const csv = csvNamesForYear(year)
// Sequential: BA vote CSV alone is hundreds of MB; parallel parse spikes peak RAM.
// TSE splits "_BA.csv" by unidade eleitoral (UE), not by voter's state: presidente
// runs under UE=BR, so its per-município/zona votes+tallies (BA municípios
// included) live only in the "_BR.csv" files, never in "_BA.csv" — those
// files are always needed once presidente is in scope, historical or not.
// candBr registers presidente candidates (also a national/BR-scope race).
const voteBa = await loadCsvFromZip(votacaoZip.buffer, csv.voteBa)
const voteBr = historicalOnly ? [] : await loadCsvFromZip(votacaoZip.buffer, csv.voteBr)
const voteBr = await loadCsvFromZip(votacaoZip.buffer, csv.voteBr)
const detalheBa = await loadCsvFromZip(detalheZip.buffer, csv.detalheBa)
const detalheBr = historicalOnly ? [] : await loadCsvFromZip(detalheZip.buffer, csv.detalheBr)
const detalheBr = await loadCsvFromZip(detalheZip.buffer, csv.detalheBr)
const candBa = await loadCsvFromZip(candZip.buffer, csv.candBa)
const candBr = historicalOnly ? [] : await loadCsvFromZip(candZip.buffer, csv.candBr)
const candBr = await loadCsvFromZip(candZip.buffer, csv.candBr)
const built = buildElectionResultsFromCsvRows({
voteRows: [...voteBa, ...voteBr],
@ -174,7 +186,7 @@ const main = async () => {
candBaRows: candBa,
candBrRows: candBr,
year,
...(historicalOnly ? { offices: FEDERAL_ONLY_OFFICES } : {}),
...(historicalOnly ? { offices: HISTORICAL_BASELINE_OFFICES } : {}),
})
if (built.unknownMunicipalities.length > 0) {

View file

@ -1,4 +1,7 @@
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 { formatElectionNumber } from '@/lib/electionInsights'
@ -13,8 +16,65 @@ import type { MunicipalityPotential, RollOff } from '@/utilities/municipalityPot
import type { VoteEstimateScenarioViewModel } from '@/utilities/voteEstimate'
import type { MunicipalityPledgeCoverageView } from '@/utilities/votePledgeData'
/**
* One `dt`/`dd` pair in the diagnostic grid, with a hover/focus/tap tooltip
* explaining the metric — deliberately not the "?" icon button pattern used
* by `CampaignInfoHint` in the card title (that stays reserved for
* card-level context that persists open; this is a per-metric definition
* that dismisses on its own), per the E8 UI feedback: these are dense
* numbers that need an inline explanation, not a separate affordance to
* seek out. Reuses `MunicipalityHoverTooltip` (same shadcn Tooltip stack
* wired for baseline/sortable-header hints), which handles the tap-to-open
* itself.
*
* A real `<button>` (not a `div` with `tabIndex`) so the tap target is
* reliably focusable/clickable on touch, sized to the 44px target, and
* announced with its own accessible name — a screen-reader user tabbing in
* hears "mais informações" up front instead of discovering extra content
* only by opening it. `side="right"` keeps the bubble off the vertical axis
* this row shares with "Cobertura da meta" above, so a viewport-bottom
* collision flip never covers that figure (`/impeccable critique` finding).
*/
const GoalAccountMetric = ({
label,
value,
explanation,
}: {
label: string
value: string
explanation: ReactNode
}) => (
<MunicipalityHoverTooltip content={explanation} side="right" align="start" sideOffset={8}>
<button
type="button"
aria-label={`${label}: mais informações`}
className="flex min-h-11 w-fit flex-col items-start gap-0.5 rounded-md px-1.5 py-1 text-left outline-none hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring"
>
<dt className="text-xs text-muted-foreground underline decoration-dotted decoration-muted-foreground/70 underline-offset-2">
{label}
</dt>
<dd className="tabular-nums">{value}</dd>
</button>
</MunicipalityHoverTooltip>
)
/**
* Two-tier tooltip copy: a short lead sentence naming what the metric
* measures (its one bolded term), then a visually secondary formula line —
* split per the E8 `/impeccable critique` finding that a single ~40-word
* 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).
*/
const MetricExplanation = ({ lead, formula }: { lead: ReactNode; formula?: ReactNode }) => (
<div className="flex flex-col gap-1">
<p>{lead}</p>
{formula ? <p className="text-background/70">{formula}</p> : null}
</div>
)
const formatRollOff = (rollOff: RollOff | null): string => {
if (!rollOff) return 'Sem majoritária seedada (2014/2018)'
if (!rollOff) return 'Sem majoritária 2022 disponível'
const votesLabel = formatElectionNumber(rollOff.votes)
const percentLabel =
rollOff.percentOfTurnout != null
@ -60,13 +120,20 @@ export const MunicipalityGoalAccountCard = ({
Conta da cadeira
</h2>
<CampaignInfoHint label="Sobre a conta da cadeira">
<p>
<strong>Meta</strong> é a estimativa da mesa; sem estimativa, usa a meta sugerida
(decomposição proporcional da meta estadual pelo teto do campo projetado).{' '}
<strong>Comprometido</strong> é só a soma das declarações de lideranças — nunca a meta.
O roll-off (diferença de brancos/nulos entre a disputa de DF e a majoritária) só existe
para 2022; não há majoritária seedada em 2014/2018.
</p>
<div className="flex flex-col gap-2">
<p>
<strong>Meta</strong> é a estimativa da mesa; sem estimativa, usa a meta sugerida
(decomposição proporcional da meta estadual pelo teto do campo projetado).
</p>
<p>
<strong>Comprometido</strong> é só a soma das declarações de lideranças — nunca a
meta.
</p>
<p>
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>
</div>
</CampaignInfoHint>
</div>
@ -91,9 +158,14 @@ export const MunicipalityGoalAccountCard = ({
<span className="text-sm text-muted-foreground">Cobertura da meta</span>
</div>
{goalCoverage.coverageRatio != null ? (
// h-2 (vs. the shared default h-1): at 0% coverage — the most common
// early-campaign state — the empty track is the only visual signal,
// and a 4px hairline read as near-invisible in the E8
// `/impeccable critique`.
<Progress
value={goalCoverageProgressPercent(goalCoverage)}
aria-label={`Cobertura da meta: ${formatGoalCoverageRatioLabel(goalCoverage)}`}
className="h-2"
/>
) : null}
<p className="text-xs text-muted-foreground tabular-nums">
@ -102,22 +174,66 @@ export const MunicipalityGoalAccountCard = ({
</div>
<dl className="grid grid-cols-2 gap-3 border-t pt-3 text-sm sm:grid-cols-4">
<div>
<dt className="text-xs text-muted-foreground">Teto do campo (proj.)</dt>
<dd className="tabular-nums">{formatElectionNumber(Math.round(potential.projectedFieldCeiling))}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Captura (2022)</dt>
<dd className="tabular-nums">{formatRatioAsPercentLabel(potential.captureRate2022)}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Share intracampo ({latestIntraFieldShareYear})</dt>
<dd className="tabular-nums">{formatRatioAsPercentLabel(latestIntraFieldShare)}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Roll-off (2022)</dt>
<dd className="tabular-nums">{formatRollOff(potential.rollOff2022)}</dd>
</div>
<GoalAccountMetric
label="Teto do campo (proj.)"
value={formatElectionNumber(Math.round(potential.projectedFieldCeiling))}
explanation={
<MetricExplanation
lead={
<>
<strong>Teto do campo</strong> projeta quantos votos válidos o campo pode
alcançar em 2026.
</>
}
formula="Fórmula: votos do presidencial do campo em 2022 (1º turno), ajustados pelo crescimento de comparecimento projetado para a disputa de deputado federal."
/>
}
/>
<GoalAccountMetric
label="Captura (2022)"
value={formatRatioAsPercentLabel(potential.captureRate2022)}
explanation={
<MetricExplanation
lead={
<>
<strong>Captura</strong> mostra quanto do teto do campo Jorge Solla conquistou
em 2022.
</>
}
formula="Fórmula: votos de Solla (2022) ÷ teto do campo (presidencial 2022). Só diagnóstico — não entra na meta."
/>
}
/>
<GoalAccountMetric
label={`Share intracampo (${latestIntraFieldShareYear})`}
value={formatRatioAsPercentLabel(latestIntraFieldShare)}
explanation={
<MetricExplanation
lead={
<>
<strong>Share intracampo</strong> mostra a fração dos votos do campo, só na
disputa de deputado federal, que foi para Jorge Solla nesse ano.
</>
}
formula="O denominador é o próprio campo nessa disputa — não o teto presidencial de 2022, que é o denominador da Captura."
/>
}
/>
<GoalAccountMetric
label="Roll-off (2022)"
value={formatRollOff(potential.rollOff2022)}
explanation={
<MetricExplanation
lead={
<>
<strong>Roll-off</strong> mede quanto voto em branco/nulo a disputa de deputado
federal atrai a mais que a presidencial, no mesmo pleito.
</>
}
formula="Fórmula: (brancos + nulos, deputado federal 2022) − (brancos + nulos, presidencial 2022)."
/>
}
/>
</dl>
{suggestedGoal <= 0 ? (
<p className="text-xs text-muted-foreground">

View file

@ -1,27 +1,82 @@
'use client'
import type { ReactElement, ReactNode } from 'react'
import { cloneElement, useEffect, useRef, useState } from 'react'
import type { PointerEvent, ReactElement, ReactNode, Ref } from 'react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
/** Hover/focus explanation with no extra chrome — wraps an existing control or heading. */
/**
* Hover/focus explanation with no extra chrome — wraps an existing control or
* heading. Radix's `TooltipTrigger` deliberately ignores touch pointer
* events — no hover on touch, and a tap doesn't reliably focus a non-form
* element either — which otherwise makes the whole tooltip unreachable on
* phones (an `/impeccable critique` finding). The open state is controlled
* so a `pointerType === 'touch'` tap can explicitly toggle it; mouse hover
* and keyboard focus are untouched (Radix keeps driving `onOpenChange` for
* those exactly as before), so a **mouse** click — which already hovered the
* trigger on its way in — can't immediately re-close what hover just opened.
*
* Radix's Tooltip has no outside-tap dismiss layer (that's a Popover
* feature), so once opened by touch it would otherwise stay open until the
* page navigates. The `pointerdown`-outside listener below closes it —
* tapping a *different* metric's trigger counts as "outside" too, which is
* what keeps at most one of these tooltips open at a time.
*/
type TappableElement = ReactElement<{
onPointerUp?: (event: PointerEvent) => void
ref?: Ref<HTMLElement>
}>
export const MunicipalityHoverTooltip = ({
content,
children,
side = 'bottom',
align = 'center',
sideOffset = 4,
}: {
content: ReactNode
children: ReactElement
children: TappableElement
side?: 'top' | 'right' | 'bottom' | 'left'
align?: 'start' | 'center' | 'end'
}) => (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side={side} align={align} className="max-w-xs text-left font-normal">
{content}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
sideOffset?: number
}) => {
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLElement | 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
setOpen(false)
}
document.addEventListener('pointerdown', closeIfOutside)
return () => document.removeEventListener('pointerdown', closeIfOutside)
}, [open])
const trigger = cloneElement(children, {
ref: (node: HTMLElement | null) => {
triggerRef.current = node
},
onPointerUp: (event: PointerEvent) => {
children.props.onPointerUp?.(event)
if (event.pointerType === 'touch') setOpen((current) => !current)
},
})
return (
<TooltipProvider delayDuration={300}>
<Tooltip open={open} onOpenChange={setOpen}>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent
side={side}
align={align}
sideOffset={sideOffset}
className="max-w-xs text-left font-normal"
>
{content}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View file

@ -9,8 +9,12 @@ import artifactJson from '@/lib/electionAggregates/bahia-federal-baseline.json'
* v2 (E8 "conta da cadeira"): adds the curated campo-parties federal nominal
* votes, federal T1 turnout/blank/null tally, and 2022 majoritarian
* (presidente/governador #13) votes+tally used for "teto do campo",
* "share intracampo" and roll-off. 2022-only for the majoritarian slice — no
* majoritária seeded for 2014/2018 (E8 audit finding).
* "share intracampo" and roll-off. 2022-only for the majoritarian slice by
* design (E8's formulas anchor the field ceiling and roll-off to the single
* most recent majoritarian race) — the DB has majoritária seeded for
* 2014/2018 too (`pnpm db:seed:tse`, since 2026-07-24), but it isn't cut into
* this artifact; a future consumer that needs it extends `majoritarian2022`
* into a `majoritarianByYear` map.
*
* Generated by `pnpm build:election-aggregates` from the locally seeded TSE
* data — never computed at build or request time (see the script header for

View file

@ -13,6 +13,8 @@ import {
import {
applyStateVoteTotals,
dedupeCandidates,
mergeDuplicateTallyRows,
mergeDuplicateVoteRows,
parseCandidateVoteRow,
parseConsultaCandRow,
parseDetalheApuracaoRow,
@ -21,6 +23,17 @@ import {
const ALL_OFFICES = new Set<ElectionOffice>(ELECTION_OFFICES)
export const FEDERAL_ONLY_OFFICES = new Set<ElectionOffice>([FEDERAL_DEPUTY_OFFICE])
/**
* Historical seed scope (2014/2018): federal deputy (E2 trend series) plus
* presidente/governador (E8 majoritarian baseline — same rationale as the
* 2022 full-ticket import, just for the two prior cycles). Excludes
* deputado_estadual: no consumer needs the state-assembly race historically.
*/
export const HISTORICAL_BASELINE_OFFICES = new Set<ElectionOffice>([
FEDERAL_DEPUTY_OFFICE,
'presidente',
'governador',
])
const STATE_OFFICES = new Set<ElectionOffice>([
'governador',
'deputado_federal',
@ -78,11 +91,16 @@ export const buildElectionResultsFromCsvRows = (args: {
offices.has('deputado_estadual')
const unknownMunicipalities = new Set<string>()
const votes = collectParsed(args.voteRows, year, unknownMunicipalities, (row) =>
parseCandidateVoteRow(row, { stateFilter: 'BA', offices }),
// Dedupe TSE's "voto em trânsito" split rows — see mergeDuplicateVoteRows's doc.
const votes = mergeDuplicateVoteRows(
collectParsed(args.voteRows, year, unknownMunicipalities, (row) =>
parseCandidateVoteRow(row, { stateFilter: 'BA', offices }),
),
)
const tallies = collectParsed(args.tallyRows, year, unknownMunicipalities, (row) =>
parseDetalheApuracaoRow(row, { stateFilter: 'BA', offices }),
const tallies = mergeDuplicateTallyRows(
collectParsed(args.tallyRows, year, unknownMunicipalities, (row) =>
parseDetalheApuracaoRow(row, { stateFilter: 'BA', offices }),
),
)
const candidatesRaw: ElectionCandidateRow[] = []

View file

@ -15,6 +15,70 @@ import {
} from '@/lib/electionResults'
import { computeIdentityKey } from '@/lib/electionCandidateIdentity'
/**
* Merges rows sharing the same `keyFn` key via `combine`, preserving first-seen
* order. Shared by `mergeDuplicateVoteRows`/`mergeDuplicateTallyRows` below —
* both fold TSE's "voto em trânsito" split rows (see their docs) and differ
* only in the key and how colliding rows combine.
*/
const mergeByKey = <T,>(
rows: readonly T[],
keyFn: (row: T) => string,
combine: (existing: T, row: T) => T,
): T[] => {
const merged = new Map<string, T>()
for (const row of rows) {
const key = keyFn(row)
const existing = merged.get(key)
merged.set(key, existing ? combine(existing, row) : row)
}
return [...merged.values()]
}
const voteRowKey = (row: CandidateVoteRow): string =>
`${row.year}|${row.office}|${row.turn}|${row.state}|${row.cityCode}|${row.zoneNumber}|${row.candidateNumber}|${row.voteType}`
/**
* TSE's presidente data (the only office open to "voto em trânsito" — voting
* away from your registered zone, national ballot only) records the trânsito
* ballots as a SEPARATE row sharing the same (year, office, turn, state,
* cityCode, zoneNumber, candidateNumber, voteType) key as the regular row —
* colliding with `ElectionCandidateVote`'s unique index, which doesn't track
* the untracked ST_VOTO_EM_TRANSITO flag. Both rows are additive (distinct
* electorates, same candidate/zone), so sum `votes` instead of dropping
* either. Observed in a handful of 2014 BA zones; absent in 2018/2022 — a
* no-op when there is nothing to merge.
*/
export const mergeDuplicateVoteRows = (rows: readonly CandidateVoteRow[]): CandidateVoteRow[] =>
mergeByKey(rows, voteRowKey, (existing, row) => ({ ...existing, votes: existing.votes + row.votes }))
const tallyRowKey = (row: TseDetalheApuracaoRow): string =>
`${row.year}|${row.office}|${row.turn}|${row.state}|${row.cityCode}|${row.zoneNumber}`
const SUMMABLE_TALLY_FIELDS = [
'aptos',
'comparecimento',
'abstencoes',
'votosValidos',
'votosNominaisValidos',
'votosLegenda',
'votosBranco',
'votosNulo',
'votosAnulados',
] as const satisfies ReadonlyArray<keyof TseDetalheApuracaoRow>
/** Same trânsito-split merge as `mergeDuplicateVoteRows`, for tally rows (sums every count field). */
export const mergeDuplicateTallyRows = (
rows: readonly TseDetalheApuracaoRow[],
): TseDetalheApuracaoRow[] =>
mergeByKey(rows, tallyRowKey, (existing, row) => {
const summed = { ...existing }
for (const field of SUMMABLE_TALLY_FIELDS) {
summed[field] = existing[field] + row[field]
}
return summed
})
export type TseCsvRow = Record<string, string>
const cell = (row: TseCsvRow, key: string): string => row[key] ?? ''

View file

@ -40,7 +40,8 @@ export const projectedValidVotes = (baseline: MunicipalityFederalBaseline): numb
* so it stands in for "how many votes could the field plausibly get here".
* Primary reading only (governador is a secondary cross-check surfaced by the
* artifact, not blended in — assumption flagged for product validation in
* the E8 plan). 2022-only: no majoritarian tally is seeded for 2014/2018.
* the E8 plan). 2022-only by design — see `bahiaElectionAggregates.ts` for
* why the committed artifact doesn't cut 2014/2018 majoritária too.
*/
export const fieldCeiling = (baseline: MunicipalityFederalBaseline): number =>
baseline.majoritarian2022?.president.votes ?? 0
@ -95,8 +96,8 @@ export type RollOff = {
/**
* Roll-off (diagnostic only): the DF race always shows more blank/null votes
* than the majoritarian race in the same turnout — this is the gap, in votes
* and as a share of comparecimento. 2022-only (no majoritarian tally for
* 2014/2018); `null` when either tally is missing for the slug.
* and as a share of comparecimento. 2022-only, same artifact limitation as
* `fieldCeiling` above; `null` when either tally is missing for the slug.
*/
export const rollOff = (baseline: MunicipalityFederalBaseline): RollOff | null => {
const majoritarian = baseline.majoritarian2022?.president

View file

@ -17,6 +17,8 @@ import {
} from '@/lib/electionResults'
import { loadTseFixtureResults, loadTseFixtureResultsForYear, TSE_FIXTURE_EXPECTED } from '../helpers/tseFixtures'
import { FEDERAL_ONLY_OFFICES } from '@/lib/electionResultsBuild'
import { mergeDuplicateTallyRows, mergeDuplicateVoteRows } from '@/lib/electionResultsParse'
import type { CandidateVoteRow, TseDetalheApuracaoRow } from '@/lib/electionResults'
describe('electionResults municipality mapping', () => {
it('resolves every canonical Bahia municipality through itself', () => {
@ -167,3 +169,87 @@ describe('electionResults fixture parse + winners', () => {
expect(built.votes.every((row) => row.year === 2018)).toBe(true)
})
})
describe('electionResults "voto em trânsito" merge (2014 presidente duplicate rows)', () => {
const baseVote: CandidateVoteRow = {
year: 2014,
office: 'presidente',
turn: '1',
state: 'BA',
cityCode: '99999',
cityName: 'Feira de Santana',
zoneNumber: 154,
candidateNumber: 13,
candidateName: 'DILMA',
coalition: null,
party: 'PT',
voteType: 'nominal',
votes: 100,
}
it('sums votes for rows sharing the same scope/zone/candidate key (the trânsito split)', () => {
const merged = mergeDuplicateVoteRows([baseVote, { ...baseVote, votes: 40 }])
expect(merged).toHaveLength(1)
expect(merged[0].votes).toBe(140)
})
it('keeps rows with a different key separate (no false merge across zones/candidates)', () => {
const otherZone = { ...baseVote, zoneNumber: 155, votes: 10 }
const otherCandidate = { ...baseVote, candidateNumber: 45, votes: 20 }
const merged = mergeDuplicateVoteRows([baseVote, otherZone, otherCandidate])
expect(merged).toHaveLength(3)
expect(merged.reduce((sum, row) => sum + row.votes, 0)).toBe(130)
})
it('is a no-op when there is nothing to merge (2018/2022 shape)', () => {
const rows = [baseVote, { ...baseVote, zoneNumber: 1 }]
expect(mergeDuplicateVoteRows(rows)).toEqual(rows)
})
const baseTally: TseDetalheApuracaoRow = {
year: 2014,
office: 'presidente',
turn: '1',
state: 'BA',
cityCode: '99999',
cityName: 'Feira de Santana',
zoneNumber: 154,
aptos: 300,
comparecimento: 200,
abstencoes: 100,
votosValidos: 180,
votosNominaisValidos: 175,
votosLegenda: 5,
votosBranco: 10,
votosNulo: 10,
votosAnulados: 0,
}
it('sums every count field for duplicate tally rows sharing the same scope key', () => {
const merged = mergeDuplicateTallyRows([
baseTally,
{ ...baseTally, aptos: 50, comparecimento: 40, abstencoes: 10, votosValidos: 35, votosNominaisValidos: 33, votosLegenda: 2, votosBranco: 3, votosNulo: 2, votosAnulados: 0 },
])
expect(merged).toHaveLength(1)
expect(merged[0]).toMatchObject({
aptos: 350,
comparecimento: 240,
abstencoes: 110,
votosValidos: 215,
votosNominaisValidos: 208,
votosLegenda: 7,
votosBranco: 13,
votosNulo: 12,
votosAnulados: 0,
})
})
it('is a no-op when zones differ', () => {
const rows = [baseTally, { ...baseTally, zoneNumber: 155 }]
expect(mergeDuplicateTallyRows(rows)).toEqual(rows)
})
})