diff --git a/.impeccable/critique/2026-07-25T01-04-34Z__omponents-campaign-municipalitygoalaccountcard-tsx.md b/.impeccable/critique/2026-07-25T01-04-34Z__omponents-campaign-municipalitygoalaccountcard-tsx.md new file mode 100644 index 00000000..dcc665bf --- /dev/null +++ b/.impeccable/critique/2026-07-25T01-04-34Z__omponents-campaign-municipalitygoalaccountcard-tsx.md @@ -0,0 +1,96 @@ +--- +target: Conta da cadeira card (/campanha/municipios/) — 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 `

` — 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? diff --git a/docs/plans/conta-da-cadeira.md b/docs/plans/conta-da-cadeira.md index 1d6af04c..5d238e96 100644 --- a/docs/plans/conta-da-cadeira.md +++ b/docs/plans/conta-da-cadeira.md @@ -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 ` + +) + +/** + * 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 }) => ( +

+

{lead}

+ {formula ?

{formula}

: null} +
+) + 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 -

- Meta é a estimativa da mesa; sem estimativa, usa a meta sugerida - (decomposição proporcional da meta estadual pelo teto do campo projetado).{' '} - Comprometido é 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. -

+
+

+ Meta é a estimativa da mesa; sem estimativa, usa a meta sugerida + (decomposição proporcional da meta estadual pelo teto do campo projetado). +

+

+ Comprometido é só a soma das declarações de lideranças — nunca a + meta. +

+

+ 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. +

+
@@ -91,9 +158,14 @@ export const MunicipalityGoalAccountCard = ({ Cobertura da meta {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`. ) : null}

@@ -102,22 +174,66 @@ export const MunicipalityGoalAccountCard = ({

-
-
Teto do campo (proj.)
-
{formatElectionNumber(Math.round(potential.projectedFieldCeiling))}
-
-
-
Captura (2022)
-
{formatRatioAsPercentLabel(potential.captureRate2022)}
-
-
-
Share intracampo ({latestIntraFieldShareYear})
-
{formatRatioAsPercentLabel(latestIntraFieldShare)}
-
-
-
Roll-off (2022)
-
{formatRollOff(potential.rollOff2022)}
-
+ + Teto do campo projeta quantos votos válidos o campo pode + alcançar em 2026. + + } + formula="Fórmula: votos do presidencial do campo em 2022 (1º turno), ajustados pelo crescimento de comparecimento projetado para a disputa de deputado federal." + /> + } + /> + + Captura mostra quanto do teto do campo Jorge Solla conquistou + em 2022. + + } + formula="Fórmula: votos de Solla (2022) ÷ teto do campo (presidencial 2022). Só diagnóstico — não entra na meta." + /> + } + /> + + Share intracampo mostra a fração dos votos do campo, só na + disputa de deputado federal, que foi para Jorge Solla nesse ano. + + } + formula="O denominador é o próprio campo nessa disputa — não o teto presidencial de 2022, que é o denominador da Captura." + /> + } + /> + + Roll-off mede quanto voto em branco/nulo a disputa de deputado + federal atrai a mais que a presidencial, no mesmo pleito. + + } + formula="Fórmula: (brancos + nulos, deputado federal 2022) − (brancos + nulos, presidencial 2022)." + /> + } + />
{suggestedGoal <= 0 ? (

diff --git a/src/components/campaign/MunicipalityHoverTooltip.tsx b/src/components/campaign/MunicipalityHoverTooltip.tsx index 32505803..501ca199 100644 --- a/src/components/campaign/MunicipalityHoverTooltip.tsx +++ b/src/components/campaign/MunicipalityHoverTooltip.tsx @@ -1,27 +1,82 @@ 'use client' -import type { ReactElement, ReactNode } from 'react' +import { cloneElement, useEffect, useRef, useState } from 'react' +import type { PointerEvent, ReactElement, ReactNode, Ref } from 'react' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -/** Hover/focus explanation with no extra chrome — wraps an existing control or heading. */ +/** + * Hover/focus explanation with no extra chrome — wraps an existing control or + * heading. Radix's `TooltipTrigger` deliberately ignores touch pointer + * events — no hover on touch, and a tap doesn't reliably focus a non-form + * element either — which otherwise makes the whole tooltip unreachable on + * phones (an `/impeccable critique` finding). The open state is controlled + * so a `pointerType === 'touch'` tap can explicitly toggle it; mouse hover + * and keyboard focus are untouched (Radix keeps driving `onOpenChange` for + * those exactly as before), so a **mouse** click — which already hovered the + * trigger on its way in — can't immediately re-close what hover just opened. + * + * Radix's Tooltip has no outside-tap dismiss layer (that's a Popover + * feature), so once opened by touch it would otherwise stay open until the + * page navigates. The `pointerdown`-outside listener below closes it — + * tapping a *different* metric's trigger counts as "outside" too, which is + * what keeps at most one of these tooltips open at a time. + */ +type TappableElement = ReactElement<{ + onPointerUp?: (event: PointerEvent) => void + ref?: Ref +}> + export const MunicipalityHoverTooltip = ({ content, children, side = 'bottom', align = 'center', + sideOffset = 4, }: { content: ReactNode - children: ReactElement + children: TappableElement side?: 'top' | 'right' | 'bottom' | 'left' align?: 'start' | 'center' | 'end' -}) => ( - - - {children} - - {content} - - - -) + sideOffset?: number +}) => { + const [open, setOpen] = useState(false) + const triggerRef = useRef(null) + + useEffect(() => { + if (!open) return + const closeIfOutside = (event: globalThis.PointerEvent) => { + if (event.pointerType !== 'touch') return + if (triggerRef.current?.contains(event.target as Node)) return + setOpen(false) + } + document.addEventListener('pointerdown', closeIfOutside) + return () => document.removeEventListener('pointerdown', closeIfOutside) + }, [open]) + + const trigger = cloneElement(children, { + ref: (node: HTMLElement | null) => { + triggerRef.current = node + }, + onPointerUp: (event: PointerEvent) => { + children.props.onPointerUp?.(event) + if (event.pointerType === 'touch') setOpen((current) => !current) + }, + }) + + return ( + + + {trigger} + + {content} + + + + ) +} diff --git a/src/lib/bahiaElectionAggregates.ts b/src/lib/bahiaElectionAggregates.ts index c44d67c2..ba2f6d4d 100644 --- a/src/lib/bahiaElectionAggregates.ts +++ b/src/lib/bahiaElectionAggregates.ts @@ -9,8 +9,12 @@ import artifactJson from '@/lib/electionAggregates/bahia-federal-baseline.json' * v2 (E8 "conta da cadeira"): adds the curated campo-parties federal nominal * votes, federal T1 turnout/blank/null tally, and 2022 majoritarian * (presidente/governador #13) votes+tally used for "teto do campo", - * "share intracampo" and roll-off. 2022-only for the majoritarian slice — no - * majoritária seeded for 2014/2018 (E8 audit finding). + * "share intracampo" and roll-off. 2022-only for the majoritarian slice by + * design (E8's formulas anchor the field ceiling and roll-off to the single + * most recent majoritarian race) — the DB has majoritária seeded for + * 2014/2018 too (`pnpm db:seed:tse`, since 2026-07-24), but it isn't cut into + * this artifact; a future consumer that needs it extends `majoritarian2022` + * into a `majoritarianByYear` map. * * Generated by `pnpm build:election-aggregates` from the locally seeded TSE * data — never computed at build or request time (see the script header for diff --git a/src/lib/electionResultsBuild.ts b/src/lib/electionResultsBuild.ts index 47801fba..69260bd0 100644 --- a/src/lib/electionResultsBuild.ts +++ b/src/lib/electionResultsBuild.ts @@ -13,6 +13,8 @@ import { import { applyStateVoteTotals, dedupeCandidates, + mergeDuplicateTallyRows, + mergeDuplicateVoteRows, parseCandidateVoteRow, parseConsultaCandRow, parseDetalheApuracaoRow, @@ -21,6 +23,17 @@ import { const ALL_OFFICES = new Set(ELECTION_OFFICES) export const FEDERAL_ONLY_OFFICES = new Set([FEDERAL_DEPUTY_OFFICE]) +/** + * Historical seed scope (2014/2018): federal deputy (E2 trend series) plus + * presidente/governador (E8 majoritarian baseline — same rationale as the + * 2022 full-ticket import, just for the two prior cycles). Excludes + * deputado_estadual: no consumer needs the state-assembly race historically. + */ +export const HISTORICAL_BASELINE_OFFICES = new Set([ + FEDERAL_DEPUTY_OFFICE, + 'presidente', + 'governador', +]) const STATE_OFFICES = new Set([ 'governador', 'deputado_federal', @@ -78,11 +91,16 @@ export const buildElectionResultsFromCsvRows = (args: { offices.has('deputado_estadual') const unknownMunicipalities = new Set() - const votes = collectParsed(args.voteRows, year, unknownMunicipalities, (row) => - parseCandidateVoteRow(row, { stateFilter: 'BA', offices }), + // Dedupe TSE's "voto em trânsito" split rows — see mergeDuplicateVoteRows's doc. + const votes = mergeDuplicateVoteRows( + collectParsed(args.voteRows, year, unknownMunicipalities, (row) => + parseCandidateVoteRow(row, { stateFilter: 'BA', offices }), + ), ) - const tallies = collectParsed(args.tallyRows, year, unknownMunicipalities, (row) => - parseDetalheApuracaoRow(row, { stateFilter: 'BA', offices }), + const tallies = mergeDuplicateTallyRows( + collectParsed(args.tallyRows, year, unknownMunicipalities, (row) => + parseDetalheApuracaoRow(row, { stateFilter: 'BA', offices }), + ), ) const candidatesRaw: ElectionCandidateRow[] = [] diff --git a/src/lib/electionResultsParse.ts b/src/lib/electionResultsParse.ts index ce171761..fe099d71 100644 --- a/src/lib/electionResultsParse.ts +++ b/src/lib/electionResultsParse.ts @@ -15,6 +15,70 @@ import { } from '@/lib/electionResults' import { computeIdentityKey } from '@/lib/electionCandidateIdentity' +/** + * Merges rows sharing the same `keyFn` key via `combine`, preserving first-seen + * order. Shared by `mergeDuplicateVoteRows`/`mergeDuplicateTallyRows` below — + * both fold TSE's "voto em trânsito" split rows (see their docs) and differ + * only in the key and how colliding rows combine. + */ +const mergeByKey = ( + rows: readonly T[], + keyFn: (row: T) => string, + combine: (existing: T, row: T) => T, +): T[] => { + const merged = new Map() + for (const row of rows) { + const key = keyFn(row) + const existing = merged.get(key) + merged.set(key, existing ? combine(existing, row) : row) + } + return [...merged.values()] +} + +const voteRowKey = (row: CandidateVoteRow): string => + `${row.year}|${row.office}|${row.turn}|${row.state}|${row.cityCode}|${row.zoneNumber}|${row.candidateNumber}|${row.voteType}` + +/** + * TSE's presidente data (the only office open to "voto em trânsito" — voting + * away from your registered zone, national ballot only) records the trânsito + * ballots as a SEPARATE row sharing the same (year, office, turn, state, + * cityCode, zoneNumber, candidateNumber, voteType) key as the regular row — + * colliding with `ElectionCandidateVote`'s unique index, which doesn't track + * the untracked ST_VOTO_EM_TRANSITO flag. Both rows are additive (distinct + * electorates, same candidate/zone), so sum `votes` instead of dropping + * either. Observed in a handful of 2014 BA zones; absent in 2018/2022 — a + * no-op when there is nothing to merge. + */ +export const mergeDuplicateVoteRows = (rows: readonly CandidateVoteRow[]): CandidateVoteRow[] => + mergeByKey(rows, voteRowKey, (existing, row) => ({ ...existing, votes: existing.votes + row.votes })) + +const tallyRowKey = (row: TseDetalheApuracaoRow): string => + `${row.year}|${row.office}|${row.turn}|${row.state}|${row.cityCode}|${row.zoneNumber}` + +const SUMMABLE_TALLY_FIELDS = [ + 'aptos', + 'comparecimento', + 'abstencoes', + 'votosValidos', + 'votosNominaisValidos', + 'votosLegenda', + 'votosBranco', + 'votosNulo', + 'votosAnulados', +] as const satisfies ReadonlyArray + +/** Same trânsito-split merge as `mergeDuplicateVoteRows`, for tally rows (sums every count field). */ +export const mergeDuplicateTallyRows = ( + rows: readonly TseDetalheApuracaoRow[], +): TseDetalheApuracaoRow[] => + mergeByKey(rows, tallyRowKey, (existing, row) => { + const summed = { ...existing } + for (const field of SUMMABLE_TALLY_FIELDS) { + summed[field] = existing[field] + row[field] + } + return summed + }) + export type TseCsvRow = Record const cell = (row: TseCsvRow, key: string): string => row[key] ?? '' diff --git a/src/utilities/municipalityPotential.ts b/src/utilities/municipalityPotential.ts index f550470c..c057873b 100644 --- a/src/utilities/municipalityPotential.ts +++ b/src/utilities/municipalityPotential.ts @@ -40,7 +40,8 @@ export const projectedValidVotes = (baseline: MunicipalityFederalBaseline): numb * so it stands in for "how many votes could the field plausibly get here". * Primary reading only (governador is a secondary cross-check surfaced by the * artifact, not blended in — assumption flagged for product validation in - * the E8 plan). 2022-only: no majoritarian tally is seeded for 2014/2018. + * the E8 plan). 2022-only by design — see `bahiaElectionAggregates.ts` for + * why the committed artifact doesn't cut 2014/2018 majoritária too. */ export const fieldCeiling = (baseline: MunicipalityFederalBaseline): number => baseline.majoritarian2022?.president.votes ?? 0 @@ -95,8 +96,8 @@ export type RollOff = { /** * Roll-off (diagnostic only): the DF race always shows more blank/null votes * than the majoritarian race in the same turnout — this is the gap, in votes - * and as a share of comparecimento. 2022-only (no majoritarian tally for - * 2014/2018); `null` when either tally is missing for the slug. + * and as a share of comparecimento. 2022-only, same artifact limitation as + * `fieldCeiling` above; `null` when either tally is missing for the slug. */ export const rollOff = (baseline: MunicipalityFederalBaseline): RollOff | null => { const majoritarian = baseline.majoritarian2022?.president diff --git a/tests/unit/electionResults.unit.spec.ts b/tests/unit/electionResults.unit.spec.ts index 23c8af22..75daba05 100644 --- a/tests/unit/electionResults.unit.spec.ts +++ b/tests/unit/electionResults.unit.spec.ts @@ -17,6 +17,8 @@ import { } from '@/lib/electionResults' import { loadTseFixtureResults, loadTseFixtureResultsForYear, TSE_FIXTURE_EXPECTED } from '../helpers/tseFixtures' import { FEDERAL_ONLY_OFFICES } from '@/lib/electionResultsBuild' +import { mergeDuplicateTallyRows, mergeDuplicateVoteRows } from '@/lib/electionResultsParse' +import type { CandidateVoteRow, TseDetalheApuracaoRow } from '@/lib/electionResults' describe('electionResults municipality mapping', () => { it('resolves every canonical Bahia municipality through itself', () => { @@ -167,3 +169,87 @@ describe('electionResults fixture parse + winners', () => { expect(built.votes.every((row) => row.year === 2018)).toBe(true) }) }) + +describe('electionResults "voto em trânsito" merge (2014 presidente duplicate rows)', () => { + const baseVote: CandidateVoteRow = { + year: 2014, + office: 'presidente', + turn: '1', + state: 'BA', + cityCode: '99999', + cityName: 'Feira de Santana', + zoneNumber: 154, + candidateNumber: 13, + candidateName: 'DILMA', + coalition: null, + party: 'PT', + voteType: 'nominal', + votes: 100, + } + + it('sums votes for rows sharing the same scope/zone/candidate key (the trânsito split)', () => { + const merged = mergeDuplicateVoteRows([baseVote, { ...baseVote, votes: 40 }]) + + expect(merged).toHaveLength(1) + expect(merged[0].votes).toBe(140) + }) + + it('keeps rows with a different key separate (no false merge across zones/candidates)', () => { + const otherZone = { ...baseVote, zoneNumber: 155, votes: 10 } + const otherCandidate = { ...baseVote, candidateNumber: 45, votes: 20 } + + const merged = mergeDuplicateVoteRows([baseVote, otherZone, otherCandidate]) + + expect(merged).toHaveLength(3) + expect(merged.reduce((sum, row) => sum + row.votes, 0)).toBe(130) + }) + + it('is a no-op when there is nothing to merge (2018/2022 shape)', () => { + const rows = [baseVote, { ...baseVote, zoneNumber: 1 }] + expect(mergeDuplicateVoteRows(rows)).toEqual(rows) + }) + + const baseTally: TseDetalheApuracaoRow = { + year: 2014, + office: 'presidente', + turn: '1', + state: 'BA', + cityCode: '99999', + cityName: 'Feira de Santana', + zoneNumber: 154, + aptos: 300, + comparecimento: 200, + abstencoes: 100, + votosValidos: 180, + votosNominaisValidos: 175, + votosLegenda: 5, + votosBranco: 10, + votosNulo: 10, + votosAnulados: 0, + } + + it('sums every count field for duplicate tally rows sharing the same scope key', () => { + const merged = mergeDuplicateTallyRows([ + baseTally, + { ...baseTally, aptos: 50, comparecimento: 40, abstencoes: 10, votosValidos: 35, votosNominaisValidos: 33, votosLegenda: 2, votosBranco: 3, votosNulo: 2, votosAnulados: 0 }, + ]) + + expect(merged).toHaveLength(1) + expect(merged[0]).toMatchObject({ + aptos: 350, + comparecimento: 240, + abstencoes: 110, + votosValidos: 215, + votosNominaisValidos: 208, + votosLegenda: 7, + votosBranco: 13, + votosNulo: 12, + votosAnulados: 0, + }) + }) + + it('is a no-op when zones differ', () => { + const rows = [baseTally, { ...baseTally, zoneNumber: 155 }] + expect(mergeDuplicateTallyRows(rows)).toEqual(rows) + }) +})