mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Merge branch 'cursor/5930b0db'
This commit is contained in:
commit
5cdd1027f4
45 changed files with 20428 additions and 108 deletions
|
|
@ -8,6 +8,7 @@ alwaysApply: false
|
|||
## Status
|
||||
|
||||
- **B19 registrado (2026-07-24):** gerenciar assessores em `/campanha/assessores` (lista + novo + detalhe) — só Coordenador Geral e Candidato; criar/editar contas `advisor`, carteira de municípios, reenviar senha; alinha access update/phone do `candidate`. Sem migration. Plano: [`gerenciar-assessores.md`](../../../docs/plans/gerenciar-assessores.md).
|
||||
- **E8 entregue em código (2026-07-24):** conta da cadeira — artefato TSE commitado estendido para v2 (`campoFederalVotesByYear`, `federalTallyByYear`, `majoritarian2022` presidente/governador T1 2022, via `pnpm build:election-aggregates`; teto de bytes revisado 400→700 KB) + curadoria `src/lib/campoParties.ts` (ano→siglas do campo, validada contra `electionPartySpectrum` por unit test); global `campaignGoals` (meta estadual 150 mil + margem, migration `20260724_180000_add_campaign_goals_global`, access staff-read/coordinator-write, **sem** hook de revalidação — `/campanha` é dinâmico com auth); `municipalityPotential.ts` (válidos projetados, teto do campo, captura, share intracampo, roll-off 2022-only, decomposição proporcional da meta + sanity por TI) e `goalCoverage.ts` (`meta = expectedVotes[cenário] ?? suggestedGoal`; `comprometido = Σ pledges`, nunca a expectativa da mesa — semântica travada contra o conflito da auditoria); UI Impeccable classe B: linha "Cobertura da meta" no dashboard e no overview da lista, nova coluna na lista de municípios (renomeando "Cobertura"→"Assessoria" para eliminar ambiguidade), card "Conta da cadeira" no detalhe (fixo em `central`, sem seletor de cenário). `/simplify` pós-entrega: removida a checagem de sanidade por TI do caminho quente por requisição (fica exportada/testada para o E12 consumir), lookup de potencial por município virou O(1) (`Map` em vez de `.find`), loaders quase-idênticos de votos majoritários/deputado federal unificados, tipo de tally duplicado unificado, formatadores de percentual/progresso deduplicados entre dashboard/overview/card. Desbloqueia **E9/E10/E12/E13** no roadmap. **Follow-up mesmo dia (feedback do usuário):** tooltips hover/foco nos 4 números de diagnóstico do card (`MunicipalityHoverTooltip`, sem ícone de interrogação); seed local passou a importar presidente/governador também para 2014/2018 (artefato/fórmulas continuam 2022-only); `/impeccable critique` do card (2×P1 touch dead-end + colisão de layout, 2×P2 idiomas de disclosure/cópia densa, 1×P3 progress bar) endereçado por completo — tooltip agora abre/fecha por toque com dismiss por toque fora, nunca cobre "Cobertura da meta", cópia reescrita em frase-líder + fórmula sem cross-reference, barra de progresso mais visível a 0%; registrado **E18** (documentação de conceitos de inteligência de campanha, plano futuro). Plano: [`conta-da-cadeira.md`](../../../docs/plans/conta-da-cadeira.md).
|
||||
- **C12 Registro-fundação entregue em código (2026-07-24):** trajetória de `votePledge` por versions nativas com baseline; atualização municipal `sinal` tipada (explicações, fonte e triangulação); `actionPlan.origin`; demandas vinculadas ao plano com custo derivado e criação múltipla atômica no formulário; `allocationDecision` ex-ante imutável com snapshot pequeno. Migration consolidada `20260724_180000_add_campaign_foundation_records`; testes de integração e jornada E2E dedicados.
|
||||
- **B18 registrado (2026-07-24):** filtros **salvos com nome** em `/campanha/municipios` (`localStorage`) + acesso rápido de 2º nível sob Municípios no sidebar (hover desktop / expand mobile; sticky só no filtro salvo ativo). Soft B16/B17 (barra). Fatia concreta do “saved views” que FD2 adiava como genérico. Sem migration. Plano: [`filtros-salvos-municipios.md`](../../../docs/plans/filtros-salvos-municipios.md).
|
||||
- **Avaliação: reorder de colunas por DnD (2026-07-24):** pedido para arrastar headers em `/campanha/municipios` → **fora de escopo** (não confundir com **B17** hide/show). Tecnicamente possível (~1–1,5d), mas conflita com sort B15 + filtros B16 no mesmo header, anti spreadsheet do PRODUCT, e não desbloqueia decisão de alocação. Gatilho em [`reordenar-colunas-lista-municipios.md`](../../../docs/plans/reordenar-colunas-lista-municipios.md).
|
||||
|
|
@ -87,6 +88,7 @@ alwaysApply: false
|
|||
|
||||
- Em 2026-07-24, **header da coluna A11 = `2022` (não "Concentração 2022"):** o título longo competia com células curtas (`%` + `12º`); no contexto da mesa o ano basta. Definição da métrica fica no hover do header (`formatMunicipalityConcentrationHint`). **Rejeitado:** manter "Concentração" no rótulo visível.
|
||||
- Em 2026-07-24, **C12 preserva fatos e decisões antes de sugerir:** pledges mantêm histórico nativo; sinais exigem tipo+fonte e distinguem triangulação; origem/custo são observados via plano→demandas; decisão de alocação é append-only para campanha (correção só por admin), sempre com racional e snapshot ex-ante pequeno. Demandas criadas junto do plano compartilham a mesma transação e fazem rollback integral em conflito. **Rejeitado:** sobrescrever trajetória, custo duplicado no plano, sugestão que decide automaticamente.
|
||||
- Em 2026-07-24, **conta da cadeira (E8) — meta × comprometido, teto do campo e derivados vão para o artefato commitado, não para queries em runtime:** `meta = expectedVotes[cenário] ?? suggestedGoal` (a expectativa da mesa nunca é substituída pela soma dos pledges no denominador de cobertura — resolve o conflito de `resolveMunicipalityStaffVoteTotal`, que faz a expectativa vencer); `comprometido = aggregate.effectiveByScenario[cenário]` (só Σ pledges). Teto do campo = presidencial do campo T1 2022 (#13) como série primária, governador como leitura secundária (assumido, para validar com produto). Decomposição da meta estadual é proporcional ao teto do campo projetado (`suggestedGoal_i = metaEfetiva × tetoProjetado_i / Σ tetoProjetado`), nunca uma fórmula composta com captura/share/roll-off como pesos — essas ficam como colunas de diagnóstico. Roll-off (brancos+nulos DF − majoritária) é 2022-only (não há tally majoritário seedado para 2014/2018). `teto do campo`/`captura`/`share intracampo`/`roll-off` são derivados de um artefato TSE **commitado** (v2 de `bahia-federal-baseline.json`, via `pnpm build:election-aggregates`), não de `unstable_cache` sobre `electionCandidateVote` (~100k/73k/52k linhas por ano) — o hardening de 2026-07-23 já havia banido esse padrão do runtime. Meta estadual mora num global editável (`campaignGoals`, 150 mil inicial); `campoParties.ts` é lib estática curada por ano, não collection administrável nem segunda fonte de verdade de partido (valida contra `electionPartySpectrum.ts` por unit test). **Rejeitado:** meta por município 100% automática; fórmula composta multifatorial de potencial; inferir "campo" pela coligação bruta do TSE; collection de metas versionadas nesta fatia (fica para C12).
|
||||
- Em 2026-07-24, **uma única série por cenário — "Metas" eliminadas:** o grupo `municipality.voteGoals` ("Meta Bom/Regular/Mínimo") duplicava o conceito de votos estimados e foi removido do app (collection, zod, view models, form/card, `utilities/voteGoals.ts`); a série do município é `expectedVotes` (pessimista/média/otimista). Planilha EXPECTATIVA mapeia **Bom→otimista, Regular→média, Mínimo→pessimista**. Migration `20260724_133600_drop_municipality_vote_goals` com backfill metas→estimativas (estimativa existente vence). **Supersede** o "não fundir com `voteGoals`" da decisão A10 abaixo. **Rejeitado:** manter os dois grupos com semânticas distintas (meta de planejamento × estimativa) — a mesa trabalha com um número por cenário.
|
||||
- Em 2026-07-24, **E4R overwrite-always:** `pnpm db:seed:projecao` sempre sobrescreve `expectedVotes` + `priority` nas linhas casadas (planilha nova sobre DB já populado). **Rejeitado:** empty-only / `--overwrite` opt-in (quebraria o re-seed). Dry-run continua obrigatório antes de produção.
|
||||
- Em 2026-07-23, **access explícito em toda collection (hardening Fase 0):** o default do Payload ("qualquer autenticado pode CRUD") é tratado como bug; toda collection declara `access` explícito — `users`/`signature`/`subscription`/`consent` só Payload admin (`payloadAdminOnly`), escrita de CMS admin-only com leitura pública preservada. JWTs de `campaignUser` não operam mais essas collections via `/api`. **Rejeitado:** confiar no default do Payload; RBAC só na camada de UI.
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
@ -65,6 +65,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
|
|||
|
||||
**Recently resolved (2026-07-24):** **E4R** Import único da planilha de projeção — `pnpm db:seed:projecao` (`scripts/import-projecao.mjs`) lê o xlsx canônico em `docs/sheets/`, faz match via `canonicalizeMunicipalityName` + `municipalityCatalog`, e **sempre sobrescreve** `municipality.expectedVotes` + `priority` (re-seed quando a mesa manda planilha nova); Salvador pulado; `--dry-run` + guard `ALLOW_REMOTE_DB`; parser puro em `src/lib/projectionSheetParse.ts` (Bom→`optimistic`, Regular→`central`, Mínimo→`pessimistic`). **Fase 2 (mesmo dia, decisão do produto — não é mais zero PII):** o seed também importa as colunas de estratégia, com PRIORITÁRIAS sobrepondo MAPA GERAL quando não-vazia — SITUAÇÃO→`politicalTrend` (QUEDA/MANTÉM/AUMENTO→desfavorável/neutra/favorável, com nota de proveniência), DOBRADINHAS→`stateDeputy`+`stateDeputies` (união; célula crua em `dobradinhaNotes`), LIDERANÇAS→`contact` name-only **sem telefone** + `leadership` (dedup estadual por slug do nome; `Contact.phone` virou opcional via migration `20260724_175500_contact_phone_optional`, UI continua exigindo), ASSESSOR RESPONSÁVEL→`campaignUser`+`advisors` (união; Edizio=coordinator, Solla=candidate — `candidate` agora é elegível como assessor em `eligibleCampaignStaffWhere`; novos usuários com email `<slug>@planilha.invalid` + senha aleatória, sem login até um admin trocar), ENCAMINHAMENTOS→`nextSteps`, OBSERVAÇÃO→`strengths`/`risks` (classificação curada por slug). Relações são união (nunca removem vínculos da UI); re-run idempotente; nomes de atores políticos entraram sem trilha de consentimento — regularizar com a assessoria jurídica. No mesmo dia, o grupo duplicado `municipality.voteGoals` ("Meta Bom/Regular/Mínimo") foi **removido** — collection, zod, view models, form/card e utilitário `voteGoals.ts` — via migration `20260724_133600_drop_municipality_vote_goals` (backfill metas→estimativas onde a estimativa estava vazia): `expectedVotes` (pessimista/média/otimista) é a única série por cenário do município. Plano: [`docs/plans/import-planilha-projecao.md`](docs/plans/import-planilha-projecao.md).
|
||||
|
||||
**Recently resolved (2026-07-24):** **E8** Conta da cadeira (metas derivadas, potencial por município, cobertura) — committed TSE artifact extended to v2 (`pnpm build:election-aggregates` now writes `campoFederalVotesByYear`, `federalTallyByYear`, and 2022 `majoritarian2022` presidente/governador T1 per municipality, alongside the existing `votesByYear`/`validVotesByYear`; byte budget raised 400→700 KB in the unit test); `src/lib/campoParties.ts` (curated field-party siglas per election year, unit-tested against `electionPartySpectrum.ts`'s `esquerda` bucket — not a second party classifier); global `campaignGoals` (`src/globals/CampaignGoals.ts`, migration `20260724_180000_add_campaign_goals_global`, `stateGoal` default 150,000 + `margin`/`baseYear`/`note`, `read` = admin/staff, `update` = admin/`isCampaignUnrestricted`, **no** `afterChange` hook — `/campanha` is dynamic with auth so nothing caches it); pure derived utilities `src/utilities/municipalityPotential.ts` (projected valid votes, field ceiling, capture rate, intra-field share, 2022-only roll-off, proportional goal decomposition + TI sanity check) and `src/utilities/goalCoverage.ts` — **locked semantics:** `meta = expectedVotes[scenario] ?? suggestedGoal`, `comprometido = aggregate.effectiveByScenario[scenario]` (pledges only — the mesa's own expectation never substitutes for the pledge sum in the coverage denominator, unlike `resolveMunicipalityStaffVoteTotal`); orchestration in `src/utilities/municipalityGoalAccount.ts` (`cache()`-deduplicated across dashboard/list/detail). UI (Impeccable class B): "Cobertura da meta" row on the dashboard and the list overview `CampaignMetricStrip`, a new list column (renaming the old "Cobertura" column to "Assessoria" to remove ambiguity) via a shared `MunicipalityListGoalCoverageCell` (compact/default layouts), and a new "Conta da cadeira" card on the municipality detail page (fixed to the `central` scenario, replacing the "Votos estimados" block that used to live in `MunicipalityStrategyCard`). Unblocks roadmap items **E9/E10/E12/E13**. Plano: [`docs/plans/conta-da-cadeira.md`](docs/plans/conta-da-cadeira.md).
|
||||
|
||||
**Recently resolved (2026-07-23):** **A10** Cenários de estimativa (pessimista/média/otimista) — `votePledge.estimatedVotes` e `municipality.expectedVotes` viram groups (`pessimistic|central|optimistic`); migration `20260723_124200_add_vote_estimate_scenarios` (backfill scalar→`central`); agregação por cenário em `votePledgeData` com default `central`; mapa 2026 com `values2026ByScenario` + seletor local; lista/overview/dashboard com média + faixa secundária; liderança continua com um `declaredVotes` sem ver estimativas. Plano: [`docs/plans/cenarios-estimativa-votos.md`](docs/plans/cenarios-estimativa-votos.md). Desbloqueia **E8**.
|
||||
|
||||
## Posts & Tags (news / publications)
|
||||
|
|
@ -124,7 +126,7 @@ The internal `/campanha` area is gated by its own authentication, deliberately k
|
|||
`/campanha` is organized around `municipality` ("Município") — the campaign's PREDEFINED operational territory. Deployed to production on 2026-07-23 (destructive remodel migrations applied via the Vercel build). Master plan: `docs/plans/remodelagem-municipios.md` (supersedes `docs/plans/remodelagem-pracas.md`).
|
||||
|
||||
- **Município definition:** 435 seeded rows — one per Bahia municipality (416), except Salvador (19 zone municipalities, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated). Canonical catalog: `src/lib/municipalityCatalog.ts` (`tests/fixtures/municipality-catalog.snapshot.json`). Seeded by migration `20260723_200000_remodel_municipalities` (+ reconcile `20260723_202000_reconcile_municipality_remodel`). Geography is admin-only; staff edit strategy fields and the coordinator assigns `advisors`.
|
||||
- **Collections (admin group `Campanha`):** `municipality`, `leadership`, `votePledge`, `organization`, `stateDeputy` (dobradinhas), `campaignDemand`, `municipalityUpdate`, `campaignInvite`, `supporter`, `actionPlan`.
|
||||
- **Collections (admin group `Campanha`):** `municipality`, `leadership`, `votePledge`, `organization`, `stateDeputy` (dobradinhas), `campaignDemand`, `municipalityUpdate`, `campaignInvite`, `supporter`, `actionPlan`. **Global** (same admin group): `campaignGoals` (E8 "conta da cadeira" — state-level vote goal + margin; `read` = admin/staff, `update` = admin/`isCampaignUnrestricted`; no `afterChange` hook — nothing caches it).
|
||||
- **Roles:** `coordinator` ("Coordenador Geral") and `candidate` ("Candidato", unrestricted visibility via `isCampaignUnrestricted`) see/manage everything staff sees; `advisor` ("Assessor") is scoped to `municipality.advisors`; `leader` ("Liderança") is **lockdown** — home is the supporter contact tool only (`LeaderContactsPanel`, `source: lideranca`), reads only supporters they created (`createdBy`), no municipalities/leaderships/pledges/demands/plans/election data.
|
||||
- **Vote pledge asymmetry:** staff declare `declaredVotes` and record `estimatedVotes` (pessimistic/central/optimistic); leaders never see estimates. Aggregates use `estimated[S] ?? declared`, default `central` (`src/utilities/votePledgeData.ts`).
|
||||
- **Demandas:** staff-only create/read (`canCreateCampaignDemand`); optional `leadership` provenance selected by staff. Escalated decisions: coordinator **or** candidate.
|
||||
|
|
|
|||
|
|
@ -1,12 +1,43 @@
|
|||
# E8 — Conta da cadeira (metas derivadas, potencial por município, cobertura)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-24 (refs sincronizadas pós-remodelagem Municípios + hardening; **A10 entregue e remodelagem em produção — dependências duras satisfeitas**; meta inicial ancorada na sessão de campo de 2026-07-23; `expectedVotes` seedados por E4R — o grupo `voteGoals` foi removido do app em 2026-07-24, a série manual por cenário é só `expectedVotes`)
|
||||
Status: entregue em código (2026-07-24; ver "Entregue (as-built)" abaixo)
|
||||
Atualizado em: 2026-07-24 (auditoria de implementação — ver "Achados da auditoria" abaixo; escopo completo confirmado com o usuário; appetite revisado ~2,5–3d; entrega registrada)
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Inteligência de campanha", E8; plano-mestre [inteligencia-campanha.md](inteligencia-campanha.md); A10 ✓)
|
||||
Impeccable: B — cobertura/metas encaixadas no dashboard, na lista de municípios e no detalhe (sem rota nova)
|
||||
Appetite: ~2 dias eng; migration pequena (global `campaignGoals`) + utilities derivadas + encaixes de UI
|
||||
Appetite: ~2,5–3 dias eng (revisado — degrau 3 da caching ladder para teto do campo/share intracampo/roll-off, ver auditoria); migration pequena (global `campaignGoals`) + artefato TSE estendido + utilities derivadas + encaixes de UI
|
||||
Responsável: —
|
||||
|
||||
## Achados da auditoria de implementação (2026-07-24)
|
||||
|
||||
Confirmado contra o repositório antes do craft: dependências duras satisfeitas (A10 vivo, remodelagem em produção, `voteGoals` de fato removido); o artefato commitado [bahia-federal-baseline.json](../../src/lib/electionAggregates/bahia-federal-baseline.json) já tem `votesByYear`/`validVotesByYear` por município para 2014/2018/2022; o banco local seedado para esta sessão confirma TSE completo nos três anos (DF T1 2014/2018/2022 + ticket 2022 completo, incluindo presidente/governador por zona) — o rabbit hole "roll-off por zona sem tally do majoritário" fica **fechado por evidência para 2022** (não para 2014/2018, que só têm deputado federal).
|
||||
|
||||
Ajustes ao plano abaixo, travados nesta sessão:
|
||||
|
||||
1. **Escala.** Teto do campo / share intracampo / roll-off NÃO usam `unstable_cache` sobre as collections como a seção "Abordagem proposta" sugeria — `electionCandidateVote` tem ~100k/73k/52k linhas por ano (2022/2018/2014); varrer isso por município na lista/dashboard é exatamente o padrão que o hardening de 2026-07-23 baniu do runtime. Vai para o **degrau 3 da caching ladder**: estender o artefato commitado via `pnpm build:election-aggregates` (`campoFederalVotesByYear`, `federalTallyByYear`, `majoritarian2022`), não um loader novo com tag.
|
||||
2. **Meta × comprometido (semântica).** A seção "Objetivos" abaixo (linha da cobertura) e o rabbit hole sobre `expectedVotes ?? Σ pledges` estavam ambíguos: hoje `resolveMunicipalityStaffVoteTotal` faz a expectativa **substituir** a soma dos pledges — usar isso como denominador daria 100% de cobertura nos ~189 municípios com planilha (E4R). **Travado:** `meta = expectedVotes[cenário] ?? suggestedGoal`; `comprometido = aggregate.effectiveByScenario[cenário]` (só pledges, nunca a expectativa da mesa). Cobertura = "quanto da expectativa está lastreado em compromisso auditável" — mesma regra do plano do [E9](fila-de-alocacao.md).
|
||||
3. **Global sem hook de revalidação.** `revalidateGlobal` citado nos "Componentes" abaixo é cerimônia morta: `/campanha` é dinâmico com auth (nada cacheia o global) e a tag não entraria no allowlist de `revalidateRequest.ts`. O global `campaignGoals` não terá `afterChange` hook (comentário no código justificando).
|
||||
4. **`campoParties.ts` não é uma segunda fonte de verdade de partido.** Já existe [electionPartySpectrum.ts](../../src/lib/electionPartySpectrum.ts) (buckets esquerda/centro/direita). `campoParties.ts` é a curadoria de **campo por ano** (2014/2018/2022/2026); um unit test prova que toda sigla do campo está no bucket `esquerda`, com exclusões documentadas.
|
||||
5. **Access de global é terreno novo.** Os 4 globals existentes (`SiteSettings`, `HomePage`, `Metadata`, `PrivacyPolicy`) usam `read: () => true` / `update: Boolean(user)`; nenhum está no grupo `Campanha` nem nega leitura. `campaignGoals` precisa de access explícito (JWT de `campaignUser` alcança `/api/globals/*`).
|
||||
|
||||
Decisões de produto travadas na mesma sessão (detalhes na seção "Decisões travadas" e "Questões em aberto" abaixo, já atualizadas): meta estadual inicial 150.000 (editável); válidos projetados = média ponderada `(v2014 + v2018 + 2×v2022) / 4`; decomposição proporcional ao teto do campo projetado (não fórmula composta); teto do campo = presidencial do campo T1 2022 (#13) como primário, governador como leitura secundária (assumido, validar com produto); roll-off = (brancos+nulos DF) − (brancos+nulos majoritária), 2022-only.
|
||||
|
||||
## Entregue (as-built, 2026-07-24)
|
||||
|
||||
Todas as 6 fases implementadas conforme o plano de execução, com os ajustes da auditoria já incorporados:
|
||||
|
||||
- **Artefato v2** (`pnpm build:election-aggregates`): `campoFederalVotesByYear`, `federalTallyByYear` e `majoritarian2022` (presidente/governador T1 2022) adicionados por município a `bahia-federal-baseline.json`, ao lado de `votesByYear`/`validVotesByYear`. Curadoria `src/lib/campoParties.ts` (2014/2018/2022/2026), validada por unit test contra o bucket `esquerda` de `electionPartySpectrum.ts`. Teto de bytes do artefato revisado de 400 KB para 700 KB (`tests/unit/bahiaElectionAggregates.unit.spec.ts`).
|
||||
- **Global `campaignGoals`** (`src/globals/CampaignGoals.ts`, migration `20260724_180000_add_campaign_goals_global`): `stateGoal` (default 150.000), `margin`, `baseYear`, `note`; `read` = admin/staff, `update` = admin/`isCampaignUnrestricted`; **sem** hook `afterChange` (achado #4 da auditoria — cerimônia morta, `/campanha` é dinâmico com auth). Loader `src/utilities/campaignGoals.ts` com React `cache()`.
|
||||
- **Derivados puros**: `src/utilities/municipalityPotential.ts` (`projectedValidVotes`, `fieldCeiling`, `projectedFieldCeiling`, `captureRate`, `intraFieldShare`, `rollOff`, `decomposeStateGoal`, `sanityCheckSuggestedGoalsByTerritory`) e `src/utilities/goalCoverage.ts` (`meta = expectedVotes[cenário] ?? suggestedGoal`; `comprometido = aggregate.effectiveByScenario[cenário]`, nunca a expectativa da mesa — achado #2). Orquestração server-scoped em `src/utilities/municipalityGoalAccount.ts` (`cache()`-deduplicado entre dashboard/lista/detalhe).
|
||||
- **UI** (Impeccable classe B, shape → craft → critique → harden): linha "Cobertura da meta" na `CampaignMetricStrip` do dashboard (`CampaignDashboard.tsx`) e do overview da lista (`MunicipalityListOverview.tsx`); nova coluna "Cobertura da meta" em `MunicipalityList.tsx` (a coluna antiga "Cobertura" foi renomeada para "Assessoria" para eliminar a ambiguidade), com célula compartilhada `MunicipalityListGoalCoverageCell.tsx` (variante `compact` para a tabela desktop, `default` para os cards mobile); card novo "Conta da cadeira" (`MunicipalityGoalAccountCard.tsx`) na aba Visão geral do detalhe do município, fixo no cenário `central` (sem seletor de cenário nessa página) — substitui o bloco "Votos estimados" que antes vivia em `MunicipalityStrategyCard.tsx`.
|
||||
- **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'`.
|
||||
|
|
@ -21,13 +52,13 @@ Na implementação (`implement-roadmap-item`): craft compacto → critique → p
|
|||
|
||||
## Contexto
|
||||
|
||||
O relatório de discovery ([§5.1](../research/relatorio-entrevista-persona-campanha.md)) fixou a OMTM da campanha até 16/08: **cobertura da meta por compromissos auditáveis** (Σ pledges ÷ meta, com delta semanal como número da reunião), decomposta por município. Hoje `municipality.expectedVotes` (pessimista/média/otimista) é a única série manual por cenário (o grupo `voteGoals` foi removido em 2026-07-24), mas: não há meta estadual nem decomposição de cima para baixo; não há noção de potencial derivado (válidos projetados, captura do campo, roll-off); denominadores baseados em `aptos` inflariam municípios de abstenção estrutural (relatório §6.6/I-B); e não existe "campo" definido por ano para share intracampo. Com **E4R** ([import-planilha-projecao.md](import-planilha-projecao.md)), `expectedVotes`/`priority` chegam seedados da planilha da coordenação (~189 municípios com estimativas em 3 cenários): a decomposição meta→município nasce **reconciliando** a sugestão derivada com as estimativas reais da mesa — o valor manual continua vencendo o sugerido, e divergências grandes viram aviso, não sobrescrita. Os dados TSE necessários já estão em `electionTally` (válidos/brancos/nulos/comparecimento por cargo×ano×cidade×zona) e `electionCandidateVote`; desde o hardening 2026-07-23, a série de válidos federais T1 + votos Solla por município já existe pré-agregada no artefato commitado `src/lib/bahiaElectionAggregates.ts` — preferir o artefato para válidos projetados; teto do campo, share intracampo e roll-off continuam derivando das collections (envolver em `unstable_cache` com a tag `election-tse`, padrão dos loaders de eleições).
|
||||
O relatório de discovery ([§5.1](../research/relatorio-entrevista-persona-campanha.md)) fixou a OMTM da campanha até 16/08: **cobertura da meta por compromissos auditáveis** (Σ pledges ÷ meta, com delta semanal como número da reunião), decomposta por município. Hoje `municipality.expectedVotes` (pessimista/média/otimista) é a única série manual por cenário (o grupo `voteGoals` foi removido em 2026-07-24), mas: não há meta estadual nem decomposição de cima para baixo; não há noção de potencial derivado (válidos projetados, captura do campo, roll-off); denominadores baseados em `aptos` inflariam municípios de abstenção estrutural (relatório §6.6/I-B); e não existe "campo" definido por ano para share intracampo. Com **E4R** ([import-planilha-projecao.md](import-planilha-projecao.md)), `expectedVotes`/`priority` chegam seedados da planilha da coordenação (~189 municípios com estimativas em 3 cenários): a decomposição meta→município nasce **reconciliando** a sugestão derivada com as estimativas reais da mesa — o valor manual continua vencendo o sugerido, e divergências grandes viram aviso, não sobrescrita. Os dados TSE necessários já estão em `electionTally` (válidos/brancos/nulos/comparecimento por cargo×ano×cidade×zona) e `electionCandidateVote`; desde o hardening 2026-07-23, a série de válidos federais T1 + votos Solla por município já existe pré-agregada no artefato commitado `src/lib/bahiaElectionAggregates.ts`. **Teto do campo, share intracampo e roll-off também vão para esse artefato** (não para um loader novo com `unstable_cache`): `electionCandidateVote` tem ~100k/73k/52k linhas por ano (2022/2018/2014) e a lista/dashboard varreriam isso por município a cada carregamento — exatamente o padrão de runtime que o hardening de 2026-07-23 baniu. `pnpm build:election-aggregates` passa a escrever `campoFederalVotesByYear`, `federalTallyByYear` e `majoritarian2022` por município, ao lado de `votesByYear`/`validVotesByYear` já existentes.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Global `campaignGoals` (admin group `Campanha`, staff-only): meta estadual de votos, margem, ano-base, nota.
|
||||
- Utilities derivadas por município (sem persistir o derivado): válidos projetados do cargo (série 2014/2018/2022), teto do campo (majoritário 1º turno), taxa de captura, share intracampo (via `campoParties.ts`), roll-off DF×majoritária, potencial e meta sugerida (decomposição proporcional ao potencial).
|
||||
- Cobertura: Σ (estimativa do cenário ativo — default **média/`central`** via A10 — `?? declaredVotes`) ÷ meta por município e agregada; delta semanal (exige C12 para série; até lá, delta vs. snapshot em memória do último load é aceitável mostrar como "—").
|
||||
- Cobertura por município e agregada, para os três cenários: `meta = expectedVotes[cenário] ?? suggestedGoal` (derivado); `comprometido = aggregate.effectiveByScenario[cenário]` (só Σ pledges — declarado/estimado por leadership, nunca a expectativa da mesa); `coverageRatio = comprometido ÷ meta`. Cenário ativo escolhido no cliente (`MunicipalityEstimateScenarioProvider`, default `central`). Delta semanal (exige C12 para série; até lá, delta vs. snapshot em memória do último load é aceitável mostrar como "—").
|
||||
- Encaixes: linha de cobertura no dashboard (`campaignDashboardData.ts`), colunas meta/cobertura na lista (`MunicipalityList`/`MunicipalityListOverview`), card no detalhe do município.
|
||||
- Access: tudo staff-only (`coordinator`/`advisor`); `leader` não vê metas — mesmo padrão de `expectedVotes`.
|
||||
- Sanity check por TI: Σ metas dos municípios do TI vs. participação histórica do TI no voto (aviso, não bloqueio).
|
||||
|
|
@ -42,22 +73,29 @@ O relatório de discovery ([§5.1](../research/relatorio-entrevista-persona-camp
|
|||
## Questões em aberto
|
||||
|
||||
- **Meta estadual inicial?** **Resolvido em campo (2026-07-23 — [CUSTOMER.md](../CUSTOMER.md)):** piso projetado do coordenador = **150 mil** (2022: 129 mil) — topo da faixa da cadeira. Valor inicial do global `campaignGoals` = 150 mil, editável (margem continua dele); o QE cheio segue rejeitado como default.
|
||||
- **Projeção de válidos: média simples da série ou tendência?** Opções: média 3 eleições | último ano | regressão simples. **Recomendação:** média ponderada (2022 peso 2) — barata, estável, sem cheiro de "previsão estatística" (fora de escopo).
|
||||
- **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 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
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Tally["electionTally + electionCandidateVote<br/>(série 2014/2018/2022)"]
|
||||
Campo["campoParties.ts"]
|
||||
Seed["electionTally + electionCandidateVote<br/>(local, já seedado)"]
|
||||
Campo["campoParties.ts<br/>(curadoria ano → siglas)"]
|
||||
Build["pnpm build:election-aggregates"]
|
||||
Artifact["bahia-federal-baseline.json v2<br/>(Solla, válidos, campo DF,<br/>teto majoritário 2022, roll-off 2022)"]
|
||||
Goals["global campaignGoals"]
|
||||
Pot["computeMunicipalityPotential<br/>(válidos proj., teto, captura, roll-off)"]
|
||||
Cov["computeGoalCoverage<br/>(Σ pledges ÷ meta)"]
|
||||
Cov["computeGoalCoverage<br/>(meta vs. comprometido)"]
|
||||
Pledges["aggregatePledgesByMunicipality<br/>(votePledgeData.ts)"]
|
||||
UI["dashboard + MunicipalityList/Overview + detalhe"]
|
||||
Tally --> Pot
|
||||
Campo --> Pot
|
||||
Goals --> Cov
|
||||
Seed --> Build
|
||||
Campo --> Build
|
||||
Build --> Artifact
|
||||
Artifact --> Pot
|
||||
Goals --> Pot
|
||||
Pot --> Cov
|
||||
Pledges --> Cov
|
||||
Cov --> UI
|
||||
|
|
@ -65,17 +103,18 @@ flowchart LR
|
|||
|
||||
Componentes:
|
||||
|
||||
- **`src/lib/campoParties.ts`**: mapa ano→partidos do campo (2014/2018/2022/2026), com fixture de teste.
|
||||
- **`src/utilities/municipalityPotential.ts`**: deriva por município (reusa `municipalityElectionGeography.ts` para células cidade×zona e `municipalityElectoralBaseline.ts` para a série); expõe `projectedValidVotes`, `fieldCeiling`, `captureRate`, `intraFieldShare`, `rollOff`, `suggestedGoal`.
|
||||
- **`src/utilities/goalCoverage.ts`**: cobertura por município/agregado consumindo `aggregatePledgesByMunicipality` (regra por cenário de [A10](cenarios-estimativa-votos.md) / `votePledgeData.ts`, default `central`) + `expectedVotes`/meta sugerida; sanity por TI via `bahiaTerritories.ts`.
|
||||
- **Global `CampaignGoals`** (`src/globals/CampaignGoals.ts`): campos `stateGoal`, `margin`, `baseYear`, `note`; access staff-read/coordinator-write; hook `revalidateGlobal`.
|
||||
- **`src/lib/campoParties.ts`**: mapa ano→partidos do campo (2014/2018/2022/2026), validado contra `electionPartySpectrum.ts` (unit test), com exclusões documentadas.
|
||||
- **`scripts/build-election-aggregates.mjs` + `src/lib/bahiaElectionAggregates.ts`**: artefato estendido (v2) — por município, além de `votesByYear`/`validVotesByYear`, agora `campoFederalVotesByYear` (3 anos), `federalTallyByYear` (comparecimento/brancos/nulos) e `majoritarian2022` (presidente/governador T1: votos + brancos/nulos/válidos).
|
||||
- **`src/utilities/municipalityPotential.ts`**: deriva por município a partir do artefato (nenhuma query no runtime); expõe `projectedValidVotes`, `fieldCeiling`, `captureRate`, `intraFieldShare`, `rollOff`, `suggestedGoal`.
|
||||
- **`src/utilities/goalCoverage.ts`**: `meta = expectedVotes[cenário] ?? suggestedGoal`; `comprometido = aggregate.effectiveByScenario[cenário]` (só Σ pledges, via [A10](cenarios-estimativa-votos.md) / `votePledgeData.ts`, default `central`) — a expectativa da mesa nunca substitui os pledges no denominador de cobertura; sanity por TI via `bahiaTerritories.ts`.
|
||||
- **Global `CampaignGoals`** (`src/globals/CampaignGoals.ts`): campos `stateGoal`, `margin`, `baseYear`, `note`; access staff-read/coordinator-write; **sem** hook de revalidação (`/campanha` é dinâmico com auth, nada cacheia este global — ver "Achados da auditoria").
|
||||
- **UI:** linha nova em `CampaignMetricStrip` do dashboard; coluna/ordenar por cobertura em `MunicipalityList` (reusa padrão B9 para editar meta via `municipalityStaffFormActions`); card "Conta da cadeira" no detalhe.
|
||||
- **Migration**: `pnpm migrate:create add_campaign_goals_global` (tabela do global). Sem mudança em collections.
|
||||
|
||||
## Dependências
|
||||
|
||||
- Duras: nenhuma pendente — **A10** entregue ([plano](cenarios-estimativa-votos.md)) e remodelagem em produção desde 2026-07-23 (schema `municipality`/`votePledge` vivo). Suave: C12 (delta semanal real da cobertura usa trajetória de pledges; até lá, sem histórico).
|
||||
- Reusa: `municipalityElectoralBaseline.ts`, `municipalityElectionGeography.ts`, `votePledgeData.ts` (agregação por cenário pós-A10), `municipalityPageData.ts` (`loadMunicipalityListPageBundle`), `campaignDashboardData.ts`, `municipalityStaffFormActions.ts`, `campaignAccess.ts`.
|
||||
- Reusa: `bahiaElectionAggregates.ts` (artefato v2), `municipalityElectionGeography.ts` (loaders do artefato v2), `votePledgeData.ts` (agregação por cenário pós-A10), `campaignMunicipalityScope.ts` (`loadMunicipalityScope`), `campaignDashboardData.ts`, `municipalityStaffFormActions.ts`, `campaignAccess.ts`.
|
||||
|
||||
## Não escopo
|
||||
|
||||
|
|
@ -85,12 +124,13 @@ 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.** Verificar cobertura de `electionTally` para presidente/governador por zona antes de expor; se faltar, roll-off só municipal (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
|
||||
|
||||
- **Delta semanal persistido.** Revisitar quando C12 entregar trajetória (gatilho: primeira semana com 2 snapshots).
|
||||
- **Meta por cenário (Bom/Regular/Mínimo) × cobertura tripla.** Gatilho: coordenador pedir leitura por cenário na reunião (hoje: cobre contra `regular ?? suggestedGoal`).
|
||||
- **Meta por cenário (pessimista/central/otimista) × cobertura tripla.** Resolvido nesta sessão como escopo do próprio E8 (não mais adiado): cobertura é computada para os três cenários no servidor, seletor de cenário fica no cliente (`MunicipalityEstimateScenarioProvider`, default `central`).
|
||||
- **Modelo de disclosure do grid de diagnóstico (`GoalAccountMetric`) ainda não "assentado" para mais métricas.** O `/impeccable critique` pós-hardening (26/40; ver Follow-up #3) levantou, em "Questions to Consider", se hover/foco continua o mecanismo certo de explicação conforme **E9/E10/B13/E11/E12/E13/E14** adicionam métricas a este mesmo card, e se "Captura"/"Share intracampo" deveriam virar um único número com toggle de denominador em vez de dois números cada um explicando o outro por tooltip. Nenhum achado adicional confirmado nesta sessão (é pergunta de design, não bug) — **não registrado como item**; gatilho: revisitar quando a próxima entrega de E9–E14 for adicionar uma 5ª métrica a esta `dl`/grid (nesse ponto, decidir layout/idioma de disclosure antes de replicar `GoalAccountMetric` de novo). Largura tablet/foldable intermediária também não testada — mesmo gatilho.
|
||||
|
||||
## Referências
|
||||
|
||||
|
|
|
|||
107
docs/plans/documentacao-conceitos-campanha.md
Normal file
107
docs/plans/documentacao-conceitos-campanha.md
Normal 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`)
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-24 (B19 gerenciar assessores; C12 ✓; C11 absorve escala plano↔demandas; B18; B17; B16; reorder DnD fora de escopo; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6, E8)
|
||||
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)
|
||||
|
|
@ -47,24 +47,24 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md](
|
|||
- **E4R (2026-07-24)** — import único da planilha de projeção → `municipality.expectedVotes` (Bom→otimista, Regular→média, Mínimo→pessimista) + `priority` (`pnpm db:seed:projecao`, always-overwrite, dry-run + runbook; Salvador pulado; zero PII). No mesmo dia o grupo duplicado `voteGoals` ("Meta Bom/Regular/Mínimo") foi **removido do app** (migration `20260724_133600` com backfill metas→estimativas) — a única série por cenário é `expectedVotes`. Plano: [import-planilha-projecao.md](plans/import-planilha-projecao.md). Seed local verificado (189 estimativas / 50 alta); produção após smoke.
|
||||
- **E17 (2026-07-24)** — tabela comparativa dos 27 Territórios de Identidade no Início staff (`/campanha`): `territoryOverview.ts` (rollup puro client-safe: `computeTerritoryRollup` + `sortTerritoryRows`) + `loadTerritoryOverview.ts` (loader server-only, `overrideAccess: true`) + `TerritoryOverviewTable.tsx` (tabela densa, ordenação client-side default `% da própria votação desc`, Metropolitano decomposto em Salvador 19 zonas × Demais RMS, linha→`/campanha/municipios?region=<TI>`). Somas/razões apenas (salvaguarda MAUP); sem migration/collection. Primeira fatia de E12. Plano: [tabela-ti-inicio.md](plans/tabela-ti-inicio.md).
|
||||
- **C12 (2026-07-24)** — registro-fundação: versions nativas em `votePledge` com baseline; sinais tipados em `municipalityUpdate`; origem do `actionPlan`; demandas vinculadas com custo derivado e criação múltipla atômica; `allocationDecision` ex-ante imutável. Migration `20260724_180000_add_campaign_foundation_records`; testes int + E2E. Plano: [registro-fundacao.md](plans/registro-fundacao.md).
|
||||
- **E8 (2026-07-24)** — conta da cadeira: artefato TSE commitado estendido para v2 (`campoFederalVotesByYear`, `federalTallyByYear`, `majoritarian2022` presidente/governador T1, via `pnpm build:election-aggregates`); curadoria `src/lib/campoParties.ts` (ano→siglas do campo, validada contra `electionPartySpectrum`); global `campaignGoals` (meta estadual 150 mil + margem, access staff-read/coordinator-write, migration `20260724_180000_add_campaign_goals_global`); `municipalityPotential.ts` (válidos projetados, teto do campo, captura, share intracampo, roll-off 2022-only, decomposição proporcional da meta + sanity por TI) e `goalCoverage.ts` (`meta = expectedVotes[cenário] ?? suggestedGoal`; `comprometido = Σ pledges`, nunca a expectativa da mesa); encaixado no dashboard, na visão geral e na lista de municípios (nova coluna "Cobertura da meta"), e num novo card "Conta da cadeira" no detalhe. Plano: [conta-da-cadeira.md](plans/conta-da-cadeira.md). Desbloqueia **E9**, **E10**, **E12**, **E13** (e melhora **B13**/**E16**).
|
||||
|
||||
## 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).
|
||||
|
||||
| ID | Fatia | Plano | Entrega essencial | Classe | Appetite | Janela | Depende de |
|
||||
| --- | ---------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | --------- | ------------------------------ |
|
||||
| E8 | Conta da cadeira | [detalhes](plans/conta-da-cadeira.md) | Global `campaignGoals`; potencial por município (válidos projetados, captura do campo, roll-off, headroom); decomposição meta→município; cobertura Σpledges÷meta (cenário default = média); meta inicial ancorada em campo: piso 150 mil (2022: 129k) | B | ~2d | 1–2 | — (A10 ✓, remodelagem em prod) |
|
||||
| E9 | Fila de alocação | [detalhes](plans/fila-de-alocacao.md) | Lista de decisão (7 colunas + % da própria votação — absorve A11), ordenação por déficit descoberto (default do staff quando houver metas — registro no fluxo de poder) + risco por frescor, "coluna da vergonha" | B | ~1,5d | 2 | E8 |
|
||||
| E16 | Dossiê do município (pré-agenda) | [detalhes](plans/dossie-municipio.md) | Dossiê pré-visita compondo dados existentes (série/captura, pledges/cobertura, lideranças, conjuntura, forças/riscos, dobradinhas, sinais, perfil A8) + emendas quando houver dado (G11); visão print — pedido explícito de campo (O6) | B | ~1d | 2 | — (melhora com E8/C12) |
|
||||
| E10 | Classificação territorial relativa | [detalhes](plans/classificacao-territorial-relativa.md) | Âncoras relativas (LQ / share da cadeira marginal), multi-eixo; substitui 35/20/10 para DF | B | ~1d | 3 | E8 |
|
||||
| B13 | Escala relativa no mapa | [detalhes](plans/escala-relativa-mapa.md) | Quantis do candidato (default) + LQ + rank; símbolo proporcional por votos em jogo; % válidos mantido | B | ~2d | 3 | E8, E10 |
|
||||
| E9 | Fila de alocação | [detalhes](plans/fila-de-alocacao.md) | Lista de decisão (7 colunas + % da própria votação — absorve A11), ordenação por déficit descoberto (default do staff quando houver metas — registro no fluxo de poder) + risco por frescor, "coluna da vergonha" | B | ~1,5d | 2 | E8 ✓ |
|
||||
| E16 | Dossiê do município (pré-agenda) | [detalhes](plans/dossie-municipio.md) | Dossiê pré-visita compondo dados existentes (série/captura, pledges/cobertura, lideranças, conjuntura, forças/riscos, dobradinhas, sinais, perfil A8) + emendas quando houver dado (G11); visão print — pedido explícito de campo (O6) | B | ~1d | 2 | — (melhora com E8 ✓/C12) |
|
||||
| E10 | Classificação territorial relativa | [detalhes](plans/classificacao-territorial-relativa.md) | Âncoras relativas (LQ / share da cadeira marginal), multi-eixo; substitui 35/20/10 para DF | B | ~1d | 3 | E8 ✓ |
|
||||
| B13 | Escala relativa no mapa | [detalhes](plans/escala-relativa-mapa.md) | Quantis do candidato (default) + LQ + rank; símbolo proporcional por votos em jogo; % válidos mantido | B | ~2d | 3 | E8 ✓, E10 |
|
||||
| E14 | Níveis de envolvimento N0–N4 | [detalhes](plans/niveis-de-envolvimento.md) | `engagementLevel` com histerese e sinais de reversão; staff-only (vocabulário duplo) | B | ~1d | 3 | C12 |
|
||||
| E11 | Motor de sugestões v1 | [detalhes](plans/motor-de-sugestoes.md) | 8 padrões (P1/P2/P3/P5/P6/P7/K-A/K-B), menu com estatuto, aceitar/descartar → `allocationDecision`, triagem 1–5 | C | ~3d | 3 | E8, E9, E10, C12 |
|
||||
| E12 | Camada TI | [detalhes](plans/camada-territorios-identidade.md) | Rollups com salvaguardas MAUP, benchmark intra-TI (gatilhos T no motor = fase 2 de E11) | B | ~1,5d | 3 | E8 |
|
||||
| E13 | Planejador de presença/giros | [detalhes](plans/planejador-de-giros.md) | Elegibilidade (5 condições ✓/—), fases construção/consolidação/ativação, compositor de giro, visita pedida×justificada | C | ~1,5d | 3 | E8, C12 |
|
||||
| E11 | Motor de sugestões v1 | [detalhes](plans/motor-de-sugestoes.md) | 8 padrões (P1/P2/P3/P5/P6/P7/K-A/K-B), menu com estatuto, aceitar/descartar → `allocationDecision`, triagem 1–5 | C | ~3d | 3 | E8 ✓, E9, E10, C12 |
|
||||
| E12 | Camada TI | [detalhes](plans/camada-territorios-identidade.md) | Rollups com salvaguardas MAUP, benchmark intra-TI (gatilhos T no motor = fase 2 de E11) | B | ~1,5d | 3 | E8 ✓ |
|
||||
| E13 | Planejador de presença/giros | [detalhes](plans/planejador-de-giros.md) | Elegibilidade (5 condições ✓/—), fases construção/consolidação/ativação, compositor de giro, visita pedida×justificada | C | ~1,5d | 3 | E8 ✓, C12 |
|
||||
| E15 | Backtest pós-eleição | [detalhes](plans/backtest-pos-eleicao.md) | Pledge (trajetória) vs. resultado por zona; calibração de limiares | A | ~1d | pós-04/10 | C12, TSE 2026 |
|
||||
|
||||
### Demais itens abertos
|
||||
|
|
@ -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,11 +131,12 @@ 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"]
|
||||
E8i["E8 ✓ Conta da cadeira"]
|
||||
E9i["E9 Fila de alocação"]
|
||||
C12i["C12 Registro-fundação"]
|
||||
C12i["C12 ✓ Registro-fundação"]
|
||||
E16i["E16 Dossiê do município"]
|
||||
E10i["E10 Classificação relativa"]
|
||||
B13i["B13 Escala relativa no mapa"]
|
||||
|
|
@ -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,13 +179,13 @@ flowchart TD
|
|||
B8F2 -.ZE Salvador.-> B14n
|
||||
```
|
||||
|
||||
Paralelizáveis agora: **E8** (A10 ✓, C12 ✓ e remodelagem em produção), **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 E8/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); **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; **E8** 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; **C12 ✓** registro-fundação já em código; **E8** conta da cadeira → **E9** fila de alocação; **E16** dossiê do município (pedido O6 — compõe o existente, melhora com E8); D2 se sobrar folga.
|
||||
**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.
|
||||
|
||||
**Janela 3 — 16/08 → set:** A6 dobradinha (pós-TSE 15/08); **E10** classificação relativa → **B13** escala relativa no mapa; **E14** níveis N0–N4; **E11** motor de sugestões v1; **E12** camada TI; **E13** planejador de giros; **B8 F2** polígonos das zonas de Salvador; débitos sobreviventes. Se houver folga **e** os deltas do ZAP não estiverem sendo absorvidos pelo fluxo sede-digita (C12): começar **D3**.
|
||||
|
||||
|
|
@ -190,9 +193,9 @@ Paralelizáveis agora: **E8** (A10 ✓, C12 ✓ e remodelagem em produção), **
|
|||
|
||||
### Cortes seguros / não cortáveis
|
||||
|
||||
**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); C2 dados reais; assimetria declarado×estimado (relação de campo); **E8**+**E9**+**C12** (a conta da cadeira, a fila e o registro ex-ante são o mínimo de "inteligência, não planilha" — e C12 é irrecuperável se não registrar durante a campanha); **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).
|
||||
**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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
/**
|
||||
* Builds the committed per-municipality election baseline artifact consumed by
|
||||
* the campaign map (src/lib/electionAggregates/bahia-federal-baseline.json).
|
||||
* the campaign map and by E8's "conta da cadeira" derived potential/coverage
|
||||
* (src/lib/electionAggregates/bahia-federal-baseline.json).
|
||||
*
|
||||
* Why an artifact instead of runtime queries: the 2014/2018/2022 TSE results
|
||||
* are immutable, but the map used to re-scan statewide city×zone slices on
|
||||
* every dashboard visit. Precomputing the per-municipality sums (Solla nominal
|
||||
* votes + federal T1 valid votes, cut by the same `municipalityCatalog`
|
||||
* geography used at runtime — Salvador split into zone municipalities, every
|
||||
* other municipality whole) removes all historical-year DB work.
|
||||
* are immutable, but the map/E8 used to re-scan statewide city×zone slices
|
||||
* (electionCandidateVote has ~100k/73k/52k rows for 2022/2018/2014) on every
|
||||
* dashboard/list visit. Precomputing the per-municipality sums — Solla nominal
|
||||
* votes, federal T1 valid votes, campo-parties federal nominal votes,
|
||||
* federal T1 turnout/blank/null, and 2022 majoritarian (presidente/governador
|
||||
* #13) votes+tally — cut by the same `municipalityCatalog` geography used at
|
||||
* runtime (Salvador split into zone municipalities, every other municipality
|
||||
* whole) removes all historical-year DB work from the request/build path.
|
||||
*
|
||||
* Why not compute during `pnpm build`: Vercel builds must not depend on the
|
||||
* production database CONTENT (TSE seeds are local-only by policy), and the
|
||||
|
|
@ -35,16 +39,18 @@ loadEnv({ path: '.env.local' })
|
|||
loadEnv({ path: '.env' })
|
||||
|
||||
const payloadConfig = (await import('../src/payload.config.ts')).default
|
||||
const { BASELINE_TICKET_2022, HISTORICAL_SERIES_YEARS } = await import(
|
||||
'../src/lib/electionResults.ts'
|
||||
)
|
||||
const { BASELINE_TICKET_2022, ELECTION_YEAR_2022, FEDERAL_DEPUTY_OFFICE, HISTORICAL_SERIES_YEARS } =
|
||||
await import('../src/lib/electionResults.ts')
|
||||
const { municipalityCatalog } = await import('../src/lib/municipalityCatalog.ts')
|
||||
const { municipalityElectionGeography } = await import(
|
||||
'../src/utilities/municipalityElectionGeography.ts'
|
||||
)
|
||||
const {
|
||||
loadCampoFederalVotesByCityZone,
|
||||
loadCandidateVotesByCityZone,
|
||||
loadOfficeTallyByCityZone,
|
||||
loadValidVotesByCityZone,
|
||||
sumOfficeTallyForGeography,
|
||||
sumVotesForGeography,
|
||||
} = await import('../src/utilities/municipalityElectoralBaseline.ts')
|
||||
|
||||
|
|
@ -65,13 +71,20 @@ const cliReader = { collection: 'users' }
|
|||
const candidateNumber = BASELINE_TICKET_2022.candidate.candidateNumber
|
||||
const municipalities = {}
|
||||
for (const entry of municipalityCatalog) {
|
||||
municipalities[entry.slug] = { votesByYear: {}, validVotesByYear: {} }
|
||||
municipalities[entry.slug] = {
|
||||
votesByYear: {},
|
||||
validVotesByYear: {},
|
||||
campoFederalVotesByYear: {},
|
||||
federalTallyByYear: {},
|
||||
}
|
||||
}
|
||||
|
||||
for (const year of HISTORICAL_SERIES_YEARS) {
|
||||
const [candidateVotes, validVotes] = await Promise.all([
|
||||
const [candidateVotes, validVotes, campoVotes, federalTally] = await Promise.all([
|
||||
loadCandidateVotesByCityZone(payload, cliReader, { year, candidateNumber }),
|
||||
loadValidVotesByCityZone(payload, cliReader, { year }),
|
||||
loadCampoFederalVotesByCityZone(payload, cliReader, { year }),
|
||||
loadOfficeTallyByCityZone(payload, cliReader, { year, office: FEDERAL_DEPUTY_OFFICE, turn: '1' }),
|
||||
])
|
||||
|
||||
if (candidateVotes.size === 0) {
|
||||
|
|
@ -83,17 +96,65 @@ for (const year of HISTORICAL_SERIES_YEARS) {
|
|||
}
|
||||
|
||||
let yearTotal = 0
|
||||
let campoTotal = 0
|
||||
for (const entry of municipalityCatalog) {
|
||||
const geography = municipalityElectionGeography(entry)
|
||||
const votes = sumVotesForGeography(candidateVotes, geography)
|
||||
const valid = sumVotesForGeography(validVotes, geography)
|
||||
const campo = sumVotesForGeography(campoVotes, geography)
|
||||
const tally = sumOfficeTallyForGeography(federalTally, geography)
|
||||
municipalities[entry.slug].votesByYear[String(year)] = votes
|
||||
municipalities[entry.slug].validVotesByYear[String(year)] = valid
|
||||
municipalities[entry.slug].campoFederalVotesByYear[String(year)] = campo
|
||||
municipalities[entry.slug].federalTallyByYear[String(year)] = tally
|
||||
yearTotal += votes
|
||||
campoTotal += campo
|
||||
}
|
||||
console.log(`[build:election-aggregates] ${year}: ${yearTotal} votes for #${candidateNumber}.`)
|
||||
console.log(
|
||||
`[build:election-aggregates] ${year}: ${yearTotal} votes for #${candidateNumber} ` +
|
||||
`(campo total: ${campoTotal}).`,
|
||||
)
|
||||
}
|
||||
|
||||
// 2022-only: majoritarian (presidente/governador #13) votes + tally, used for
|
||||
// "teto do campo" and roll-off — no majoritária seeded for 2014/2018 (E8 audit).
|
||||
const majoritarianOffices = [
|
||||
{ key: 'president', office: 'presidente', candidateNumber: BASELINE_TICKET_2022.president.candidateNumber },
|
||||
{ key: 'governor', office: 'governador', candidateNumber: BASELINE_TICKET_2022.governor.candidateNumber },
|
||||
]
|
||||
|
||||
const majoritarianByOffice = {}
|
||||
for (const { key, office, candidateNumber: officeCandidateNumber } of majoritarianOffices) {
|
||||
const [votes, tally] = await Promise.all([
|
||||
loadCandidateVotesByCityZone(payload, cliReader, {
|
||||
year: ELECTION_YEAR_2022,
|
||||
office,
|
||||
turn: '1',
|
||||
candidateNumber: officeCandidateNumber,
|
||||
}),
|
||||
loadOfficeTallyByCityZone(payload, cliReader, { year: ELECTION_YEAR_2022, office, turn: '1' }),
|
||||
])
|
||||
majoritarianByOffice[key] = { votes, tally }
|
||||
}
|
||||
|
||||
let presidentTotal = 0
|
||||
for (const entry of municipalityCatalog) {
|
||||
const geography = municipalityElectionGeography(entry)
|
||||
const president = {
|
||||
votes: sumVotesForGeography(majoritarianByOffice.president.votes, geography),
|
||||
...sumOfficeTallyForGeography(majoritarianByOffice.president.tally, geography),
|
||||
}
|
||||
const governor = {
|
||||
votes: sumVotesForGeography(majoritarianByOffice.governor.votes, geography),
|
||||
...sumOfficeTallyForGeography(majoritarianByOffice.governor.tally, geography),
|
||||
}
|
||||
municipalities[entry.slug].majoritarian2022 = { president, governor }
|
||||
presidentTotal += president.votes
|
||||
}
|
||||
console.log(
|
||||
`[build:election-aggregates] 2022 majoritarian: ${presidentTotal} votes for president #${BASELINE_TICKET_2022.president.candidateNumber}.`,
|
||||
)
|
||||
|
||||
const artifact = {
|
||||
provenance:
|
||||
'Derived from TSE open data seeded by pnpm db:seed:tse; regenerate with pnpm build:election-aggregates.',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
|
|||
import { CandidateComparePicker } from '@/components/campaign/CandidateComparePicker'
|
||||
import { MunicipalityBaselineCard } from '@/components/campaign/MunicipalityBaselineCard'
|
||||
import { MunicipalityCandidateComparisonTable } from '@/components/campaign/MunicipalityCandidateComparisonTable'
|
||||
import { MunicipalityGoalAccountCard } from '@/components/campaign/MunicipalityGoalAccountCard'
|
||||
import { MunicipalityLeadershipsPanel } from '@/components/campaign/MunicipalityLeadershipsPanel'
|
||||
import { MunicipalityPledgesPanel } from '@/components/campaign/MunicipalityPledgesPanel'
|
||||
import { MunicipalityStrategyCard } from '@/components/campaign/MunicipalityStrategyCard'
|
||||
|
|
@ -32,6 +33,7 @@ import {
|
|||
} from '@/utilities/municipalityDetailTabUi'
|
||||
import { municipalityElectionGeographyForSlug } from '@/utilities/municipalityElectionGeography'
|
||||
import { loadMunicipalityElectoralBaseline } from '@/utilities/municipalityElectoralBaseline'
|
||||
import { loadMunicipalityGoalAccount } from '@/utilities/municipalityGoalAccount'
|
||||
import {
|
||||
getMunicipalityDetailViewModel,
|
||||
MunicipalityNotFoundError,
|
||||
|
|
@ -46,10 +48,13 @@ import {
|
|||
parseMunicipalityUpdateFeedParams,
|
||||
} from '@/utilities/municipalityUpdatePageData'
|
||||
import { loadAdvisorSummaries } from '@/utilities/municipalityViewModels'
|
||||
import type { VoteEstimateScenarioViewModel } from '@/utilities/voteEstimate'
|
||||
import {
|
||||
aggregateMunicipalityPledgesFromRows,
|
||||
loadMunicipalityPledges,
|
||||
toMunicipalityPledgeCoverageView,
|
||||
type MunicipalityPledgeAggregate,
|
||||
type MunicipalityPledgeCoverageView,
|
||||
} from '@/utilities/votePledgeData'
|
||||
import { declareVotesFormAction, estimateVotesFormAction } from './pledgeFormActions'
|
||||
import { createMunicipalityUpdateFormAction } from './updateFormActions'
|
||||
|
|
@ -182,26 +187,64 @@ const OverviewTab = async ({
|
|||
view.kind === 'zona' ? <MunicipalityZoneNeighborhoodsCard municipalitySlug={view.slug} /> : null
|
||||
|
||||
const pledges = await loadMunicipalityPledges(payload, user, view.id)
|
||||
const pledgeCoverage = toMunicipalityPledgeCoverageView(
|
||||
aggregateMunicipalityPledgesFromRows(pledges),
|
||||
)
|
||||
const pledgeAggregate = aggregateMunicipalityPledgesFromRows(pledges)
|
||||
const pledgeCoverage = toMunicipalityPledgeCoverageView(pledgeAggregate)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{zoneNeighborhoodsCard}
|
||||
{view.strategy ? (
|
||||
<MunicipalityStrategyCard
|
||||
strategy={view.strategy}
|
||||
municipalitySlug={view.slug}
|
||||
canEdit
|
||||
pledgeCoverage={pledgeCoverage}
|
||||
/>
|
||||
<>
|
||||
<MunicipalityStrategyCard strategy={view.strategy} municipalitySlug={view.slug} canEdit />
|
||||
<GoalAccountCard
|
||||
payloadUser={{ payload, user }}
|
||||
municipalityID={view.id}
|
||||
slug={view.slug}
|
||||
expectedVotes={view.strategy.expectedVotes}
|
||||
pledgeCoverage={pledgeCoverage}
|
||||
pledgeAggregate={pledgeAggregate}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<MunicipalityPledgesPanel pledges={pledges} estimateFormAction={estimateVotesFormAction} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const GoalAccountCard = async ({
|
||||
payloadUser: { payload, user },
|
||||
municipalityID,
|
||||
slug,
|
||||
expectedVotes,
|
||||
pledgeCoverage,
|
||||
pledgeAggregate,
|
||||
}: {
|
||||
payloadUser: PayloadUser
|
||||
municipalityID: number
|
||||
slug: string
|
||||
expectedVotes: VoteEstimateScenarioViewModel
|
||||
pledgeCoverage: MunicipalityPledgeCoverageView | null
|
||||
pledgeAggregate: MunicipalityPledgeAggregate
|
||||
}) => {
|
||||
const { suggestedGoal, goalCoverage, potential } = await loadMunicipalityGoalAccount(
|
||||
payload,
|
||||
user,
|
||||
{ slug, expectedVotes },
|
||||
pledgeAggregate,
|
||||
)
|
||||
|
||||
return (
|
||||
<MunicipalityGoalAccountCard
|
||||
municipalityID={municipalityID}
|
||||
expectedVotes={expectedVotes}
|
||||
pledgeCoverage={pledgeCoverage}
|
||||
suggestedGoal={suggestedGoal}
|
||||
goalCoverage={goalCoverage}
|
||||
potential={potential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const MunicipalityTabFallback = () => (
|
||||
<div aria-hidden="true" className="flex flex-col gap-4">
|
||||
<div className="h-40 w-full animate-pulse rounded-xl border bg-muted/40" />
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ import { Badge } from '@/components/ui/Badge'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { formatElectionNumber } from '@/lib/electionInsights'
|
||||
import type { StaffDashboardView } from '@/utilities/campaignDashboardData'
|
||||
import {
|
||||
formatGoalCoverageDeficitLabel,
|
||||
formatGoalCoverageRatioLabel,
|
||||
goalCoverageProgressPercent,
|
||||
} from '@/utilities/goalCoverage'
|
||||
import {
|
||||
formatVoteEstimateEndpointsLabel,
|
||||
voteEstimateScenarioLabels,
|
||||
|
|
@ -68,6 +73,12 @@ export const CampaignDashboard = ({
|
|||
? Math.round((view.withAdvisorCount / view.municipalityCount) * 100)
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
label: 'Cobertura da meta',
|
||||
value: formatGoalCoverageRatioLabel(view.goalCoverage),
|
||||
detail: formatGoalCoverageDeficitLabel(view.goalCoverage),
|
||||
progress: goalCoverageProgressPercent(view.goalCoverage),
|
||||
},
|
||||
{
|
||||
label: 'Declarações sem estimativa',
|
||||
value: formatElectionNumber(view.missingEstimateCount),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,19 @@ export type CampaignMetric = {
|
|||
valueAriaLabel?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind needs the full class name present in source to generate it — this
|
||||
* map keeps existing 3-metric callers (`sm:grid-cols-3`, unchanged) working
|
||||
* byte-for-byte while giving 4-metric callers (E8 "conta da cadeira") a
|
||||
* balanced 2x2 layout on narrow screens instead of an orphaned 4th cell.
|
||||
*/
|
||||
const gridColsBySize: Record<number, string> = {
|
||||
1: 'sm:grid-cols-1',
|
||||
2: 'sm:grid-cols-2',
|
||||
3: 'sm:grid-cols-3',
|
||||
4: 'sm:grid-cols-2 lg:grid-cols-4',
|
||||
}
|
||||
|
||||
export const CampaignMetricStrip = ({
|
||||
metrics,
|
||||
className,
|
||||
|
|
@ -21,7 +34,8 @@ export const CampaignMetricStrip = ({
|
|||
}) => (
|
||||
<dl
|
||||
className={cn(
|
||||
'grid grid-cols-1 gap-0 divide-y overflow-hidden rounded-xl bg-card ring-1 ring-foreground/10 sm:grid-cols-3 sm:divide-x sm:divide-y-0',
|
||||
'grid grid-cols-1 gap-0 divide-y overflow-hidden rounded-xl bg-card ring-1 ring-foreground/10 sm:divide-x sm:divide-y-0',
|
||||
gridColsBySize[metrics.length] ?? 'sm:grid-cols-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
246
src/components/campaign/MunicipalityGoalAccountCard.tsx
Normal file
246
src/components/campaign/MunicipalityGoalAccountCard.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
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'
|
||||
import {
|
||||
formatGoalCoverageDeficitLabel,
|
||||
formatGoalCoverageRatioLabel,
|
||||
formatRatioAsPercentLabel,
|
||||
goalCoverageProgressPercent,
|
||||
type MunicipalityGoalCoverage,
|
||||
} from '@/utilities/goalCoverage'
|
||||
import type { MunicipalityPotential, RollOff } from '@/utilities/municipalityPotential'
|
||||
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 2022 disponível'
|
||||
const votesLabel = formatElectionNumber(rollOff.votes)
|
||||
const percentLabel =
|
||||
rollOff.percentOfTurnout != null
|
||||
? ` (${formatRatioAsPercentLabel(rollOff.percentOfTurnout)} do comparecimento)`
|
||||
: ''
|
||||
return `${votesLabel}${percentLabel}`
|
||||
}
|
||||
|
||||
/**
|
||||
* E8 "conta da cadeira" — meta × comprometido para este município, com o
|
||||
* bloco de diagnóstico (teto do campo, captura, share intracampo, roll-off).
|
||||
* Cenário fixo `central`: o detalhe não tem seletor de cenário (a lista e o
|
||||
* dashboard têm).
|
||||
*/
|
||||
export const MunicipalityGoalAccountCard = ({
|
||||
municipalityID,
|
||||
expectedVotes,
|
||||
pledgeCoverage,
|
||||
suggestedGoal,
|
||||
goalCoverage,
|
||||
potential,
|
||||
}: {
|
||||
municipalityID: number
|
||||
expectedVotes: VoteEstimateScenarioViewModel
|
||||
pledgeCoverage: MunicipalityPledgeCoverageView | null
|
||||
suggestedGoal: number
|
||||
goalCoverage: MunicipalityGoalCoverage
|
||||
potential: MunicipalityPotential
|
||||
}) => {
|
||||
const usesMesaEstimate = expectedVotes.central != null
|
||||
const latestIntraFieldShareYear = Math.max(
|
||||
...Object.keys(potential.intraFieldShareByYear).map(Number),
|
||||
)
|
||||
const latestIntraFieldShare = potential.intraFieldShareByYear[latestIntraFieldShareYear] ?? null
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="municipality-goal-account-title"
|
||||
className="flex flex-col gap-4 rounded-xl border p-4"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<h2 id="municipality-goal-account-title" className="text-base font-medium">
|
||||
Conta da cadeira
|
||||
</h2>
|
||||
<CampaignInfoHint label="Sobre a conta da cadeira">
|
||||
<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>
|
||||
|
||||
<div className="rounded-lg bg-muted/40 px-3 py-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Votos estimados</p>
|
||||
<MunicipalityListExpectedVotesControl
|
||||
municipalityID={municipalityID}
|
||||
expectedVotes={expectedVotes}
|
||||
pledgeCoverage={pledgeCoverage}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Meta usada na conta: {formatElectionNumber(goalCoverage.goal)} (
|
||||
{usesMesaEstimate ? 'estimativa da mesa' : 'meta sugerida, decomposição da meta estadual'})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-2xl font-semibold tabular-nums">
|
||||
{formatGoalCoverageRatioLabel(goalCoverage)}
|
||||
</span>
|
||||
<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">
|
||||
{formatGoalCoverageDeficitLabel(goalCoverage)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-3 border-t pt-3 text-sm sm:grid-cols-4">
|
||||
<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">
|
||||
Sem teto do campo projetado para decompor uma meta sugerida — cadastre a estimativa da
|
||||
mesa manualmente.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import Link from 'next/link'
|
|||
import { MunicipalityAdvisorAvatarStack } from '@/components/campaign/MunicipalityAdvisorAvatarStack'
|
||||
import { MunicipalityListAdvisorsControl } from '@/components/campaign/MunicipalityListAdvisorsControl'
|
||||
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/MunicipalityListExpectedVotesControl'
|
||||
import { MunicipalityListGoalCoverageCell } from '@/components/campaign/MunicipalityListGoalCoverageCell'
|
||||
import { MunicipalityListTrendControl } from '@/components/campaign/MunicipalityListTrendControl'
|
||||
import { MunicipalitySortableHead } from '@/components/campaign/MunicipalitySortableHead'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
|
|
@ -160,6 +161,14 @@ export const MunicipalityList = ({
|
|||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<dt className="text-muted-foreground">Cobertura da meta</dt>
|
||||
<dd>
|
||||
<MunicipalityListGoalCoverageCell
|
||||
coverageByScenario={municipality.goalCoverageByScenario}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<dt className="text-muted-foreground">Assessoria</dt>
|
||||
<dd>
|
||||
|
|
@ -224,8 +233,9 @@ export const MunicipalityList = ({
|
|||
Última atualização
|
||||
</MunicipalitySortableHead>
|
||||
<MunicipalitySortableHead state={state} sortKey="coverage">
|
||||
Cobertura
|
||||
Assessoria
|
||||
</MunicipalitySortableHead>
|
||||
<TableHead>Cobertura da meta</TableHead>
|
||||
</>
|
||||
) : (
|
||||
<MunicipalitySortableHead state={state} sortKey="lastUpdateAt">
|
||||
|
|
@ -318,6 +328,12 @@ export const MunicipalityList = ({
|
|||
{hasAdvisor ? 'Coberta' : 'Sem assessor'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MunicipalityListGoalCoverageCell
|
||||
coverageByScenario={municipality.goalCoverageByScenario}
|
||||
layout="compact"
|
||||
/>
|
||||
</TableCell>
|
||||
</>
|
||||
) : (
|
||||
<TableCell>
|
||||
|
|
|
|||
47
src/components/campaign/MunicipalityListGoalCoverageCell.tsx
Normal file
47
src/components/campaign/MunicipalityListGoalCoverageCell.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
'use client'
|
||||
|
||||
import { useMunicipalityEstimateScenarioOptional } from '@/components/campaign/MunicipalityEstimateScenarioContext'
|
||||
import {
|
||||
formatGoalCoverageDeficitLabel,
|
||||
formatGoalCoverageDeficitShortLabel,
|
||||
formatGoalCoverageRatioLabel,
|
||||
type MunicipalityGoalCoverage,
|
||||
} from '@/utilities/goalCoverage'
|
||||
import { DEFAULT_VOTE_ESTIMATE_SCENARIO, type VoteEstimateScenario } from '@/utilities/voteEstimate'
|
||||
|
||||
/**
|
||||
* E8 "conta da cadeira" list cell: reads the shared scenario picker (same
|
||||
* pattern as `MunicipalityListExpectedVotesControl`) and shows the
|
||||
* percent + deficit for that scenario, computed server-side for all three.
|
||||
*
|
||||
* `compact` (desktop table, dense) shows a short signed deficit with the
|
||||
* full sentence as a `title` tooltip — same "abbreviate + title" pattern as
|
||||
* `StaffMunicipalityVotesDisplay`'s "Nas lideranças" subline — so the column
|
||||
* doesn't force a wide `whitespace-nowrap` cell. `default` (mobile cards,
|
||||
* more room) spells the sentence out.
|
||||
*/
|
||||
export const MunicipalityListGoalCoverageCell = ({
|
||||
coverageByScenario,
|
||||
layout = 'default',
|
||||
}: {
|
||||
coverageByScenario: Record<VoteEstimateScenario, MunicipalityGoalCoverage>
|
||||
layout?: 'default' | 'compact'
|
||||
}) => {
|
||||
const scenarioContext = useMunicipalityEstimateScenarioOptional()
|
||||
const activeScenario = scenarioContext?.scenario ?? DEFAULT_VOTE_ESTIMATE_SCENARIO
|
||||
const coverage = coverageByScenario[activeScenario]
|
||||
const deficitLabel = formatGoalCoverageDeficitLabel(coverage)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium tabular-nums">{formatGoalCoverageRatioLabel(coverage)}</span>
|
||||
{layout === 'compact' ? (
|
||||
<span className="text-xs text-muted-foreground tabular-nums" title={deficitLabel}>
|
||||
{formatGoalCoverageDeficitShortLabel(coverage)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{deficitLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -12,6 +12,11 @@ import {
|
|||
import { VoteEstimateScenarioStrip } from '@/components/campaign/VoteEstimateScenarioStrip'
|
||||
import { formatElectionNumber } from '@/lib/electionInsights'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
formatGoalCoverageDeficitLabel,
|
||||
formatGoalCoverageRatioLabel,
|
||||
goalCoverageProgressPercent,
|
||||
} from '@/utilities/goalCoverage'
|
||||
import type { MunicipalityListOverviewData } from '@/utilities/municipalityPageData'
|
||||
import {
|
||||
formatVoteEstimateEndpointsLabel,
|
||||
|
|
@ -46,6 +51,7 @@ export const MunicipalityListOverview = ({ view }: { view: MunicipalityListOverv
|
|||
const endpointsLabel = formatVoteEstimateEndpointsLabel(scenarioTotals)
|
||||
const scenarioLabel = voteEstimateScenarioLabels[scenario]
|
||||
const hasScenarioTotals = hasAnyVoteEstimate(scenarioTotals)
|
||||
const goalCoverage = view.goalCoverageByScenario[scenario]
|
||||
|
||||
const heroDetail = (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
|
|
@ -117,6 +123,12 @@ export const MunicipalityListOverview = ({ view }: { view: MunicipalityListOverv
|
|||
? Math.round((view.withAdvisorCount / view.municipalityCount) * 100)
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
label: 'Cobertura da meta',
|
||||
value: formatGoalCoverageRatioLabel(goalCoverage),
|
||||
detail: formatGoalCoverageDeficitLabel(goalCoverage),
|
||||
progress: goalCoverageProgressPercent(goalCoverage),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { PencilIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { StaffMunicipalityVotesDisplay } from '@/components/campaign/StaffMunicipalityVotesDisplay'
|
||||
import { StateDeputyChips } from '@/components/campaign/StateDeputyChips'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -11,7 +10,6 @@ import {
|
|||
politicalTrendLabels,
|
||||
} from '@/utilities/municipalityUi'
|
||||
import type { MunicipalityDetailViewModel } from '@/utilities/municipalityViewModels'
|
||||
import type { MunicipalityPledgeCoverageView } from '@/utilities/votePledgeData'
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('pt-BR')
|
||||
|
||||
|
|
@ -19,12 +17,10 @@ export const MunicipalityStrategyCard = ({
|
|||
strategy,
|
||||
municipalitySlug,
|
||||
canEdit,
|
||||
pledgeCoverage,
|
||||
}: {
|
||||
strategy: NonNullable<MunicipalityDetailViewModel['strategy']>
|
||||
municipalitySlug: string
|
||||
canEdit: boolean
|
||||
pledgeCoverage: MunicipalityPledgeCoverageView | null
|
||||
}) => {
|
||||
const trend = strategy.politicalTrend
|
||||
|
||||
|
|
@ -63,14 +59,6 @@ export const MunicipalityStrategyCard = ({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted/40 px-3 py-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Votos estimados</p>
|
||||
<StaffMunicipalityVotesDisplay
|
||||
expectedVotes={strategy.expectedVotes}
|
||||
pledgeCoverage={pledgeCoverage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{trend.status || trend.note ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-sm font-medium">Tendência política</h3>
|
||||
|
|
|
|||
72
src/globals/CampaignGoals.ts
Normal file
72
src/globals/CampaignGoals.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { isCampaignStaff, isCampaignUnrestricted, isPayloadAdmin } from '@/utilities/campaignAccess'
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
const slug = 'campaignGoals'
|
||||
|
||||
/**
|
||||
* State-level campaign goal used by E8 "conta da cadeira" to decompose a
|
||||
* per-municipality suggested goal proportional to each municipality's
|
||||
* projected field ceiling (`municipalityPotential.ts`). Editable by staff
|
||||
* (piso do CG, sessão 2026-07-23: 150.000) rather than derived automatically —
|
||||
* decomposition reads this value, it never writes back to it.
|
||||
*
|
||||
* No `afterChange` revalidation hook: `/campanha` is dynamic with
|
||||
* authentication on every request (no ISR/ISR tag caches this global), and
|
||||
* `campaignGoals` is not in the `revalidateRequest.ts` tag allowlist. Adding
|
||||
* one here would be dead ceremony (see the E8 plan audit finding #4).
|
||||
*
|
||||
* Access is explicit (not Payload's "any authenticated user" default) because
|
||||
* a `campaignUser` JWT reaches `/api/globals/*` directly: leaders and
|
||||
* unauthenticated requests must be denied on read, and only coordinator/
|
||||
* candidate (`isCampaignUnrestricted`) may write the state goal.
|
||||
*/
|
||||
export const CampaignGoals: GlobalConfig = {
|
||||
slug,
|
||||
label: 'Metas da campanha',
|
||||
admin: {
|
||||
group: 'Campanha',
|
||||
},
|
||||
access: {
|
||||
read: ({ req: { user } }) => isPayloadAdmin(user) || isCampaignStaff(user),
|
||||
update: ({ req: { user } }) => isPayloadAdmin(user) || isCampaignUnrestricted(user),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'stateGoal',
|
||||
type: 'number',
|
||||
label: 'Meta estadual (votos)',
|
||||
required: true,
|
||||
defaultValue: 150_000,
|
||||
min: 0,
|
||||
admin: {
|
||||
description:
|
||||
'Piso decidido pela coordenação geral. A meta por município (E8) é decomposta proporcionalmente ao teto do campo projetado a partir deste valor.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'margin',
|
||||
type: 'number',
|
||||
label: 'Margem (%)',
|
||||
min: 0,
|
||||
admin: {
|
||||
description: 'Margem de segurança sobre a meta estadual, para leitura da mesa (não altera a decomposição).',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'baseYear',
|
||||
type: 'number',
|
||||
label: 'Ano de referência',
|
||||
admin: {
|
||||
description: 'Ano-base considerado pela coordenação ao fixar a meta estadual (contexto, não usado em cálculo).',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'note',
|
||||
type: 'textarea',
|
||||
label: 'Observação',
|
||||
admin: {
|
||||
description: 'Contexto livre sobre a decisão da meta (ex.: data e instância que decidiu).',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -6,16 +6,48 @@ import artifactJson from '@/lib/electionAggregates/bahia-federal-baseline.json'
|
|||
* `municipalityCatalog` geography the runtime uses (Salvador split by zone,
|
||||
* every other municipality whole — Camaçari included).
|
||||
*
|
||||
* 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 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
|
||||
* the rationale). A snapshot test pins artifact keys ↔ catalog slugs.
|
||||
*/
|
||||
|
||||
/** Turnout/valid/blank/null tally cell (`municipalityElectoralBaseline.ts`'s `OfficeTallyCell` is this same type). */
|
||||
export type FederalBaselineTallyCell = {
|
||||
comparecimento: number
|
||||
votosValidos: number
|
||||
votosBranco: number
|
||||
votosNulo: number
|
||||
}
|
||||
|
||||
/** One 2022 majoritarian office (presidente/governador #13): votes + tally. */
|
||||
type MajoritarianOfficeCell = FederalBaselineTallyCell & {
|
||||
votes: number
|
||||
}
|
||||
|
||||
export type MunicipalityFederalBaseline = {
|
||||
/** TSE year (as string) → Solla nominal votes inside the municipality geography. */
|
||||
votesByYear: Record<string, number>
|
||||
/** TSE year (as string) → federal T1 valid votes inside the municipality geography. */
|
||||
validVotesByYear: Record<string, number>
|
||||
/** TSE year (as string) → curated campo-parties (`campoParties.ts`) federal T1 nominal votes. */
|
||||
campoFederalVotesByYear: Record<string, number>
|
||||
/** TSE year (as string) → federal T1 turnout/valid/blank/null tally. */
|
||||
federalTallyByYear: Record<string, FederalBaselineTallyCell>
|
||||
/** 2022-only majoritarian tickets (absent for older artifacts / unknown slugs). */
|
||||
majoritarian2022?: {
|
||||
president: MajoritarianOfficeCell
|
||||
governor: MajoritarianOfficeCell
|
||||
}
|
||||
}
|
||||
|
||||
type FederalBaselineArtifact = {
|
||||
|
|
@ -33,7 +65,12 @@ export const FEDERAL_BASELINE_CANDIDATE_NUMBER = artifact.candidateNumber
|
|||
export const federalBaselineMunicipalitySlugs = (): string[] =>
|
||||
Object.keys(artifact.municipalities)
|
||||
|
||||
const EMPTY_BASELINE: MunicipalityFederalBaseline = { votesByYear: {}, validVotesByYear: {} }
|
||||
const EMPTY_BASELINE: MunicipalityFederalBaseline = {
|
||||
votesByYear: {},
|
||||
validVotesByYear: {},
|
||||
campoFederalVotesByYear: {},
|
||||
federalTallyByYear: {},
|
||||
}
|
||||
|
||||
/**
|
||||
* Baseline for one catalog municipality slug. Unknown slugs return an empty
|
||||
|
|
|
|||
61
src/lib/campoParties.ts
Normal file
61
src/lib/campoParties.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { normalizePartySigla } from '@/lib/electionPartySpectrum'
|
||||
|
||||
/**
|
||||
* Curated "campo" (Solla/PT political field) parties by year, used for the
|
||||
* deputado-federal "share intracampo" diagnostic (E8): of the votes that went
|
||||
* to the field, how many went to Solla specifically. This is NOT the same
|
||||
* concept as `electionPartySpectrum.ts` (ideological esquerda/centro/direita
|
||||
* bucket, from the Bolognesi et al. expert survey) — every entry here MUST
|
||||
* also be in the `esquerda` bucket (enforced by
|
||||
* `tests/unit/campoParties.unit.spec.ts`), but the reverse isn't required:
|
||||
* plenty of `esquerda`-bucket parties are not part of this specific ticket's
|
||||
* field in a given year.
|
||||
*
|
||||
* Deliberately NOT inferred from the raw TSE `coalition` string on
|
||||
* `electionCandidateVote`: Brazilian proportional "coligações" bundle
|
||||
* opportunistic, ideologically unrelated allies purely to maximize the
|
||||
* quotient. Confirmed against the locally seeded 2014/2018 data — Solla's own
|
||||
* registered coalition that year also included PP, PR, PSD, PTB (2014) /
|
||||
* PODE, PP, PR, PSD (2018), none of which belong in the political field.
|
||||
*
|
||||
* Sourcing:
|
||||
* - 2014: coalition "MAIS MUDANÇAS, NOVAS CONQUISTAS" (BA, deputado federal) —
|
||||
* PT's core-left partners inside it: PC do B, PDT.
|
||||
* - 2018: coalition "FRENTE DO TRABALHO POR TODA BAHIA" (BA, deputado
|
||||
* federal) — PT's core-left partners inside it: PC do B, PSB.
|
||||
* - 2022: PT ran under the "Federação Brasil da Esperança — FE Brasil"
|
||||
* (PT/PC do B/PV), a permanent partisan federation (TSE registered it
|
||||
* 2022-05-24) — not a temporary coalition. Verified via TSE candidate
|
||||
* filing (Folha "Ficha do candidato" 2022) and press coverage (Estadão,
|
||||
* Bahia Notícias). PSB backed the same governismo ticket for governor that
|
||||
* year but ran its own deputado-federal slate outside the federation, so it
|
||||
* is excluded from the 2022 federal-deputy field (would double count a
|
||||
* separate slate as "ours").
|
||||
* - 2026: FE Brasil is legally bound to stay unified through the end of the
|
||||
* 2023–2026 mandate, so it carries over unchanged. Revisit once 2026
|
||||
* deputado-federal candidacies are registered (TSE `consulta_cand_2026`).
|
||||
*/
|
||||
export type CampoElectionYear = 2014 | 2018 | 2022 | 2026
|
||||
|
||||
const CAMPO_PARTIES_BY_YEAR: Readonly<Record<CampoElectionYear, readonly string[]>> = {
|
||||
2014: ['PT', 'PCDOB', 'PDT'],
|
||||
2018: ['PT', 'PCDOB', 'PSB'],
|
||||
2022: ['PT', 'PCDOB', 'PV'],
|
||||
2026: ['PT', 'PCDOB', 'PV'],
|
||||
}
|
||||
|
||||
export const CAMPO_ELECTION_YEARS = Object.keys(CAMPO_PARTIES_BY_YEAR).map(Number) as CampoElectionYear[]
|
||||
|
||||
export const isCampoElectionYear = (year: number): year is CampoElectionYear =>
|
||||
(CAMPO_ELECTION_YEARS as readonly number[]).includes(year)
|
||||
|
||||
/** Campo party siglas (already normalized) for one year, or [] for an unknown year. */
|
||||
export const campoPartiesForYear = (year: number): readonly string[] =>
|
||||
isCampoElectionYear(year) ? CAMPO_PARTIES_BY_YEAR[year] : []
|
||||
|
||||
/** Whether a TSE party sigla belongs to the curated field for that year. */
|
||||
export const isCampoParty = (party: string | null | undefined, year: number): boolean => {
|
||||
const normalized = normalizePartySigla(party)
|
||||
if (!normalized) return false
|
||||
return campoPartiesForYear(year).includes(normalized)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -138,7 +138,8 @@ const bahiaMunicipalityByKey = new Map(
|
|||
|
||||
/**
|
||||
* Known TSE NM_MUNICIPIO spellings that diverge from the SEPLAN/SECULT canonical
|
||||
* names in bahiaTerritories (2022 open-data files). Keys are normalizeMunicipalityKey outputs.
|
||||
* names in bahiaTerritories (seen across 2014/2018/2022 open-data files). Keys
|
||||
* are normalizeMunicipalityKey outputs.
|
||||
*/
|
||||
const TSE_MUNICIPALITY_ALIASES: Readonly<Record<string, string>> = {
|
||||
// TSE: CAMACÃ → canonical Camacan
|
||||
|
|
@ -149,6 +150,8 @@ const TSE_MUNICIPALITY_ALIASES: Readonly<Record<string, string>> = {
|
|||
'MUQUEM DO SAO FRANCISCO': 'Muquém de São Francisco',
|
||||
// TSE: SANTA TEREZINHA → Santa Teresinha
|
||||
'SANTA TEREZINHA': 'Santa Teresinha',
|
||||
// TSE 2018 votacao_candidato_munzona: QUINJINGUE (typo) → canonical Quijingue
|
||||
QUINJINGUE: 'Quijingue',
|
||||
}
|
||||
|
||||
for (const [aliasKey, canonical] of Object.entries(TSE_MUNICIPALITY_ALIASES)) {
|
||||
|
|
|
|||
|
|
@ -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[] = []
|
||||
|
|
|
|||
|
|
@ -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] ?? ''
|
||||
|
|
|
|||
38
src/migrations/20260724_180000_add_campaign_goals_global.ts
Normal file
38
src/migrations/20260724_180000_add_campaign_goals_global.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
/**
|
||||
* Adds the `campaignGoals` global (E8 "conta da cadeira" — state-level vote
|
||||
* goal + margin used to decompose a per-municipality suggested goal).
|
||||
*
|
||||
* Hand-written rather than `migrate:create`-generated: the committed snapshot
|
||||
* chain has no `.json` for the last several migrations (2026-07-23/24 —
|
||||
* `add_vote_estimate_scenarios`, `remodel_municipalities`,
|
||||
* `add_state_deputy`, `reconcile_municipality_remodel`,
|
||||
* `drop_municipality_vote_goals`, `contact_phone_optional` all shipped
|
||||
* hand-written SQL without regenerating the drizzle-kit snapshot), so the
|
||||
* schema differ has no accurate "before" state for `municipality`/`contact`
|
||||
* and asks to disambiguate unrelated historical renames. This migration only
|
||||
* adds a brand-new table, so it's written directly following the pattern of
|
||||
* `20260719_054706_add_privacy_policy_global` (idempotent guard is
|
||||
* unnecessary — Payload never lets two migrations with the same name run
|
||||
* twice, and the table cannot already exist).
|
||||
*/
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "campaign_goals" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"state_goal" numeric NOT NULL,
|
||||
"margin" numeric,
|
||||
"base_year" numeric,
|
||||
"note" varchar,
|
||||
"updated_at" timestamp(3) with time zone,
|
||||
"created_at" timestamp(3) with time zone
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
DROP TABLE "campaign_goals" CASCADE;
|
||||
`)
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import * as migration_20260723_202000_reconcile_municipality_remodel from './202
|
|||
import * as migration_20260724_133600_drop_municipality_vote_goals from './20260724_133600_drop_municipality_vote_goals'
|
||||
import * as migration_20260724_175500_contact_phone_optional from './20260724_175500_contact_phone_optional'
|
||||
import * as migration_20260724_180000_add_campaign_foundation_records from './20260724_180000_add_campaign_foundation_records'
|
||||
import * as migration_20260724_180000_add_campaign_goals_global from './20260724_180000_add_campaign_goals_global'
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -168,4 +169,9 @@ export const migrations = [
|
|||
down: migration_20260724_180000_add_campaign_foundation_records.down,
|
||||
name: '20260724_180000_add_campaign_foundation_records',
|
||||
},
|
||||
{
|
||||
up: migration_20260724_180000_add_campaign_goals_global.up,
|
||||
down: migration_20260724_180000_add_campaign_goals_global.down,
|
||||
name: '20260724_180000_add_campaign_goals_global',
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -145,12 +145,14 @@ export interface Config {
|
|||
home: Home;
|
||||
metadata: Metadatum;
|
||||
'privacy-policy': PrivacyPolicy;
|
||||
campaignGoals: CampaignGoal;
|
||||
};
|
||||
globalsSelect: {
|
||||
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
|
||||
home: HomeSelect<false> | HomeSelect<true>;
|
||||
metadata: MetadataSelect<false> | MetadataSelect<true>;
|
||||
'privacy-policy': PrivacyPolicySelect<false> | PrivacyPolicySelect<true>;
|
||||
campaignGoals: CampaignGoalsSelect<false> | CampaignGoalsSelect<true>;
|
||||
};
|
||||
locale: null;
|
||||
widgets: {
|
||||
|
|
@ -2043,6 +2045,31 @@ export interface PrivacyPolicy {
|
|||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "campaignGoals".
|
||||
*/
|
||||
export interface CampaignGoal {
|
||||
id: number;
|
||||
/**
|
||||
* Piso decidido pela coordenação geral. A meta por município (E8) é decomposta proporcionalmente ao teto do campo projetado a partir deste valor.
|
||||
*/
|
||||
stateGoal: number;
|
||||
/**
|
||||
* Margem de segurança sobre a meta estadual, para leitura da mesa (não altera a decomposição).
|
||||
*/
|
||||
margin?: number | null;
|
||||
/**
|
||||
* Ano-base considerado pela coordenação ao fixar a meta estadual (contexto, não usado em cálculo).
|
||||
*/
|
||||
baseYear?: number | null;
|
||||
/**
|
||||
* Contexto livre sobre a decisão da meta (ex.: data e instância que decidiu).
|
||||
*/
|
||||
note?: string | null;
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "site-settings_select".
|
||||
|
|
@ -2115,6 +2142,19 @@ export interface PrivacyPolicySelect<T extends boolean = true> {
|
|||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "campaignGoals_select".
|
||||
*/
|
||||
export interface CampaignGoalsSelect<T extends boolean = true> {
|
||||
stateGoal?: T;
|
||||
margin?: T;
|
||||
baseYear?: T;
|
||||
note?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "collections_widget".
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { SupporterImportBatch } from './collections/SupporterImportBatch'
|
|||
import { Tag } from './collections/Tag'
|
||||
import { Users } from './collections/Users'
|
||||
import { VotePledge } from './collections/VotePledge'
|
||||
import { CampaignGoals } from './globals/CampaignGoals'
|
||||
import { HomePage } from './globals/HomePage'
|
||||
import { Metadata } from './globals/Metadata'
|
||||
import { PrivacyPolicy } from './globals/PrivacyPolicy'
|
||||
|
|
@ -108,7 +109,7 @@ export default buildConfig({
|
|||
Post,
|
||||
Tag,
|
||||
],
|
||||
globals: [SiteSettings, HomePage, Metadata, PrivacyPolicy],
|
||||
globals: [SiteSettings, HomePage, Metadata, PrivacyPolicy, CampaignGoals],
|
||||
editor: lexicalEditor(),
|
||||
secret: process.env.PAYLOAD_SECRET || '',
|
||||
typescript: {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import type { Payload } from 'payload'
|
|||
|
||||
import type { CampaignUser, VotePledge } from '@/payload-types'
|
||||
import { loadMunicipalityScope } from '@/utilities/campaignMunicipalityScope'
|
||||
import type { MunicipalityGoalCoverage } from '@/utilities/goalCoverage'
|
||||
import { loadMunicipalityGoalCoverageBundle } from '@/utilities/municipalityGoalAccount'
|
||||
import { relationshipId, requireRelationshipId } from '@/utilities/relationship'
|
||||
import type { VoteEstimateScenario } from '@/utilities/voteEstimate'
|
||||
import { rollupMunicipalityStaffVotes } from '@/utilities/votePledgeData'
|
||||
|
|
@ -16,6 +18,8 @@ export type StaffDashboardView = {
|
|||
declaredVotesTotal: number
|
||||
pledgeCount: number
|
||||
missingEstimateCount: number
|
||||
/** E8 "conta da cadeira" — meta × comprometido, cenário central (dashboard não tem seletor de cenário). */
|
||||
goalCoverage: MunicipalityGoalCoverage
|
||||
missingEstimates: Array<{
|
||||
pledgeID: number
|
||||
municipalityName: string
|
||||
|
|
@ -108,6 +112,13 @@ export const getCampaignDashboardData = async (
|
|||
const rollup = rollupMunicipalityStaffVotes(municipalities.docs, pledgeAggregates)
|
||||
const { staffVoteTotalByScenario, declaredVotesTotal, pledgeCount, missingEstimateCount } = rollup
|
||||
|
||||
const goalCoverageBundle = await loadMunicipalityGoalCoverageBundle(
|
||||
payload,
|
||||
user,
|
||||
municipalities.docs,
|
||||
pledgeAggregates,
|
||||
)
|
||||
|
||||
const leadershipIDs = [
|
||||
...new Set(
|
||||
missingEstimatePledges.docs.map((pledge) => requireRelationshipId(pledge.leadership)),
|
||||
|
|
@ -141,6 +152,7 @@ export const getCampaignDashboardData = async (
|
|||
declaredVotesTotal,
|
||||
pledgeCount,
|
||||
missingEstimateCount,
|
||||
goalCoverage: goalCoverageBundle.aggregateByScenario.central,
|
||||
missingEstimates: missingEstimatePledges.docs.map((pledge) => {
|
||||
const municipality = municipalityById.get(requireRelationshipId(pledge.municipality))
|
||||
const leadership = leadershipsById.get(requireRelationshipId(pledge.leadership))
|
||||
|
|
|
|||
37
src/utilities/campaignGoals.ts
Normal file
37
src/utilities/campaignGoals.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import 'server-only'
|
||||
|
||||
import { cache } from 'react'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import type { CampaignGoal, CampaignUser, User } from '@/payload-types'
|
||||
|
||||
type CampaignGoalsReader = CampaignUser | User
|
||||
|
||||
/**
|
||||
* Request-scoped read of the `campaignGoals` global (E8 "conta da cadeira" —
|
||||
* state-level vote goal + margin, decomposed into a per-municipality
|
||||
* suggested goal by `municipalityPotential.ts`). Degrau 1 da caching ladder
|
||||
* (React `cache()` per-request dedup): the global changes rarely and every
|
||||
* page that shows coverage needs the same value once per request.
|
||||
*
|
||||
* No `unstable_cache`/tag layer on top: `/campanha` is dynamic with
|
||||
* per-request auth, so a cross-request cache would need its own
|
||||
* invalidation path for no read-latency benefit (see the global's own
|
||||
* comment on why it skips the `revalidateGlobal` hook).
|
||||
*
|
||||
* Access is enforced explicitly (not delegated to `payload.findGlobal`'s
|
||||
* default admin bypass): pass `user` + `overrideAccess: false` so leaders and
|
||||
* unauthenticated CLI-less callers hit the global's own `access.read` and get
|
||||
* denied, matching the Local API security pattern used everywhere else in
|
||||
* this codebase.
|
||||
*/
|
||||
export const loadCampaignGoals = cache(
|
||||
async (payload: Payload, user: CampaignGoalsReader): Promise<CampaignGoal> =>
|
||||
payload.findGlobal({
|
||||
slug: 'campaignGoals',
|
||||
depth: 0,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
)
|
||||
136
src/utilities/goalCoverage.ts
Normal file
136
src/utilities/goalCoverage.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { formatElectionNumber } from '@/lib/electionInsights'
|
||||
import type { MunicipalityPledgeAggregate } from '@/utilities/votePledgeData'
|
||||
import {
|
||||
getVoteEstimateForScenario,
|
||||
VOTE_ESTIMATE_SCENARIOS,
|
||||
type VoteEstimateScenario,
|
||||
type VoteEstimateScenarioFields,
|
||||
} from '@/utilities/voteEstimate'
|
||||
|
||||
/**
|
||||
* E8 "conta da cadeira" — coverage of the goal by auditable pledges.
|
||||
*
|
||||
* Semantics locked this session (see the E8 plan's audit finding #2):
|
||||
* - **meta** = `expectedVotes[cenário] ?? suggestedGoal` — the mesa's own
|
||||
* expectation when set (`Municipality.expectedVotes`), falling back to the
|
||||
* goal decomposed from the state goal (`municipalityPotential.ts`).
|
||||
* - **comprometido** = `aggregate.effectiveByScenario[cenário]` — the pledge
|
||||
* aggregate ONLY (`estimated[S] ?? declared`, summed over `votePledge`
|
||||
* rows). It is deliberately **not** `resolveMunicipalityStaffVoteTotal`,
|
||||
* which lets `expectedVotes` override the pledge sum — using that here
|
||||
* would report 100% coverage everywhere the projection-sheet import (E4R)
|
||||
* set an expectation without a single pledge behind it.
|
||||
*
|
||||
* Coverage answers "how much of what the mesa expects is backed by an
|
||||
* auditable commitment", not "did the mesa fill in a number".
|
||||
*/
|
||||
export type MunicipalityGoalCoverage = {
|
||||
goal: number
|
||||
committed: number
|
||||
/** committed / goal, or null when goal is zero/negative (avoids a misleading Infinity/NaN). */
|
||||
coverageRatio: number | null
|
||||
/** goal − committed; negative means the municipality is over-committed relative to its goal. */
|
||||
deficit: number
|
||||
}
|
||||
|
||||
/** Read-only zero coverage — never mutate; use for view models outside the staff scope. */
|
||||
const emptyMunicipalityGoalCoverage: Readonly<MunicipalityGoalCoverage> = {
|
||||
goal: 0,
|
||||
committed: 0,
|
||||
coverageRatio: null,
|
||||
deficit: 0,
|
||||
}
|
||||
|
||||
/** All three scenarios pointing at the same zero coverage (never mutate). */
|
||||
export const createEmptyGoalCoverageByScenario = (): Record<
|
||||
VoteEstimateScenario,
|
||||
MunicipalityGoalCoverage
|
||||
> => {
|
||||
const byScenario = {} as Record<VoteEstimateScenario, MunicipalityGoalCoverage>
|
||||
for (const scenario of VOTE_ESTIMATE_SCENARIOS) byScenario[scenario] = emptyMunicipalityGoalCoverage
|
||||
return byScenario
|
||||
}
|
||||
|
||||
/** Coverage for one municipality, one scenario. */
|
||||
export const computeGoalCoverage = (
|
||||
expectedVotes: VoteEstimateScenarioFields | null | undefined,
|
||||
suggestedGoal: number,
|
||||
pledgeAggregate: MunicipalityPledgeAggregate,
|
||||
scenario: VoteEstimateScenario,
|
||||
): MunicipalityGoalCoverage => {
|
||||
const goal = getVoteEstimateForScenario(expectedVotes, scenario) ?? suggestedGoal
|
||||
const committed = pledgeAggregate.effectiveByScenario[scenario]
|
||||
const coverageRatio = goal > 0 ? committed / goal : null
|
||||
const deficit = goal - committed
|
||||
return { goal, committed, coverageRatio, deficit }
|
||||
}
|
||||
|
||||
/** Coverage for one municipality, all three scenarios (mirrors `staffVoteTotalByScenario`). */
|
||||
export const computeGoalCoverageByScenario = (
|
||||
expectedVotes: VoteEstimateScenarioFields | null | undefined,
|
||||
suggestedGoal: number,
|
||||
pledgeAggregate: MunicipalityPledgeAggregate,
|
||||
): Record<VoteEstimateScenario, MunicipalityGoalCoverage> => {
|
||||
const coverageByScenario = {} as Record<VoteEstimateScenario, MunicipalityGoalCoverage>
|
||||
for (const scenario of VOTE_ESTIMATE_SCENARIOS) {
|
||||
coverageByScenario[scenario] = computeGoalCoverage(
|
||||
expectedVotes,
|
||||
suggestedGoal,
|
||||
pledgeAggregate,
|
||||
scenario,
|
||||
)
|
||||
}
|
||||
return coverageByScenario
|
||||
}
|
||||
|
||||
/** Sums a list of per-municipality coverages into one aggregate (dashboard/overview total). */
|
||||
export const aggregateGoalCoverage = (
|
||||
entries: ReadonlyArray<MunicipalityGoalCoverage>,
|
||||
): MunicipalityGoalCoverage => {
|
||||
const goal = entries.reduce((sum, entry) => sum + entry.goal, 0)
|
||||
const committed = entries.reduce((sum, entry) => sum + entry.committed, 0)
|
||||
const coverageRatio = goal > 0 ? committed / goal : null
|
||||
const deficit = goal - committed
|
||||
return { goal, committed, coverageRatio, deficit }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic ratio→percent label, e.g. for `MunicipalityPotential`'s
|
||||
* `captureRate2022`/`intraFieldShareByYear` diagnostics on the goal-account
|
||||
* card — shared here so those and `coverageRatio` don't each round/format
|
||||
* independently.
|
||||
*/
|
||||
export const formatRatioAsPercentLabel = (ratio: number | null): string =>
|
||||
ratio == null ? '—' : `${Math.round(ratio * 100)}%`
|
||||
|
||||
/** `coverageRatio` as a rounded percent string, or "—" when the goal is zero/unknown. */
|
||||
export const formatGoalCoverageRatioLabel = (coverage: MunicipalityGoalCoverage): string =>
|
||||
formatRatioAsPercentLabel(coverage.coverageRatio)
|
||||
|
||||
/** `coverageRatio` clamped to [0, 100] for a `Progress` bar, or `undefined` when unknown. */
|
||||
export const goalCoverageProgressPercent = (coverage: MunicipalityGoalCoverage): number | undefined =>
|
||||
coverage.coverageRatio == null ? undefined : Math.min(100, Math.round(coverage.coverageRatio * 100))
|
||||
|
||||
/** UI copy for `deficit`: how many votes are missing (or exceeded) relative to the goal. */
|
||||
export const formatGoalCoverageDeficitLabel = (coverage: MunicipalityGoalCoverage): string => {
|
||||
if (coverage.goal <= 0) return 'Sem meta definida'
|
||||
if (coverage.deficit > 0) {
|
||||
return `Faltam ${formatElectionNumber(coverage.deficit)} votos para a meta`
|
||||
}
|
||||
if (coverage.deficit < 0) {
|
||||
return `${formatElectionNumber(Math.abs(coverage.deficit))} acima da meta`
|
||||
}
|
||||
return 'Meta coberta'
|
||||
}
|
||||
|
||||
/**
|
||||
* `-1.234`/`+300`-style short form of `deficit` for dense table cells — pair
|
||||
* with `formatGoalCoverageDeficitLabel` as a `title` tooltip (same pattern as
|
||||
* `StaffMunicipalityVotesDisplay`'s "Nas lideranças" subline).
|
||||
*/
|
||||
export const formatGoalCoverageDeficitShortLabel = (coverage: MunicipalityGoalCoverage): string => {
|
||||
if (coverage.goal <= 0) return '—'
|
||||
if (coverage.deficit === 0) return '0'
|
||||
const sign = coverage.deficit > 0 ? '−' : '+'
|
||||
return `${sign}${formatElectionNumber(Math.abs(coverage.deficit))}`
|
||||
}
|
||||
|
|
@ -2,11 +2,15 @@ import configPromise from '@payload-config'
|
|||
import { unstable_cache } from 'next/cache'
|
||||
import { getPayload, type Payload } from 'payload'
|
||||
|
||||
import type { FederalBaselineTallyCell } from '@/lib/bahiaElectionAggregates'
|
||||
import { isCampoParty } from '@/lib/campoParties'
|
||||
import {
|
||||
BASELINE_TICKET_2022,
|
||||
ELECTION_YEAR_2022,
|
||||
FEDERAL_DEPUTY_OFFICE,
|
||||
HISTORICAL_SERIES_YEARS,
|
||||
type ElectionOffice,
|
||||
type ElectionTurn,
|
||||
} from '@/lib/electionResults'
|
||||
import type { CampaignUser, User } from '@/payload-types'
|
||||
import { assertCanReadElectionData } from '@/utilities/campaignAccess'
|
||||
|
|
@ -265,11 +269,21 @@ export const loadValidVotesByCityZone = async (
|
|||
return validVotes
|
||||
}
|
||||
|
||||
/** cityCode:zone → votes map for one candidate/year (municipality-level slicing). */
|
||||
/**
|
||||
* cityCode:zone → votes map for one candidate/year (municipality-level
|
||||
* slicing). Defaults to deputado-federal T1 for the existing map/E2 callers;
|
||||
* E8's "teto do campo" passes `office`/`turn` explicitly to slice
|
||||
* presidente/governador instead.
|
||||
*/
|
||||
export const loadCandidateVotesByCityZone = async (
|
||||
payload: Payload,
|
||||
user: ElectionReader,
|
||||
{ year, candidateNumber }: { year: number; candidateNumber: number },
|
||||
{
|
||||
year,
|
||||
candidateNumber,
|
||||
office = FEDERAL_DEPUTY_OFFICE,
|
||||
turn = '1',
|
||||
}: { year: number; candidateNumber: number; office?: ElectionOffice; turn?: ElectionTurn },
|
||||
): Promise<Map<string, number>> => {
|
||||
assertCanReadElectionData(user)
|
||||
|
||||
|
|
@ -278,8 +292,8 @@ export const loadCandidateVotesByCityZone = async (
|
|||
where: {
|
||||
and: [
|
||||
{ year: { equals: year } },
|
||||
{ office: { equals: FEDERAL_DEPUTY_OFFICE } },
|
||||
{ turn: { equals: '1' } },
|
||||
{ office: { equals: office } },
|
||||
{ turn: { equals: turn } },
|
||||
{ voteType: { equals: 'nominal' } },
|
||||
{ candidateNumber: { equals: candidateNumber } },
|
||||
],
|
||||
|
|
@ -298,3 +312,117 @@ export const loadCandidateVotesByCityZone = async (
|
|||
}
|
||||
return votes
|
||||
}
|
||||
|
||||
/**
|
||||
* cityCode:zone → sum of nominal deputado-federal T1 votes for every
|
||||
* candidate whose party is in the curated `campoParties.ts` field for that
|
||||
* year (E8 "share intracampo" denominator — CLI/build-time use only, this
|
||||
* scans the whole statewide slice for the office).
|
||||
*/
|
||||
export const loadCampoFederalVotesByCityZone = async (
|
||||
payload: Payload,
|
||||
user: ElectionReader,
|
||||
{ year }: { year: number },
|
||||
): Promise<Map<string, number>> => {
|
||||
assertCanReadElectionData(user)
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'electionCandidateVote',
|
||||
where: {
|
||||
and: [
|
||||
{ year: { equals: year } },
|
||||
{ office: { equals: FEDERAL_DEPUTY_OFFICE } },
|
||||
{ turn: { equals: '1' } },
|
||||
{ voteType: { equals: 'nominal' } },
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { cityCode: true, zoneNumber: true, party: true, votes: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const votes = new Map<string, number>()
|
||||
for (const row of result.docs) {
|
||||
if (!isCampoParty(row.party, year)) continue
|
||||
const key = `${row.cityCode}:${row.zoneNumber}`
|
||||
votes.set(key, (votes.get(key) ?? 0) + (row.votes ?? 0))
|
||||
}
|
||||
return votes
|
||||
}
|
||||
|
||||
/** Turnout/valid/blank/null tally cell — same shape the committed artifact stores per year. */
|
||||
export type OfficeTallyCell = FederalBaselineTallyCell
|
||||
|
||||
/**
|
||||
* cityCode:zone → turnout/valid/blank/null tally for one office×year×turn
|
||||
* (used for the roll-off diagnostic: DF tally vs. majoritarian tally in the
|
||||
* same cell — CLI/build-time use only).
|
||||
*/
|
||||
export const loadOfficeTallyByCityZone = async (
|
||||
payload: Payload,
|
||||
user: ElectionReader,
|
||||
{ year, office, turn }: { year: number; office: ElectionOffice; turn: ElectionTurn },
|
||||
): Promise<Map<string, OfficeTallyCell>> => {
|
||||
assertCanReadElectionData(user)
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'electionTally',
|
||||
where: {
|
||||
and: [{ year: { equals: year } }, { office: { equals: office } }, { turn: { equals: turn } }],
|
||||
},
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: {
|
||||
cityCode: true,
|
||||
zoneNumber: true,
|
||||
comparecimento: true,
|
||||
votosValidos: true,
|
||||
votosBranco: true,
|
||||
votosNulo: true,
|
||||
},
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const tally = new Map<string, OfficeTallyCell>()
|
||||
for (const row of result.docs) {
|
||||
const key = `${row.cityCode}:${row.zoneNumber}`
|
||||
const current = tally.get(key) ?? {
|
||||
comparecimento: 0,
|
||||
votosValidos: 0,
|
||||
votosBranco: 0,
|
||||
votosNulo: 0,
|
||||
}
|
||||
tally.set(key, {
|
||||
comparecimento: current.comparecimento + (row.comparecimento ?? 0),
|
||||
votosValidos: current.votosValidos + (row.votosValidos ?? 0),
|
||||
votosBranco: current.votosBranco + (row.votosBranco ?? 0),
|
||||
votosNulo: current.votosNulo + (row.votosNulo ?? 0),
|
||||
})
|
||||
}
|
||||
return tally
|
||||
}
|
||||
|
||||
/** Sums an `OfficeTallyCell` map over a municipality's cityCode×zones. */
|
||||
export const sumOfficeTallyForGeography = (
|
||||
tallyByCityZone: Map<string, OfficeTallyCell>,
|
||||
geography: MunicipalityElectionGeography,
|
||||
): OfficeTallyCell => {
|
||||
const total: OfficeTallyCell = {
|
||||
comparecimento: 0,
|
||||
votosValidos: 0,
|
||||
votosBranco: 0,
|
||||
votosNulo: 0,
|
||||
}
|
||||
for (const zone of geography.zones) {
|
||||
const cell = tallyByCityZone.get(`${geography.cityCode}:${zone}`)
|
||||
if (!cell) continue
|
||||
total.comparecimento += cell.comparecimento
|
||||
total.votosValidos += cell.votosValidos
|
||||
total.votosBranco += cell.votosBranco
|
||||
total.votosNulo += cell.votosNulo
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
|
|
|||
130
src/utilities/municipalityGoalAccount.ts
Normal file
130
src/utilities/municipalityGoalAccount.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import 'server-only'
|
||||
|
||||
import { cache } from 'react'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import type { CampaignUser, User } from '@/payload-types'
|
||||
import { loadCampaignGoals } from '@/utilities/campaignGoals'
|
||||
import {
|
||||
aggregateGoalCoverage,
|
||||
computeGoalCoverage,
|
||||
computeGoalCoverageByScenario,
|
||||
type MunicipalityGoalCoverage,
|
||||
} from '@/utilities/goalCoverage'
|
||||
import {
|
||||
computeMunicipalityPotential,
|
||||
computeStatewideGoalDecomposition,
|
||||
type MunicipalityPotential,
|
||||
} from '@/utilities/municipalityPotential'
|
||||
import {
|
||||
DEFAULT_VOTE_ESTIMATE_SCENARIO,
|
||||
VOTE_ESTIMATE_SCENARIOS,
|
||||
type VoteEstimateScenarioFields,
|
||||
type VoteEstimateScenario,
|
||||
} from '@/utilities/voteEstimate'
|
||||
import {
|
||||
emptyMunicipalityPledgeAggregate,
|
||||
type MunicipalityPledgeAggregate,
|
||||
} from '@/utilities/votePledgeData'
|
||||
|
||||
type GoalAccountReader = CampaignUser | User
|
||||
|
||||
/**
|
||||
* E8 "conta da cadeira" — request-scoped statewide goal decomposition
|
||||
* (`campaignGoals` global + `computeStatewideGoalDecomposition`), degrau 1 da
|
||||
* caching ladder. Every surface (dashboard, list overview, list rows,
|
||||
* detail card) needs the SAME decomposition — `suggestedGoal` for one
|
||||
* município is only meaningful relative to the full 435-slug ceiling total,
|
||||
* so this always runs over the whole catalog, never a filtered scope, and is
|
||||
* `cache()`-deduplicated so dashboard + list + detail don't each recompute it
|
||||
* on requests that touch more than one.
|
||||
*/
|
||||
const loadGoalDecompositionCached = cache(
|
||||
async (payload: Payload, user: GoalAccountReader) => {
|
||||
const goals = await loadCampaignGoals(payload, user)
|
||||
return computeStatewideGoalDecomposition(goals)
|
||||
},
|
||||
)
|
||||
|
||||
export type MunicipalityGoalCoverageBundle = {
|
||||
coverageByMunicipalityID: Map<number, Record<VoteEstimateScenario, MunicipalityGoalCoverage>>
|
||||
/** Sum of every municipality's coverage in `municipalities`, per scenario (dashboard/overview metric). */
|
||||
aggregateByScenario: Record<VoteEstimateScenario, MunicipalityGoalCoverage>
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-scenario goal coverage for a scope of municipalities (dashboard total,
|
||||
* or the list's filtered overview) plus the per-municipality breakdown the
|
||||
* list rows pick a scenario from client-side — mirrors
|
||||
* `rollupMunicipalityStaffVotes`'s "compute all 3 scenarios server-side, pick
|
||||
* client-side" shape.
|
||||
*/
|
||||
export const loadMunicipalityGoalCoverageBundle = async (
|
||||
payload: Payload,
|
||||
user: GoalAccountReader,
|
||||
municipalities: ReadonlyArray<{
|
||||
id: number
|
||||
slug: string
|
||||
expectedVotes?: VoteEstimateScenarioFields | null
|
||||
}>,
|
||||
pledgeAggregates: Map<number, MunicipalityPledgeAggregate>,
|
||||
): Promise<MunicipalityGoalCoverageBundle> => {
|
||||
const { suggestedGoalBySlug } = await loadGoalDecompositionCached(payload, user)
|
||||
|
||||
const coverageByMunicipalityID = new Map<
|
||||
number,
|
||||
Record<VoteEstimateScenario, MunicipalityGoalCoverage>
|
||||
>()
|
||||
for (const municipality of municipalities) {
|
||||
const pledgeAggregate =
|
||||
pledgeAggregates.get(municipality.id) ?? emptyMunicipalityPledgeAggregate
|
||||
const suggestedGoal = suggestedGoalBySlug.get(municipality.slug) ?? 0
|
||||
coverageByMunicipalityID.set(
|
||||
municipality.id,
|
||||
computeGoalCoverageByScenario(municipality.expectedVotes, suggestedGoal, pledgeAggregate),
|
||||
)
|
||||
}
|
||||
|
||||
const coverages = [...coverageByMunicipalityID.values()]
|
||||
const aggregateByScenario = {} as Record<VoteEstimateScenario, MunicipalityGoalCoverage>
|
||||
for (const scenario of VOTE_ESTIMATE_SCENARIOS) {
|
||||
aggregateByScenario[scenario] = aggregateGoalCoverage(
|
||||
coverages.map((coverageByScenario) => coverageByScenario[scenario]),
|
||||
)
|
||||
}
|
||||
|
||||
return { coverageByMunicipalityID, aggregateByScenario }
|
||||
}
|
||||
|
||||
export type MunicipalityGoalAccount = {
|
||||
suggestedGoal: number
|
||||
/** Fixed to the `central` scenario — the detail page has no scenario selector. */
|
||||
goalCoverage: MunicipalityGoalCoverage
|
||||
potential: MunicipalityPotential
|
||||
}
|
||||
|
||||
/**
|
||||
* One município's full "conta da cadeira": suggested goal, coverage (central
|
||||
* scenario), and the diagnostic potential block (teto do campo, captura,
|
||||
* share intracampo, roll-off) — the detail page's "Conta da cadeira" card.
|
||||
*/
|
||||
export const loadMunicipalityGoalAccount = async (
|
||||
payload: Payload,
|
||||
user: GoalAccountReader,
|
||||
municipality: { slug: string; expectedVotes?: VoteEstimateScenarioFields | null },
|
||||
pledgeAggregate: MunicipalityPledgeAggregate,
|
||||
): Promise<MunicipalityGoalAccount> => {
|
||||
const { suggestedGoalBySlug, potentialBySlug } = await loadGoalDecompositionCached(payload, user)
|
||||
const suggestedGoal = suggestedGoalBySlug.get(municipality.slug) ?? 0
|
||||
const goalCoverage = computeGoalCoverage(
|
||||
municipality.expectedVotes,
|
||||
suggestedGoal,
|
||||
pledgeAggregate,
|
||||
DEFAULT_VOTE_ESTIMATE_SCENARIO,
|
||||
)
|
||||
const potential =
|
||||
potentialBySlug.get(municipality.slug) ?? computeMunicipalityPotential(municipality.slug)
|
||||
|
||||
return { suggestedGoal, goalCoverage, potential }
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ import {
|
|||
import type { CampaignUser, Municipality } from '@/payload-types'
|
||||
import { isCampaignLeader, isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { loadMunicipalityScope } from '@/utilities/campaignMunicipalityScope'
|
||||
import type { MunicipalityGoalCoverage } from '@/utilities/goalCoverage'
|
||||
import { loadMunicipalityGoalCoverageBundle } from '@/utilities/municipalityGoalAccount'
|
||||
import {
|
||||
buildMunicipalityListWhere,
|
||||
municipalityPageSize,
|
||||
|
|
@ -48,6 +50,8 @@ export type MunicipalityListOverviewData = {
|
|||
pledgeCount: number
|
||||
missingEstimateCount: number
|
||||
withAdvisorCount: number
|
||||
/** E8 "conta da cadeira" — meta × comprometido do escopo filtrado, por cenário. */
|
||||
goalCoverageByScenario: Record<VoteEstimateScenario, MunicipalityGoalCoverage>
|
||||
}
|
||||
|
||||
export type MunicipalityListPageBundle = {
|
||||
|
|
@ -171,10 +175,21 @@ export const loadMunicipalityListPageBundle = async (
|
|||
|
||||
let overview: MunicipalityListOverviewData | null = null
|
||||
let pledgeAggregates = new Map<number, MunicipalityPledgeAggregate>()
|
||||
let goalCoverageByMunicipalityID = new Map<
|
||||
number,
|
||||
Record<VoteEstimateScenario, MunicipalityGoalCoverage>
|
||||
>()
|
||||
|
||||
if (staffScope && staffScope.municipalities.length > 0) {
|
||||
pledgeAggregates = staffScope.pledgeAggregates
|
||||
const rollup = rollupMunicipalityStaffVotes(staffScope.municipalities, pledgeAggregates)
|
||||
const goalCoverageBundle = await loadMunicipalityGoalCoverageBundle(
|
||||
payload,
|
||||
user,
|
||||
staffScope.municipalities,
|
||||
pledgeAggregates,
|
||||
)
|
||||
goalCoverageByMunicipalityID = goalCoverageBundle.coverageByMunicipalityID
|
||||
overview = {
|
||||
municipalityCount: staffScope.municipalities.length,
|
||||
staffVoteTotalByScenario: { ...rollup.staffVoteTotalByScenario },
|
||||
|
|
@ -183,6 +198,7 @@ export const loadMunicipalityListPageBundle = async (
|
|||
withAdvisorCount: staffScope.municipalities.filter(
|
||||
(municipality) => (municipality.advisors ?? []).length > 0,
|
||||
).length,
|
||||
goalCoverageByScenario: goalCoverageBundle.aggregateByScenario,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -214,6 +230,7 @@ export const loadMunicipalityListPageBundle = async (
|
|||
municipality,
|
||||
isStaff ? pledgeAggregates.get(municipality.id) : undefined,
|
||||
ranks.get(municipality.slug) ?? null,
|
||||
isStaff ? goalCoverageByMunicipalityID.get(municipality.id) : undefined,
|
||||
),
|
||||
),
|
||||
totalDocs,
|
||||
|
|
|
|||
255
src/utilities/municipalityPotential.ts
Normal file
255
src/utilities/municipalityPotential.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import {
|
||||
getMunicipalityFederalBaseline,
|
||||
type MunicipalityFederalBaseline,
|
||||
} from '@/lib/bahiaElectionAggregates'
|
||||
import {
|
||||
ELECTION_YEAR_2014,
|
||||
ELECTION_YEAR_2018,
|
||||
ELECTION_YEAR_2022,
|
||||
HISTORICAL_SERIES_YEARS,
|
||||
} from '@/lib/electionResults'
|
||||
import { municipalityCatalog } from '@/lib/municipalityCatalog'
|
||||
import type { BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
|
||||
import type { CampaignGoal } from '@/payload-types'
|
||||
|
||||
/**
|
||||
* E8 "conta da cadeira" — derived electoral potential per municipality, built
|
||||
* entirely from the committed `bahia-federal-baseline.json` artifact (no DB
|
||||
* reads: pure functions over `getMunicipalityFederalBaseline`). Every
|
||||
* projection here is a documented, fixed formula — never a fitted model (see
|
||||
* the E8 plan's "rabbit holes vigiados": projection turning into a model).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Projected 2026 valid votes: weighted average of 2014/2018/2022 federal T1
|
||||
* valid votes, weighting 2022 double (closer election, more predictive of
|
||||
* turnout composition). Documented as a fixed formula, not a forecast:
|
||||
* `(v2014 + v2018 + 2×v2022) / 4`.
|
||||
*/
|
||||
export const projectedValidVotes = (baseline: MunicipalityFederalBaseline): number => {
|
||||
const v2014 = baseline.validVotesByYear[String(ELECTION_YEAR_2014)] ?? 0
|
||||
const v2018 = baseline.validVotesByYear[String(ELECTION_YEAR_2018)] ?? 0
|
||||
const v2022 = baseline.validVotesByYear[String(ELECTION_YEAR_2022)] ?? 0
|
||||
return (v2014 + v2018 + 2 * v2022) / 4
|
||||
}
|
||||
|
||||
/**
|
||||
* "Teto do campo" (field ceiling): 2022 T1 votes for the presidential
|
||||
* candidate of the field (#13) — a majoritarian race attracts far more of
|
||||
* the field's total vote than the fragmented federal-deputy quotient race,
|
||||
* 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 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
|
||||
|
||||
/**
|
||||
* `fieldCeiling` scaled by the same turnout-growth factor used for
|
||||
* `projectedValidVotes` (projected ÷ observed 2022 valid votes), since the
|
||||
* ceiling itself has only one observed data point (2022). Falls back to the
|
||||
* unscaled ceiling when 2022 valid votes are unknown (avoids a divide-by-zero
|
||||
* blow-up rather than silently zeroing the projection).
|
||||
*/
|
||||
export const projectedFieldCeiling = (baseline: MunicipalityFederalBaseline): number => {
|
||||
const ceiling2022 = fieldCeiling(baseline)
|
||||
const validVotes2022 = baseline.validVotesByYear[String(ELECTION_YEAR_2022)] ?? 0
|
||||
if (validVotes2022 <= 0) return ceiling2022
|
||||
return ceiling2022 * (projectedValidVotes(baseline) / validVotes2022)
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture rate (diagnostic only, not a decomposition input): share of the
|
||||
* 2022 field ceiling that Solla's own candidacy actually captured. `null`
|
||||
* when the ceiling is unknown/zero rather than a misleading 0.
|
||||
*/
|
||||
export const captureRate = (baseline: MunicipalityFederalBaseline): number | null => {
|
||||
const ceiling = fieldCeiling(baseline)
|
||||
if (ceiling <= 0) return null
|
||||
const votes2022 = baseline.votesByYear[String(ELECTION_YEAR_2022)] ?? 0
|
||||
return votes2022 / ceiling
|
||||
}
|
||||
|
||||
/**
|
||||
* Intra-field share (diagnostic only): of the curated field's federal-deputy
|
||||
* nominal votes (`campoFederalVotesByYear`, `campoParties.ts`) in a given
|
||||
* year, what share went to Solla specifically. Available for all three
|
||||
* historical years (unlike `fieldCeiling`, which is majoritarian and
|
||||
* 2022-only). `null` when the field polled zero that year.
|
||||
*/
|
||||
export const intraFieldShare = (baseline: MunicipalityFederalBaseline, year: number): number | null => {
|
||||
const campo = baseline.campoFederalVotesByYear[String(year)] ?? 0
|
||||
if (campo <= 0) return null
|
||||
const votes = baseline.votesByYear[String(year)] ?? 0
|
||||
return votes / campo
|
||||
}
|
||||
|
||||
export type RollOff = {
|
||||
/** (brancos+nulos DF) − (brancos+nulos majoritária), in votes. */
|
||||
votes: number
|
||||
/** `votes` as a share of the federal-deputy T1 turnout, or null if turnout is unknown. */
|
||||
percentOfTurnout: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, 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
|
||||
const federalTally = baseline.federalTallyByYear[String(ELECTION_YEAR_2022)]
|
||||
if (!majoritarian || !federalTally) return null
|
||||
|
||||
const dfBlankNull = federalTally.votosBranco + federalTally.votosNulo
|
||||
const majoritarianBlankNull = majoritarian.votosBranco + majoritarian.votosNulo
|
||||
const votes = dfBlankNull - majoritarianBlankNull
|
||||
const percentOfTurnout = federalTally.comparecimento > 0 ? votes / federalTally.comparecimento : null
|
||||
|
||||
return { votes, percentOfTurnout }
|
||||
}
|
||||
|
||||
export type MunicipalityPotential = {
|
||||
slug: string
|
||||
projectedValidVotes: number
|
||||
fieldCeiling2022: number
|
||||
projectedFieldCeiling: number
|
||||
captureRate2022: number | null
|
||||
intraFieldShareByYear: Record<number, number | null>
|
||||
rollOff2022: RollOff | null
|
||||
}
|
||||
|
||||
/** Assembles every derived metric above for one catalog municipality slug. */
|
||||
export const computeMunicipalityPotential = (slug: string): MunicipalityPotential => {
|
||||
const baseline = getMunicipalityFederalBaseline(slug)
|
||||
const intraFieldShareByYear: Record<number, number | null> = {}
|
||||
for (const year of HISTORICAL_SERIES_YEARS) {
|
||||
intraFieldShareByYear[year] = intraFieldShare(baseline, year)
|
||||
}
|
||||
|
||||
return {
|
||||
slug,
|
||||
projectedValidVotes: projectedValidVotes(baseline),
|
||||
fieldCeiling2022: fieldCeiling(baseline),
|
||||
projectedFieldCeiling: projectedFieldCeiling(baseline),
|
||||
captureRate2022: captureRate(baseline),
|
||||
intraFieldShareByYear,
|
||||
rollOff2022: rollOff(baseline),
|
||||
}
|
||||
}
|
||||
|
||||
/** `computeMunicipalityPotential` for every catalog municipality (435 slugs). */
|
||||
export const computeAllMunicipalityPotentials = (): MunicipalityPotential[] =>
|
||||
municipalityCatalog.map((entry) => computeMunicipalityPotential(entry.slug))
|
||||
|
||||
/**
|
||||
* Decomposes the state goal proportionally to each municipality's projected
|
||||
* field ceiling — `suggestedGoal_i = stateGoal × projectedFieldCeiling_i / Σ
|
||||
* projectedFieldCeiling` — NOT a composite formula folding in capture rate or
|
||||
* intra-field share (those stay diagnostic columns; see the E8 plan's
|
||||
* "rabbit holes vigiados"). `margin` on the `campaignGoals` global is
|
||||
* informational for the table (a safety reading for the coordination), it
|
||||
* does not change the decomposed value.
|
||||
*
|
||||
* Falls back to an even split when every ceiling is zero/unknown (e.g. an
|
||||
* empty potentials list slice) rather than producing `NaN` suggested goals.
|
||||
*/
|
||||
export const decomposeStateGoal = (
|
||||
potentials: ReadonlyArray<Pick<MunicipalityPotential, 'slug' | 'projectedFieldCeiling'>>,
|
||||
goals: Pick<CampaignGoal, 'stateGoal'>,
|
||||
): Map<string, number> => {
|
||||
const stateGoal = goals.stateGoal
|
||||
const totalCeiling = potentials.reduce((sum, entry) => sum + entry.projectedFieldCeiling, 0)
|
||||
const suggestedGoalBySlug = new Map<string, number>()
|
||||
|
||||
if (totalCeiling <= 0) {
|
||||
const even = potentials.length > 0 ? stateGoal / potentials.length : 0
|
||||
for (const entry of potentials) suggestedGoalBySlug.set(entry.slug, even)
|
||||
return suggestedGoalBySlug
|
||||
}
|
||||
|
||||
for (const entry of potentials) {
|
||||
suggestedGoalBySlug.set(entry.slug, stateGoal * (entry.projectedFieldCeiling / totalCeiling))
|
||||
}
|
||||
return suggestedGoalBySlug
|
||||
}
|
||||
|
||||
export type TerritorySanityWarning = {
|
||||
region: BahiaIdentityTerritory
|
||||
suggestedGoalTotal: number
|
||||
projectedValidVotesTotal: number
|
||||
/** suggestedGoalTotal / projectedValidVotesTotal — > 1 means the decomposed goal exceeds the projected electorate. */
|
||||
ratio: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanity check (never blocking, per the plan): flags identity territories
|
||||
* (TI) where the decomposed suggested goals, summed, exceed the projected
|
||||
* valid-vote electorate of that territory — a signal that the decomposition
|
||||
* needs a human look (state goal too high, or ceilings skewed), never a
|
||||
* thrown error. Callers surface `warnAboveRatio` as an `CampaignInfoHint`,
|
||||
* not a validation failure.
|
||||
*/
|
||||
export const sanityCheckSuggestedGoalsByTerritory = (
|
||||
entries: ReadonlyArray<{
|
||||
region: BahiaIdentityTerritory
|
||||
suggestedGoal: number
|
||||
projectedValidVotes: number
|
||||
}>,
|
||||
{ warnAboveRatio = 1 }: { warnAboveRatio?: number } = {},
|
||||
): TerritorySanityWarning[] => {
|
||||
const totalsByRegion = new Map<
|
||||
BahiaIdentityTerritory,
|
||||
{ suggestedGoalTotal: number; projectedValidVotesTotal: number }
|
||||
>()
|
||||
|
||||
for (const entry of entries) {
|
||||
const current = totalsByRegion.get(entry.region) ?? {
|
||||
suggestedGoalTotal: 0,
|
||||
projectedValidVotesTotal: 0,
|
||||
}
|
||||
current.suggestedGoalTotal += entry.suggestedGoal
|
||||
current.projectedValidVotesTotal += entry.projectedValidVotes
|
||||
totalsByRegion.set(entry.region, current)
|
||||
}
|
||||
|
||||
const warnings: TerritorySanityWarning[] = []
|
||||
for (const [region, totals] of totalsByRegion) {
|
||||
if (totals.projectedValidVotesTotal <= 0) continue
|
||||
const ratio = totals.suggestedGoalTotal / totals.projectedValidVotesTotal
|
||||
if (ratio > warnAboveRatio) {
|
||||
warnings.push({ region, ...totals, ratio })
|
||||
}
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience end-to-end: computes every municipality's potential and
|
||||
* decomposes the state goal — the single entry point the dashboard/list/
|
||||
* detail loaders need (`municipalityGoalAccount.ts`), keyed by slug for O(1)
|
||||
* per-municipality lookup instead of a linear scan.
|
||||
*
|
||||
* Deliberately does NOT run `sanityCheckSuggestedGoalsByTerritory` here: no
|
||||
* caller surfaces `TerritorySanityWarning`s yet (deferred to E12's TI layer
|
||||
* — see the E8 plan), so computing them on every dashboard/list/detail
|
||||
* request would be pure wasted work. Callers that need the check once E12
|
||||
* lands can build the same `{ region, suggestedGoal, projectedValidVotes }`
|
||||
* entries from `potentialBySlug` + `municipalityCatalog` and call it
|
||||
* directly — it stays exported and unit-tested for that.
|
||||
*/
|
||||
export const computeStatewideGoalDecomposition = (
|
||||
goals: Pick<CampaignGoal, 'stateGoal'>,
|
||||
): {
|
||||
potentialBySlug: Map<string, MunicipalityPotential>
|
||||
suggestedGoalBySlug: Map<string, number>
|
||||
} => {
|
||||
const potentials = computeAllMunicipalityPotentials()
|
||||
const suggestedGoalBySlug = decomposeStateGoal(potentials, goals)
|
||||
const potentialBySlug = new Map(potentials.map((potential) => [potential.slug, potential]))
|
||||
|
||||
return { potentialBySlug, suggestedGoalBySlug }
|
||||
}
|
||||
|
|
@ -3,11 +3,13 @@ import type { Payload } from 'payload'
|
|||
import type { MunicipalityVoteRankEntry } from '@/lib/municipalityVoteRank'
|
||||
import type { CampaignUser, Municipality } from '@/payload-types'
|
||||
import { eligibleCampaignStaffWhere } from '@/utilities/campaignAccess'
|
||||
import { createEmptyGoalCoverageByScenario, type MunicipalityGoalCoverage } from '@/utilities/goalCoverage'
|
||||
import type { PoliticalTrendStatus } from '@/utilities/municipalityUi'
|
||||
import { relationshipId } from '@/utilities/relationship'
|
||||
import type { StateDeputySummary } from '@/utilities/stateDeputyData'
|
||||
import {
|
||||
toVoteEstimateScenarioViewModel,
|
||||
type VoteEstimateScenario,
|
||||
type VoteEstimateScenarioViewModel,
|
||||
} from '@/utilities/voteEstimate'
|
||||
import type { MunicipalityPledgeAggregate } from '@/utilities/votePledgeData'
|
||||
|
|
@ -55,12 +57,15 @@ export type MunicipalityListViewModel = {
|
|||
politicalTrendNote: string | null
|
||||
pledges: MunicipalityPledgeAggregate
|
||||
votePosition2022: MunicipalityVoteRankEntry | null
|
||||
/** E8 "conta da cadeira" — meta × comprometido por cenário; null fora da staff view. */
|
||||
goalCoverageByScenario: Record<VoteEstimateScenario, MunicipalityGoalCoverage>
|
||||
}
|
||||
|
||||
export const toMunicipalityListViewModel = (
|
||||
municipality: Municipality,
|
||||
pledges: MunicipalityPledgeAggregate | undefined,
|
||||
votePosition2022: MunicipalityListViewModel['votePosition2022'],
|
||||
goalCoverageByScenario?: Record<VoteEstimateScenario, MunicipalityGoalCoverage>,
|
||||
): MunicipalityListViewModel => ({
|
||||
id: municipality.id,
|
||||
name: municipality.name,
|
||||
|
|
@ -78,6 +83,7 @@ export const toMunicipalityListViewModel = (
|
|||
politicalTrendNote: municipality.politicalTrend?.note ?? null,
|
||||
pledges: pledges ?? createEmptyMunicipalityPledgeAggregate(),
|
||||
votePosition2022,
|
||||
goalCoverageByScenario: goalCoverageByScenario ?? createEmptyGoalCoverageByScenario(),
|
||||
})
|
||||
|
||||
export type MunicipalityAdvisorSummary = {
|
||||
|
|
|
|||
|
|
@ -100,7 +100,15 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
await expect(page.getByText('Votos estimados atualizados.')).toBeVisible()
|
||||
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}`)
|
||||
await expect(page.getByText('5.000')).toBeVisible()
|
||||
// "Conta da cadeira" (E8) shows the saved scenario values through
|
||||
// `MunicipalityListExpectedVotesControl` — a compact edit-in-place
|
||||
// control whose only always-visible digits are the active scenario
|
||||
// (`central`, "3.000" here). The full pessimistic/central/optimistic
|
||||
// trio only becomes plain page text via its screen-reader summary
|
||||
// (visually a hover-only preview otherwise), so assert against that
|
||||
// unique, unambiguous string rather than a bare "5.000" substring —
|
||||
// that also matches the (opacity-0 but DOM-visible) hover-preview span.
|
||||
await expect(page.getByText('Otimista: 5.000')).toBeVisible()
|
||||
await expect(page.getByText(`Assessoria: ${advisor.name}`)).toBeVisible()
|
||||
})
|
||||
|
||||
|
|
|
|||
111
tests/int/campaignGoalsAccess.int.spec.ts
Normal file
111
tests/int/campaignGoalsAccess.int.spec.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import config from '@/payload.config'
|
||||
import { loadCampaignGoals } from '@/utilities/campaignGoals'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Pins the `campaignGoals` global access matrix (E8): a `campaignUser` JWT
|
||||
* reaches `/api/globals/*` directly, so — like the election-data collections —
|
||||
* the deny for leaders and anonymous requests has to be explicit rather than
|
||||
* inherited from Payload's "any authenticated user" default.
|
||||
*/
|
||||
describe('campaignGoals global access', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it('denies anonymous read', async () => {
|
||||
await expect(
|
||||
payload.findGlobal({ slug: 'campaignGoals', overrideAccess: false }),
|
||||
).rejects.toThrow(/permissão|not allowed/i)
|
||||
})
|
||||
|
||||
it('denies a leader read', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const leader = await fixtures.createCampaignUser('leader')
|
||||
|
||||
await expect(
|
||||
payload.findGlobal({
|
||||
slug: 'campaignGoals',
|
||||
user: leader,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow(/permissão|not allowed/i)
|
||||
})
|
||||
|
||||
it('lets an advisor read but not write', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
|
||||
await expect(
|
||||
payload.findGlobal({ slug: 'campaignGoals', user: advisor, overrideAccess: false }),
|
||||
).resolves.toMatchObject({ stateGoal: expect.any(Number) })
|
||||
|
||||
await expect(
|
||||
payload.updateGlobal({
|
||||
slug: 'campaignGoals',
|
||||
data: { stateGoal: 160_000 },
|
||||
user: advisor,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow(/permissão|not allowed/i)
|
||||
})
|
||||
|
||||
it.each(['coordinator', 'candidate'] as const)(
|
||||
'lets a %s read and write',
|
||||
async (role) => {
|
||||
const fixtures = campaignFixtures()
|
||||
const staff = await fixtures.createCampaignUser(role)
|
||||
|
||||
const updated = await payload.updateGlobal({
|
||||
slug: 'campaignGoals',
|
||||
data: { stateGoal: 155_000, margin: 5 },
|
||||
user: staff,
|
||||
overrideAccess: false,
|
||||
})
|
||||
expect(updated.stateGoal).toBe(155_000)
|
||||
|
||||
await expect(
|
||||
payload.findGlobal({ slug: 'campaignGoals', user: staff, overrideAccess: false }),
|
||||
).resolves.toMatchObject({ stateGoal: 155_000, margin: 5 })
|
||||
},
|
||||
)
|
||||
|
||||
it('lets a payload admin read and write', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const admin = await fixtures.createAdminUser()
|
||||
|
||||
const updated = await payload.updateGlobal({
|
||||
slug: 'campaignGoals',
|
||||
data: { stateGoal: 150_000 },
|
||||
user: admin,
|
||||
overrideAccess: false,
|
||||
})
|
||||
expect(updated.stateGoal).toBe(150_000)
|
||||
})
|
||||
|
||||
it('loadCampaignGoals resolves for staff and denies a leader', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const leader = await fixtures.createCampaignUser('leader')
|
||||
|
||||
await expect(loadCampaignGoals(payload, coordinator)).resolves.toMatchObject({
|
||||
stateGoal: expect.any(Number),
|
||||
})
|
||||
await expect(loadCampaignGoals(payload, leader)).rejects.toThrow(/permissão|not allowed/i)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
import { statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
|
|
@ -5,9 +8,14 @@ import {
|
|||
federalBaselineMunicipalitySlugs,
|
||||
getMunicipalityFederalBaseline,
|
||||
} from '@/lib/bahiaElectionAggregates'
|
||||
import { BASELINE_TICKET_2022, HISTORICAL_SERIES_YEARS } from '@/lib/electionResults'
|
||||
import { BASELINE_TICKET_2022, ELECTION_YEAR_2022, HISTORICAL_SERIES_YEARS } from '@/lib/electionResults'
|
||||
import { municipalityCatalog } from '@/lib/municipalityCatalog'
|
||||
|
||||
const ARTIFACT_PATH = join(
|
||||
process.cwd(),
|
||||
'src/lib/electionAggregates/bahia-federal-baseline.json',
|
||||
)
|
||||
|
||||
/**
|
||||
* Pins the committed election-aggregate artifact to the municipality catalog.
|
||||
* If either changes shape (new remodel, new TSE scope), regenerate with
|
||||
|
|
@ -54,5 +62,58 @@ describe('election aggregates artifact', () => {
|
|||
const baseline = getMunicipalityFederalBaseline('nao-existe')
|
||||
expect(baseline.votesByYear).toEqual({})
|
||||
expect(baseline.validVotesByYear).toEqual({})
|
||||
expect(baseline.campoFederalVotesByYear).toEqual({})
|
||||
expect(baseline.federalTallyByYear).toEqual({})
|
||||
expect(baseline.majoritarian2022).toBeUndefined()
|
||||
})
|
||||
|
||||
it('has campo federal votes + federal tally for every historical year (v2 fields)', () => {
|
||||
for (const year of HISTORICAL_SERIES_YEARS) {
|
||||
let campoTotal = 0
|
||||
let tallyComparecimentoTotal = 0
|
||||
for (const slug of federalBaselineMunicipalitySlugs()) {
|
||||
const baseline = getMunicipalityFederalBaseline(slug)
|
||||
const campo = baseline.campoFederalVotesByYear[String(year)]
|
||||
const tally = baseline.federalTallyByYear[String(year)]
|
||||
expect(campo).toBeTypeOf('number')
|
||||
expect(campo).toBeGreaterThanOrEqual(0)
|
||||
expect(tally).toBeDefined()
|
||||
expect(tally.comparecimento).toBeGreaterThanOrEqual(0)
|
||||
expect(tally.votosValidos).toBeGreaterThanOrEqual(0)
|
||||
campoTotal += campo ?? 0
|
||||
tallyComparecimentoTotal += tally?.comparecimento ?? 0
|
||||
}
|
||||
// The campo (PT + core-left partners) always outpolls Solla alone, and
|
||||
// turnout always dwarfs any single candidate's nominal votes.
|
||||
expect(campoTotal).toBeGreaterThan(10_000)
|
||||
expect(tallyComparecimentoTotal).toBeGreaterThan(campoTotal)
|
||||
}
|
||||
})
|
||||
|
||||
it('has 2022 majoritarian (presidente/governador #13) votes+tally for every municipality', () => {
|
||||
let presidentTotal = 0
|
||||
for (const slug of federalBaselineMunicipalitySlugs()) {
|
||||
const baseline = getMunicipalityFederalBaseline(slug)
|
||||
const majoritarian = baseline.majoritarian2022
|
||||
expect(majoritarian).toBeDefined()
|
||||
if (!majoritarian) continue
|
||||
expect(majoritarian.president.votes).toBeGreaterThanOrEqual(0)
|
||||
expect(majoritarian.governor.votes).toBeGreaterThanOrEqual(0)
|
||||
expect(majoritarian.president.comparecimento).toBeGreaterThanOrEqual(majoritarian.president.votes)
|
||||
presidentTotal += majoritarian.president.votes
|
||||
}
|
||||
expect(presidentTotal).toBeGreaterThan(10_000)
|
||||
// Sanity: the 2022 majoritarian slice must not silently regress to a
|
||||
// different election year (only 2022 is seeded/expected here).
|
||||
expect(HISTORICAL_SERIES_YEARS).toContain(ELECTION_YEAR_2022)
|
||||
})
|
||||
|
||||
it('stays under the committed artifact byte budget', () => {
|
||||
const { size } = statSync(ARTIFACT_PATH)
|
||||
// v1 (votes + valid votes only) was ~96 KB; v2 adds campo votes, federal
|
||||
// tally and the 2022 majoritarian slice, measured at ~522 KB — budget
|
||||
// with headroom, not a precise pin, so future fields don't require
|
||||
// touching this test.
|
||||
expect(size).toBeLessThan(700 * 1024)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,11 +14,13 @@ import { Progress } from '@/components/ui/Progress'
|
|||
import { Toggle } from '@/components/ui/Toggle'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import { stub } from '../helpers/stub'
|
||||
import { createEmptyGoalCoverageByScenario } from '@/utilities/goalCoverage'
|
||||
import type { MunicipalityAdvisorSummary, MunicipalityListViewModel } from '@/utilities/municipalityViewModels'
|
||||
import { toVoteEstimateScenarioViewModel } from '@/utilities/voteEstimate'
|
||||
import { createEmptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeData'
|
||||
|
||||
import { stub } from '../helpers/stub'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: vi.fn(),
|
||||
|
|
@ -171,6 +173,11 @@ describe('campaign visual foundation', () => {
|
|||
share: 0.031,
|
||||
totalUnits: 435,
|
||||
},
|
||||
goalCoverageByScenario: {
|
||||
pessimistic: { goal: 1500, committed: 1200, coverageRatio: 0.8, deficit: 300 },
|
||||
central: { goal: 1500, committed: 1200, coverageRatio: 0.8, deficit: 300 },
|
||||
optimistic: { goal: 1500, committed: 1200, coverageRatio: 0.8, deficit: 300 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
|
|
@ -189,6 +196,7 @@ describe('campaign visual foundation', () => {
|
|||
politicalTrendNote: null,
|
||||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||||
votePosition2022: null,
|
||||
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -210,6 +218,9 @@ describe('campaign visual foundation', () => {
|
|||
expect(html).toContain('Coberta')
|
||||
expect(html).toContain('Sem assessor')
|
||||
expect(html).toContain('Tendência')
|
||||
expect(html).toContain('Cobertura da meta')
|
||||
expect(html).toContain('80%')
|
||||
expect(html).toContain('Faltam 300 votos para a meta')
|
||||
})
|
||||
|
||||
it('hides leadership coverage subline when pledges only have declared votes', () => {
|
||||
|
|
@ -238,6 +249,7 @@ describe('campaign visual foundation', () => {
|
|||
missingEstimateCount: 1,
|
||||
},
|
||||
votePosition2022: null,
|
||||
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
|
||||
},
|
||||
],
|
||||
advisorNamesById: new Map<number, MunicipalityAdvisorSummary>(),
|
||||
|
|
@ -272,6 +284,7 @@ describe('campaign visual foundation', () => {
|
|||
politicalTrendNote: null,
|
||||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||||
votePosition2022: null,
|
||||
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
|
||||
},
|
||||
],
|
||||
advisorNamesById: new Map<number, MunicipalityAdvisorSummary>(),
|
||||
|
|
|
|||
71
tests/unit/campoParties.unit.spec.ts
Normal file
71
tests/unit/campoParties.unit.spec.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
CAMPO_ELECTION_YEARS,
|
||||
campoPartiesForYear,
|
||||
isCampoElectionYear,
|
||||
isCampoParty,
|
||||
} from '@/lib/campoParties'
|
||||
import { partySpectrum } from '@/lib/electionPartySpectrum'
|
||||
|
||||
/**
|
||||
* Guards against `campoParties.ts` drifting into a second, unvalidated party
|
||||
* classifier: every curated "campo" party must also land in the `esquerda`
|
||||
* bucket of `electionPartySpectrum.ts` (the Bolognesi et al. expert survey).
|
||||
* The reverse is not required — see the file header for sourcing per year.
|
||||
*/
|
||||
describe('campoParties', () => {
|
||||
it('every curated party is in the esquerda spectrum bucket, for every year', () => {
|
||||
for (const year of CAMPO_ELECTION_YEARS) {
|
||||
for (const party of campoPartiesForYear(year)) {
|
||||
expect(partySpectrum(party)).toBe('esquerda')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('covers 2014, 2018, 2022 and 2026', () => {
|
||||
expect(new Set(CAMPO_ELECTION_YEARS)).toEqual(new Set([2014, 2018, 2022, 2026]))
|
||||
})
|
||||
|
||||
it('includes PT and PC do B (PCDOB) across every curated year', () => {
|
||||
for (const year of CAMPO_ELECTION_YEARS) {
|
||||
expect(isCampoParty('PT', year)).toBe(true)
|
||||
expect(isCampoParty('PC do B', year)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves 2022/2026 to the FE Brasil federation (PT/PC do B/PV)', () => {
|
||||
for (const year of [2022, 2026] as const) {
|
||||
expect(new Set(campoPartiesForYear(year))).toEqual(new Set(['PT', 'PCDOB', 'PV']))
|
||||
}
|
||||
})
|
||||
|
||||
it('excludes opportunistic centrão allies that shared the same raw TSE coalition', () => {
|
||||
// Solla's own 2014 coalition ("MAIS MUDANÇAS, NOVAS CONQUISTAS") and 2018
|
||||
// coalition ("FRENTE DO TRABALHO POR TODA BAHIA") also included these
|
||||
// parties — deliberately excluded because they are not in the political
|
||||
// field (confirmed direita/centro in electionPartySpectrum).
|
||||
expect(isCampoParty('PP', 2014)).toBe(false)
|
||||
expect(isCampoParty('PR', 2014)).toBe(false)
|
||||
expect(isCampoParty('PSD', 2014)).toBe(false)
|
||||
expect(isCampoParty('PTB', 2014)).toBe(false)
|
||||
expect(isCampoParty('PODE', 2018)).toBe(false)
|
||||
expect(isCampoParty('PP', 2018)).toBe(false)
|
||||
expect(isCampoParty('PR', 2018)).toBe(false)
|
||||
expect(isCampoParty('PSD', 2018)).toBe(false)
|
||||
})
|
||||
|
||||
it('excludes PSB from the 2022 federal field (ran its own deputado-federal slate)', () => {
|
||||
expect(isCampoParty('PSB', 2022)).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes siglas and fails closed on unknown years/parties', () => {
|
||||
expect(isCampoParty('pt', 2022)).toBe(true)
|
||||
expect(isCampoParty(null, 2022)).toBe(false)
|
||||
expect(isCampoParty('PT', 2015)).toBe(false)
|
||||
expect(isCampoElectionYear(2015)).toBe(false)
|
||||
expect(campoPartiesForYear(2015)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
93
tests/unit/goalCoverage.unit.spec.ts
Normal file
93
tests/unit/goalCoverage.unit.spec.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
aggregateGoalCoverage,
|
||||
computeGoalCoverage,
|
||||
computeGoalCoverageByScenario,
|
||||
} from '@/utilities/goalCoverage'
|
||||
import { createEmptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeData'
|
||||
|
||||
/**
|
||||
* Fixes the meta×comprometido semantics locked in the E8 plan audit: a
|
||||
* municipality can have `expectedVotes` filled by the E4R projection-sheet
|
||||
* import with ZERO pledges behind it — coverage must report that gap, not
|
||||
* 100% (which is what `resolveMunicipalityStaffVoteTotal`'s override
|
||||
* behavior would give if reused here).
|
||||
*/
|
||||
describe('computeGoalCoverage', () => {
|
||||
it('meta = expectedVotes[cenário] when set; comprometido = pledge aggregate only (never the override)', () => {
|
||||
// Feira de Santana-style case: mesa expects 3000 (E4R sheet, cenário média),
|
||||
// but only 1200 is backed by declared/estimated pledges.
|
||||
const aggregate = createEmptyMunicipalityPledgeAggregate()
|
||||
aggregate.effectiveByScenario.central = 1200
|
||||
|
||||
const coverage = computeGoalCoverage({ central: 3000 }, 5000, aggregate, 'central')
|
||||
|
||||
expect(coverage.goal).toBe(3000) // NOT 100% — expectedVotes wins over suggestedGoal, not over pledges
|
||||
expect(coverage.committed).toBe(1200)
|
||||
expect(coverage.coverageRatio).toBeCloseTo(0.4, 6)
|
||||
expect(coverage.deficit).toBe(1800)
|
||||
})
|
||||
|
||||
it('falls back to suggestedGoal when expectedVotes is unset for that scenario', () => {
|
||||
const aggregate = createEmptyMunicipalityPledgeAggregate()
|
||||
aggregate.effectiveByScenario.central = 400
|
||||
|
||||
const coverage = computeGoalCoverage(null, 1000, aggregate, 'central')
|
||||
expect(coverage.goal).toBe(1000)
|
||||
expect(coverage.committed).toBe(400)
|
||||
expect(coverage.coverageRatio).toBeCloseTo(0.4, 6)
|
||||
})
|
||||
|
||||
it('coverageRatio is null (not Infinity/NaN) when goal is zero', () => {
|
||||
const aggregate = createEmptyMunicipalityPledgeAggregate()
|
||||
const coverage = computeGoalCoverage(null, 0, aggregate, 'central')
|
||||
expect(coverage.coverageRatio).toBeNull()
|
||||
expect(coverage.deficit).toBe(0)
|
||||
})
|
||||
|
||||
it('deficit is negative when the municipality is over-committed relative to its goal', () => {
|
||||
const aggregate = createEmptyMunicipalityPledgeAggregate()
|
||||
aggregate.effectiveByScenario.central = 1500
|
||||
const coverage = computeGoalCoverage(null, 1000, aggregate, 'central')
|
||||
expect(coverage.deficit).toBe(-500)
|
||||
expect(coverage.coverageRatio).toBeCloseTo(1.5, 6)
|
||||
})
|
||||
|
||||
it('reads each scenario independently (mirrors staffVoteTotalByScenario)', () => {
|
||||
const aggregate = createEmptyMunicipalityPledgeAggregate()
|
||||
aggregate.effectiveByScenario.pessimistic = 100
|
||||
aggregate.effectiveByScenario.central = 200
|
||||
aggregate.effectiveByScenario.optimistic = 300
|
||||
|
||||
const byScenario = computeGoalCoverageByScenario(
|
||||
{ pessimistic: 500, optimistic: 900 },
|
||||
600, // suggestedGoal fallback used only for 'central' (unset in expectedVotes)
|
||||
aggregate,
|
||||
)
|
||||
|
||||
expect(byScenario.pessimistic.goal).toBe(500)
|
||||
expect(byScenario.pessimistic.committed).toBe(100)
|
||||
expect(byScenario.central.goal).toBe(600) // falls back to suggestedGoal
|
||||
expect(byScenario.central.committed).toBe(200)
|
||||
expect(byScenario.optimistic.goal).toBe(900)
|
||||
expect(byScenario.optimistic.committed).toBe(300)
|
||||
})
|
||||
})
|
||||
|
||||
describe('aggregateGoalCoverage', () => {
|
||||
it('sums goal and committed across municipalities and recomputes the ratio', () => {
|
||||
const aggregate = aggregateGoalCoverage([
|
||||
{ goal: 3000, committed: 1200, coverageRatio: 0.4, deficit: 1800 },
|
||||
{ goal: 1000, committed: 1000, coverageRatio: 1, deficit: 0 },
|
||||
])
|
||||
expect(aggregate.goal).toBe(4000)
|
||||
expect(aggregate.committed).toBe(2200)
|
||||
expect(aggregate.coverageRatio).toBeCloseTo(0.55, 6)
|
||||
expect(aggregate.deficit).toBe(1800)
|
||||
})
|
||||
|
||||
it('returns coverageRatio null for an empty list (zero goal)', () => {
|
||||
expect(aggregateGoalCoverage([]).coverageRatio).toBeNull()
|
||||
})
|
||||
})
|
||||
224
tests/unit/municipalityPotential.unit.spec.ts
Normal file
224
tests/unit/municipalityPotential.unit.spec.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { MunicipalityFederalBaseline } from '@/lib/bahiaElectionAggregates'
|
||||
import type { BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
|
||||
import {
|
||||
captureRate,
|
||||
decomposeStateGoal,
|
||||
fieldCeiling,
|
||||
intraFieldShare,
|
||||
projectedFieldCeiling,
|
||||
projectedValidVotes,
|
||||
rollOff,
|
||||
sanityCheckSuggestedGoalsByTerritory,
|
||||
type MunicipalityPotential,
|
||||
} from '@/utilities/municipalityPotential'
|
||||
|
||||
const baseline = (overrides: Partial<MunicipalityFederalBaseline> = {}): MunicipalityFederalBaseline => ({
|
||||
votesByYear: {},
|
||||
validVotesByYear: {},
|
||||
campoFederalVotesByYear: {},
|
||||
federalTallyByYear: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('projectedValidVotes', () => {
|
||||
it('weights 2022 double: (v2014 + v2018 + 2×v2022) / 4', () => {
|
||||
const b = baseline({ validVotesByYear: { '2014': 1000, '2018': 2000, '2022': 3000 } })
|
||||
// (1000 + 2000 + 2*3000) / 4 = 9000 / 4 = 2250
|
||||
expect(projectedValidVotes(b)).toBe(2250)
|
||||
})
|
||||
|
||||
it('treats missing years as zero', () => {
|
||||
const b = baseline({ validVotesByYear: { '2022': 4000 } })
|
||||
expect(projectedValidVotes(b)).toBe(2000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fieldCeiling / projectedFieldCeiling', () => {
|
||||
it('reads the 2022 president #13 votes as the primary ceiling', () => {
|
||||
const b = baseline({
|
||||
majoritarian2022: {
|
||||
president: { votes: 5000, comparecimento: 8000, votosValidos: 7500, votosBranco: 300, votosNulo: 200 },
|
||||
governor: { votes: 5500, comparecimento: 8000, votosValidos: 7500, votosBranco: 300, votosNulo: 200 },
|
||||
},
|
||||
})
|
||||
expect(fieldCeiling(b)).toBe(5000)
|
||||
})
|
||||
|
||||
it('is 0 when no majoritarian data is seeded for the slug', () => {
|
||||
expect(fieldCeiling(baseline())).toBe(0)
|
||||
})
|
||||
|
||||
it('scales the ceiling by the projected/observed 2022 valid-vote ratio', () => {
|
||||
const b = baseline({
|
||||
validVotesByYear: { '2014': 4000, '2018': 6000, '2022': 8000 },
|
||||
majoritarian2022: {
|
||||
president: { votes: 5000, comparecimento: 9000, votosValidos: 8500, votosBranco: 300, votosNulo: 200 },
|
||||
governor: { votes: 5200, comparecimento: 9000, votosValidos: 8500, votosBranco: 300, votosNulo: 200 },
|
||||
},
|
||||
})
|
||||
// projected = (4000 + 6000 + 2*8000) / 4 = 6500; ratio = 6500/8000 = 0.8125
|
||||
expect(projectedFieldCeiling(b)).toBeCloseTo(5000 * 0.8125, 6)
|
||||
})
|
||||
|
||||
it('falls back to the unscaled ceiling when 2022 valid votes are unknown', () => {
|
||||
const b = baseline({
|
||||
majoritarian2022: {
|
||||
president: { votes: 5000, comparecimento: 9000, votosValidos: 8500, votosBranco: 300, votosNulo: 200 },
|
||||
governor: { votes: 5200, comparecimento: 9000, votosValidos: 8500, votosBranco: 300, votosNulo: 200 },
|
||||
},
|
||||
})
|
||||
expect(projectedFieldCeiling(b)).toBe(5000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureRate', () => {
|
||||
it('is Solla 2022 votes over the 2022 field ceiling', () => {
|
||||
const b = baseline({
|
||||
votesByYear: { '2022': 1000 },
|
||||
majoritarian2022: {
|
||||
president: { votes: 5000, comparecimento: 9000, votosValidos: 8500, votosBranco: 300, votosNulo: 200 },
|
||||
governor: { votes: 5200, comparecimento: 9000, votosValidos: 8500, votosBranco: 300, votosNulo: 200 },
|
||||
},
|
||||
})
|
||||
expect(captureRate(b)).toBeCloseTo(0.2, 6)
|
||||
})
|
||||
|
||||
it('is null when the ceiling is unknown', () => {
|
||||
expect(captureRate(baseline({ votesByYear: { '2022': 1000 } }))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('intraFieldShare', () => {
|
||||
it('is Solla votes over the curated campo federal votes, per year', () => {
|
||||
const b = baseline({
|
||||
votesByYear: { '2018': 800 },
|
||||
campoFederalVotesByYear: { '2018': 2000 },
|
||||
})
|
||||
expect(intraFieldShare(b, 2018)).toBeCloseTo(0.4, 6)
|
||||
})
|
||||
|
||||
it('is null when the field polled zero that year', () => {
|
||||
expect(intraFieldShare(baseline(), 2018)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rollOff', () => {
|
||||
it('is (brancos+nulos DF) − (brancos+nulos majoritária), with percent of turnout', () => {
|
||||
const b = baseline({
|
||||
federalTallyByYear: {
|
||||
'2022': { comparecimento: 10_000, votosValidos: 9000, votosBranco: 600, votosNulo: 400 },
|
||||
},
|
||||
majoritarian2022: {
|
||||
president: { votes: 5000, comparecimento: 10_000, votosValidos: 9600, votosBranco: 250, votosNulo: 150 },
|
||||
governor: { votes: 5200, comparecimento: 10_000, votosValidos: 9600, votosBranco: 250, votosNulo: 150 },
|
||||
},
|
||||
})
|
||||
// DF blank+null = 1000; majoritarian blank+null = 400; roll-off = 600
|
||||
const result = rollOff(b)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.votes).toBe(600)
|
||||
expect(result?.percentOfTurnout).toBeCloseTo(0.06, 6)
|
||||
})
|
||||
|
||||
it('is null (2022-only diagnostic) when the majoritarian tally is missing', () => {
|
||||
const b = baseline({
|
||||
federalTallyByYear: {
|
||||
'2022': { comparecimento: 10_000, votosValidos: 9000, votosBranco: 600, votosNulo: 400 },
|
||||
},
|
||||
})
|
||||
expect(rollOff(b)).toBeNull()
|
||||
})
|
||||
|
||||
it('is null for years without a seeded federal tally (2014/2018)', () => {
|
||||
expect(rollOff(baseline())).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('decomposeStateGoal', () => {
|
||||
it('splits the state goal proportionally to projectedFieldCeiling, summing back to it', () => {
|
||||
const potentials = [
|
||||
{ slug: 'a', projectedFieldCeiling: 1000 },
|
||||
{ slug: 'b', projectedFieldCeiling: 3000 },
|
||||
{ slug: 'c', projectedFieldCeiling: 6000 },
|
||||
]
|
||||
const suggested = decomposeStateGoal(potentials, { stateGoal: 150_000 })
|
||||
|
||||
expect(suggested.get('a')).toBeCloseTo(15_000, 6)
|
||||
expect(suggested.get('b')).toBeCloseTo(45_000, 6)
|
||||
expect(suggested.get('c')).toBeCloseTo(90_000, 6)
|
||||
|
||||
const total = [...suggested.values()].reduce((sum, value) => sum + value, 0)
|
||||
expect(total).toBeCloseTo(150_000, 6)
|
||||
})
|
||||
|
||||
it('falls back to an even split when every ceiling is zero (never NaN)', () => {
|
||||
const potentials = [
|
||||
{ slug: 'a', projectedFieldCeiling: 0 },
|
||||
{ slug: 'b', projectedFieldCeiling: 0 },
|
||||
]
|
||||
const suggested = decomposeStateGoal(potentials, { stateGoal: 100_000 })
|
||||
expect(suggested.get('a')).toBe(50_000)
|
||||
expect(suggested.get('b')).toBe(50_000)
|
||||
})
|
||||
|
||||
it('returns an empty map for an empty potentials list', () => {
|
||||
expect(decomposeStateGoal([], { stateGoal: 150_000 }).size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanityCheckSuggestedGoalsByTerritory', () => {
|
||||
const region = 'Litoral Sul' as BahiaIdentityTerritory
|
||||
const otherRegion = 'Chapada Diamantina' as BahiaIdentityTerritory
|
||||
|
||||
it('never blocks — always returns an array, even when nothing is out of range', () => {
|
||||
const warnings = sanityCheckSuggestedGoalsByTerritory([
|
||||
{ region, suggestedGoal: 5000, projectedValidVotes: 20_000 },
|
||||
])
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('flags a territory whose decomposed goal total exceeds its projected electorate', () => {
|
||||
const warnings = sanityCheckSuggestedGoalsByTerritory([
|
||||
{ region, suggestedGoal: 15_000, projectedValidVotes: 10_000 },
|
||||
{ region: otherRegion, suggestedGoal: 4_000, projectedValidVotes: 10_000 },
|
||||
])
|
||||
expect(warnings).toHaveLength(1)
|
||||
expect(warnings[0]?.region).toBe(region)
|
||||
expect(warnings[0]?.ratio).toBeCloseTo(1.5, 6)
|
||||
})
|
||||
|
||||
it('ignores territories with no projected electorate (avoids a divide-by-zero false positive)', () => {
|
||||
const warnings = sanityCheckSuggestedGoalsByTerritory([
|
||||
{ region, suggestedGoal: 1000, projectedValidVotes: 0 },
|
||||
])
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeMunicipalityPotential / computeAllMunicipalityPotentials (real artifact)', () => {
|
||||
it('returns a well-shaped potential for a real catalog slug and a zeroed one for an unknown slug', async () => {
|
||||
const { computeMunicipalityPotential } = await import('@/utilities/municipalityPotential')
|
||||
const { municipalityCatalog } = await import('@/lib/municipalityCatalog')
|
||||
|
||||
const realSlug = municipalityCatalog[0]!.slug
|
||||
const real: MunicipalityPotential = computeMunicipalityPotential(realSlug)
|
||||
expect(real.slug).toBe(realSlug)
|
||||
expect(real.projectedValidVotes).toBeGreaterThanOrEqual(0)
|
||||
expect(real.projectedFieldCeiling).toBeGreaterThanOrEqual(0)
|
||||
|
||||
const unknown = computeMunicipalityPotential('nao-existe')
|
||||
expect(unknown.projectedValidVotes).toBe(0)
|
||||
expect(unknown.fieldCeiling2022).toBe(0)
|
||||
expect(unknown.rollOff2022).toBeNull()
|
||||
})
|
||||
|
||||
it('computeAllMunicipalityPotentials covers exactly the municipality catalog', async () => {
|
||||
const { computeAllMunicipalityPotentials } = await import('@/utilities/municipalityPotential')
|
||||
const { municipalityCatalog } = await import('@/lib/municipalityCatalog')
|
||||
|
||||
const potentials = computeAllMunicipalityPotentials()
|
||||
expect(potentials).toHaveLength(municipalityCatalog.length)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue