Merge branch 'cursor/c64aa441'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-25 22:38:58 -03:00
commit 292df15b3f
38 changed files with 1594 additions and 267 deletions

View file

@ -100,6 +100,8 @@ alwaysApply: false
## Decisões
- Em 2026-07-25, **o "por quê" da classe na lista virou tooltip de célula, e a mecânica é do sistema, não da coluna:** o pedido do produto foi que a célula "Classe" ficasse só com o chip (a linha de fatores por extenso a tornava a célula mais larga de uma tabela que não rola na horizontal — 106px hoje, o mesmo que "Tipo"), com o detalhe no hover. Em vez de resolver na coluna, a capacidade nasceu **genérica**: `cellTooltip` em `CampaignTableColumn` + `shared/CampaignCellTooltip`, e `MunicipalityHoverTooltip` promovido para `shared/CampaignHoverTooltip` — isto é o núcleo do **B23**, que fica só com os dois consumidores originais (Assessores, Tendência) e a convivência com o Popover. Contrato travado e pinado em teste: a tooltip é **redundante**, nunca a única cópia — o Radix só monta o conteúdo ao abrir, então a informação continua na célula como `sr-only`, sem tab stop novo. **Rejeitado:** manter o texto visível na célula (custo de largura na tabela que não rola); `title` nativo (não alcança teclado nem toque — foi exatamente o defeito que o critique do E10 apontou).
- Em 2026-07-25, **E10 entregue — classe territorial é relativa, derivada e sempre acompanhada do "por quê":** vocabulário **Reduto · Expansão · Manutenção · Marginal · Sem base** (research; substitui `Defesa/Ataque/Indecisa/Perdida` do A5-2, calibrado para majoritária). Classificador puro sobre o artefato TSE commitado (`municipalityTerritorialClass.ts`) — LQ contra o padrão estadual do próprio candidato (2 / 0,5), importância na votação própria, teto do campo contra a mediana do catálogo, bloco dos 50%. **Coluna nova na lista assumida sem B17** (decisão explícita do produto; o E9 tinha evitado — B17 fica mais valioso), com sort `classe` e filtro `?class=` que, por ser derivado, força o caminho `limit: 0` e se aplica também ao escopo da visão geral e às facetas. **NEC/competição fora da v1** (exigiria agregado SQL novo; gatilho = E11 precisar do eixo). Cortes ilustrativos, calibração em E15. **Rejeitado:** rótulo sem fatores (excesso de confiança, research §6.4); override manual da classe (discordância entra por E14/`allocationDecision`); quantil como classe (é escala de mapa, B13); distribuição de classes na visão geral (não é métrica). Plano: [`docs/plans/classificacao-territorial-relativa.md`](../../../docs/plans/classificacao-territorial-relativa.md).
- Em 2026-07-25, **coluna de cobertura da meta = "Cobertura" (só na lista):** header curto na fila (padrão A11); definição no tooltip B22 / glossário E18. Soft B16 (sem "Assessoria", o nome curto não ambigua). Strips/card/conceitos mantêm "Cobertura da meta". **Rejeitado:** B26 de trilha; varredura global da string; absorver só em B22.
- Em 2026-07-25, **lista de municípios sem coluna Tipo:** Município e Zona eleitoral são a mesma unidade operacional na coordenação; a coluna/filtro/sort `kind` na lista não desbloqueia decisão. Path para ZE de Salvador = multi-select/busca de slugs. Schema `kind` permanece (geografia). **Rejeitado:** B24 de trilha; hide via B17 em vez de remover; drop do campo no modelo; manter `?kind=` zumbi na URL.
- Em 2026-07-25, **B20 — Início = atalho das prioritárias, não inventário:** a seção “Municípios prioritários” no dashboard mostra amostra (≥8) ordenada por déficit de cobertura (`central`, E9) + contagem + CTA para `?priority=alta`; não lista os ~50 chips nem mente com `.slice(0, 6)` na ordem do escopo. Markup sem bullets (`list-none`, precedente assessores/recentes). **Rejeitado:** inventário completo no Início; KPI só-número sem nomes; segunda fila “só sem assessor” (já é E9 coluna da vergonha).

View file

@ -72,6 +72,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
**Recently resolved (2026-07-25):** **C13** "Plano de Ação" → "Atividade" — the vertical speaks one word now, the one the mesa already uses. Collection `actionPlan` → `activity` (`src/collections/Activity.ts`, labels "Atividade"/"Atividades"), `campaignDemand.actionPlan` → `activity`, route `/campanha/planos` → `/campanha/atividades` (create at `/campanha/atividades/nova`), sidebar entry "Atividades", and field labels realigned to the entity ("Tipo de atividade", "Origem da atividade", "Resultado da atividade") — enum **values** (`kind`/`status`/`origin`) are unchanged. Schema moved by a **hand-written data-preserving migration** (`20260725_213000_rename_action_plan_to_activity`): 4 tables, 3 enums, 2 columns, 39 indexes (4 pkeys included — renaming a pkey index renames its constraint), 14 FKs and 2 sequences, every statement guarded on the source object existing so environments converge either way, with a symmetric `down()`. Payload's generator would have emitted DROP+CREATE (data loss) and cannot see the hand-written partial index `action_plan_upcoming_start_at_idx`, so the object list came from `pg_indexes`/`pg_constraint`/`pg_type`/`pg_sequences` and the `.json` snapshot was written by transforming its predecessor — validated by `pnpm migrate:create __probe` reporting no schema changes. The same migration aligned the **8 fossil `%plaza%` index names** the Município remodel left behind (it renamed the `plaza_id` columns but not their indexes, so the database and the snapshot had been disagreeing since 2026-07-23). Guard: the `codebaseConventions.unit.spec.ts` "banned campaign terminology" describe is now table-driven and sweeps `src` + `tests` + `scripts` (migrations stay out — frozen history), banning the `actionPlan` compounds, the pt-BR copy and the old route, but **not** bare `plan`/`plano` (that would collide with the white-label subscription plans of Fase 2); allowlist covers the spec itself, `scripts/generate-remodel-municipalities-migration.mjs` and the historical comment in `campaignMigrationReconciliation.int.spec.ts`. Plano: [`docs/plans/renomear-plano-acao-para-atividade.md`](docs/plans/renomear-plano-acao-para-atividade.md).
**Recently resolved (2026-07-25):** **E10** Classificação territorial relativa — `src/utilities/municipalityTerritorialClass.ts` classifies each of the 435 catalog municipalities as **`reduto | expansao | manutencao | marginal | sem_base`** (labels "Reduto · Expansão · Manutenção · Marginal · Sem base"). This vocabulary comes from the discovery research and **replaces the majoritarian-era `Defesa/Ataque/Indecisa/Perdida`** — don't reintroduce it. The classifier is a **pure function over the committed TSE artifact** (no DB, no `campaignGoals`, no `user`, no migration): location quotient against the candidate's own statewide standard (`TERRITORIAL_CLASS_ANCHORS.strongLq` 2 / `weakLq` 0.5), share of his own statewide vote, uncaptured field ceiling (E8's `projectedFieldCeiling`) compared against the **catalog median**, plus membership in the block holding 50% of his vote (which is what keeps a big low-penetration município out of "Marginal") — that block is built by walking A11's already-memoized `computeVoteRankByYear` ordering, not by re-sorting the catalog. Cuts are ILLUSTRATIVE and versioned in one object — calibration is **E15**. `getStatewideFederalTotals(year)` was extracted and memoized into `bahiaElectionAggregates.ts`; `municipalityVoteRank.ts` reuses it instead of summing the state itself. **The UI never renders the label alone** (research §6.4 — a bare verdict buys false confidence): the class always travels with its two dominant factors as real text — never a `title` attribute, which reaches neither keyboard nor touch. In the list that text is `sr-only` behind the badge and shown on hover/focus/tap through the **B23 `cellTooltip` seam** (spelling it out under the pill made Classe the widest cell in a table that cannot scroll horizontally); on the detail card and the dossier it is a visible paragraph. Surfaces: `MunicipalityGoalAccountCard` (E8 — the class opens the diagnostics block it summarizes, no new card), the `MunicipalityDossier` cover (self-labeling badge "Classe reduto" + factors; print CSS forces `print-color-adjust: exact` on `[data-slot='badge']`, otherwise the printer flattens priority/trend/class into identical grey), and a new **"Classe" column** in `/campanha/municipios` right after "2022" — the 10th staff column, accepted without B17 by explicit product decision. Badge palette is documented in `DESIGN.md` § Status Badge; `sem_base` deliberately renders as an em dash in the list (matching the neighboring "2022" column) and keeps the flattest pill on the detail card and the dossier, where it sits in a labeled "Classe …" field. **List filtering is the one derived filter in the system:** `state.classes` is NOT part of `buildMunicipalityListWhere` — `municipalityPageData.ts` forces the `limit: 0, pagination: false` path whenever a class is selected (otherwise a 25-row page would drop rows and `totalDocs` would lie) and applies the same predicate to `staffScope.municipalities` before the overview rollup and to all three column facets. URL: repeatable `?class=` (all selected canonicalizes to absent, same as `trend`) and sort key `classe` (ordinal via `territorialClassSortWeight`, `desc` default = reduto first, `sem_base` last either way). Two new `diagnostico` concepts in `/campanha/conceitos` (E18). NEC/competition stayed out of v1 (would need a new SQL aggregate; trigger is E11 needing the competition axis beyond the intra-field proxy). Unblocks **B13**, which should consume `computeMunicipalityTerritorialClass` as-is rather than reclassify. Plano: [`docs/plans/classificacao-territorial-relativa.md`](docs/plans/classificacao-territorial-relativa.md).
**Recently resolved (2026-07-25):** **E16** Dossiê do município (pré-agenda, field request O6) — staff-only `dossie` tab on the municipality detail page (6th value in `municipalityDetailTabs`, same query-param pattern): `src/utilities/municipalityDossierData.ts` composes the existing loaders in one pass (TSE baseline + A11 rank via `municipalityElectionGeographyForSlug`, E8 goal account, pledges, leaderships sorted by a new `LeadershipRowViewModel.updatedAt` freshness field, updates feed, two scoped `actionPlan` reads with `overrideAccess: false`, and A8 demographics — the previously consumer-less `bahiaMunicipalityDemographics.ts` re-enters through the "Perfil" section, with an explicit whole-city caveat for Salvador zona municipalities). Section caps 8 lideranças / 5 sinais / 3 upcoming + 2 realizado plans, each with a "ver tudo" link to its tab. New staff-only `municipality.budgetNotes` textarea ("Emendas aportadas", G11 manual-first) via migration `20260725_022155_add_municipality_budget_notes` — its committed `.json` snapshot also heals the drizzle snapshot chain after the snapshot-less `campaign_goals` migration; strategy form/zod/action extended; the section renders only when filled. **Print view is CSS-only:** app chrome gets `print:hidden`, the `h-svh`/`overflow-hidden` shells get `print:h-auto print:overflow-visible` (otherwise only page 1 prints), and `styles.css` gains A4 print typography; the dossier cover is self-contained (name, TI, badges, "Gerado em") so the printed page keeps its identity. Int test mocks `loadMunicipalityElectoralBaseline` (its `unstable_cache` wrapper needs the Next runtime). Plano: [`docs/plans/dossie-municipio.md`](docs/plans/dossie-municipio.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, per-scenario suggested goal + TI sanity check) and `src/utilities/goalCoverage.ts` — **locked semantics:** `meta = expectedVotes[scenario] ?? suggestedGoal[scenario]`, `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). **As-built correction (same day, during E9):** the suggested goal originally split the state goal proportionally to the field ceiling alone, which ignored the candidate's own vote and produced absurd targets (2,911 for Vitória da Conquista, which gave him 5,005 votes in 2022; 813 for Campo Formoso, which gave him 47). `decomposeStateGoal` was deleted in favour of `deriveSuggestedGoalsByScenario`, anchored on the municipality's own 2022 vote — pessimistic = base × (1 − `margin`), central = base, optimistic = base × (`stateGoal` ÷ Σ base), clamped at `max(central, optimistic)` — so `stateGoal` now defines the optimistic scenario and `margin` the pessimistic haircut (10% fallback). Descriptions only, **no migration**.

View file

@ -256,9 +256,11 @@ Controls feel **confident and compact**: short primary buttons, dense toolbars,
Operational meaning for leadership/support/estimates. Always use `Badge` variants tied to CSS tokens — never hardcode greens/ambers that drift from `--support-*` / `--estimate-*`.
**Territorial class (E10)** is a four-step ladder scanned down a 25-row column, so its variants must stay mutually legible: reduto `estimate-confirmed` (green, achieved), expansão `estimate-pending` (amber, asks for work), manutenção `secondary` (grey, routine), marginal `outline` (hairline). `sem_base` gets **no badge** — absent data is the em dash the neighboring columns already use. The map lives in `territorialClassBadgeVariant`; the class never renders without its "por quê" line.
### Campaign list system (Pass 2 W1 — canonical, do not fork)
Every `/campanha` list surface composes the shared pieces in `src/components/campaign/shared/`: `CampaignTable` (columns as data over house `ui/Table`; `rounded-xl border` container), `CampaignSearchForm` / `CampaignFilterChips` as the toolbar, `CampaignListFooter` (count + pagination), `CampaignListEmptyState` (`Empty` with icon + CTA, `min-h-72 border`), all inside `CampaignListPendingBoundary` so navigation dims the RESULTS region ("Feel the action"). Rich headers (sort + filter popovers) are the municipality islands (`MunicipalitySortableHead`/`MunicipalityHeaderFilter`) — the reference implementation, not yet generalized. New list = new column definitions + these shells; a bespoke table needs a documented exception (precedents: planos cards, `TerritoryOverviewTable`).
Every `/campanha` list surface composes the shared pieces in `src/components/campaign/shared/`: `CampaignTable` (columns as data over house `ui/Table`; `rounded-xl border` container), `CampaignSearchForm` / `CampaignFilterChips` as the toolbar, `CampaignListFooter` (count + pagination), `CampaignListEmptyState` (`Empty` with icon + CTA, `min-h-72 border`), all inside `CampaignListPendingBoundary` so navigation dims the RESULTS region ("Feel the action"). Rich headers (sort + filter popovers) are the municipality islands (`MunicipalitySortableHead`/`MunicipalityHeaderFilter`) — the reference implementation, not yet generalized. Cell-level explanation IS generalized (B23 seam, first consumer E10's "Classe"): a column declares `cellTooltip: (row) => ReactNode` and `CampaignTable` wraps that cell in `CampaignCellTooltip`; returning `null` for a row leaves it untouched. The contract is that the tooltip is REDUNDANT — the same content must already be text in the cell (visible, or `sr-only` behind a badge), because Radix mounts tooltip content only on open and the table adds no tab stops. The hover primitive is `shared/CampaignHoverTooltip` (hover + keyboard focus + touch tap). New list = new column definitions + these shells; a bespoke table needs a documented exception (precedents: planos cards, `TerritoryOverviewTable`).
## 6. Do's and Don'ts

View file

@ -1,89 +1,97 @@
# E10 — Classificação territorial relativa (defesa/ataque para DF)
# E10 — Classificação territorial relativa
Status: rascunho
Atualizado em: 2026-07-25 (Pass 2 W4a) — **`src/lib/electionInsights.ts` foi deletado**: os ~52 exports sem consumidor (limiares 35/20/10 da era núcleos, gap vs 2022, conversão, mobilização, ticket flip/leverage e os helpers de badge/label citados abaixo) saíram do repo com decisão D2 assinada; as fórmulas ficam no git history (commit "Pass 2 W4a"). Sobreviventes: `formatElectionNumber` → `src/lib/electionFormat.ts`; `computeVoteTrend` → `src/lib/voteTrend.ts`. **E10 nasce em módulo novo** (ex.: `dfTerritorialClass.ts`) em terreno limpo — a premissa "editar electionInsights in place" abaixo está superada.
Atualizado em: 2026-07-24 (refs sincronizadas pós-remodelagem Municípios + hardening)
Status: **entregue 2026-07-25**
Atualizado em: 2026-07-25 (as-built E10) — as decisões travadas do rascunho anterior (vocabulário `Defesa/Ataque/Indecisa/Perdida`, edição in place de `electionInsights.ts`, NEC na v1) foram **substituídas** durante a implementação; o histórico fica abaixo em "Correções do rascunho".
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Inteligência de campanha", E10; plano-mestre [inteligencia-campanha.md](inteligencia-campanha.md))
Impeccable: B — muda o conteúdo de cards/badges existentes (insights do município, overview da lista); sem rota nova
Appetite: ~1 dia eng; sem migration (derivação em leitura, limiares versionados em lib)
Impeccable: B — muda o conteúdo de cards/lista existentes; sem rota nova
Appetite: ~1,1–1,25d eng; sem migration (derivação em leitura, âncoras versionadas em código)
Responsável: —
## Design (Impeccable)
## O que foi entregue
Âncoras: `PRODUCT.md` (princípio 5) / `DESIGN.md` (register `product`) · badges/Alert existentes do stack de insights.
Um classificador **puro sobre o artefato TSE commitado** (`src/lib/electionAggregates/bahia-federal-baseline.json`) que dá a cada um dos 435 municípios do catálogo uma classe operacional. Sem query, sem `campaignGoals`, sem `user`, sem migration.
Na implementação: craft compacto → critique → polish.
### Vocabulário (decidido com produto nesta sessão)
- **Persona / contexto:** assessor lendo o município; coordenador filtrando a lista por classe.
- **Job principal:** a classe transforma número em verbo (defender/atacar/consolidar/minimizar) sem mentir na aritmética de DF.
- **Estratégia de cor:** manter o mapeamento de badges já usado (defesa verde, ataque contorno vermelho, indecisa âmbar, perdida cinza — precedente A5-2).
- **Edit where you see:** não — classe é sugestão automática; discordância do coordenador vira nível/nota (E14), não override do cálculo.
- **Anti-goals:** classe como sentença (é sugestão); limiar absoluto hardcoded sem racional.
`reduto | expansao | manutencao | marginal | sem_base` — rótulos **Reduto · Expansão · Manutenção · Marginal · Sem base** (valores pt-BR sem acento, precedente `politicalTrend.status`). Casa com os padrões P1/P2/P10 do E11 e com o relatório de discovery; substitui `Defesa/Ataque/Indecisa/Perdida`, herança majoritária do A5-2.
## Contexto
### Eixos e âncoras
Os limiares herdados de A5-2 (`TERRITORIAL_DEFESA_MIN = 0.35` etc. em `src/lib/electionInsights.ts`) foram calibrados para leitura majoritária. O relatório (D4) mostra a degeneração em DF: share local típico 1–5% → o estado inteiro vira "perdida". Prescrição: **âncoras relativas** — múltiplos do share estadual do próprio candidato (LQ ≥ 2–3 = defesa; ~1 = padrão; < 0,5 = fraqueza) e/ou múltiplos do share da cadeira marginal — e classificação **multi-eixo**: dominância + importância do município na própria votação (concentração) + competição local + teto do campo. Cortes exatos são calibráveis por backtest (E15); aqui entra o método com valores iniciais versionados.
`src/utilities/municipalityTerritorialClass.ts` (pure, sem `server-only`, irmão de `municipalityPotential.ts` — não pode morar em `src/lib/` porque `lib` não importa `utilities`):
## Objetivos
- **`lq`** = (votos ÷ válidos local) ÷ (votos ÷ válidos estadual) — dominância relativa contra o padrão estadual do próprio candidato.
- **`ownShare`** = % da votação estadual dele que vem daqui (âncora da mesa, A11).
- **`fieldHeadroom`** = `projectedFieldCeiling` (E8) − votos próprios, comparado à **mediana do catálogo** (corte relativo, sem número mágico que envelhece).
- **`inCoreBlock`** = está no bloco que acumula 50% da votação própria — impede que um município grande com baixa penetração leia como "Marginal".
- Novo classificador em `electionInsights.ts` (ou módulo irmão `dfTerritorialClass.ts`): entrada = dominância, LQ, share do município na votação própria, captura vs. teto do campo, NEC/desequilíbrio local; saída = classe + fatores ("por quê").
- Limiares versionados como constantes nomeadas com comentário de estatuto (ilustrativos, calibráveis por E15) — mesmos 4 rótulos operacionais existentes.
- Substituição nos consumidores: card de insights do município, distribuição no overview da lista, filtro por classe (se existente) e cor de classe onde o mapa a usar (B13).
- "Por quê" exposto: a UI mostra os 2 fatores dominantes ("LQ 2,8 · município é 6% da sua votação"), não só o rótulo — antídoto do excesso de confiança (relatório §6.4).
- Testes unit com fixtures das 4 classes + casos de borda (município sem baseline, válidos 0 — reusar branches `semBaseline` existentes).
Regras, na ordem de leitura da mesa: `lq ≥ strongLq (2)` → Reduto; `lq < weakLq (0,5)` → Expansão se há campo (headroom ≥ mediana **ou** core block), senão Marginal; entre os cortes → Manutenção. Válidos 0 ou sem série → `sem_base`.
## Decisões travadas
`TERRITORIAL_CLASS_ANCHORS` carrega os cortes com comentário de estatuto: são **ilustrativos** (research §6 dá a forma: LQ > 2–3 reduto, ~1 padrão, < 0,5 fraqueza); a calibração empírica é **E15**.
- **Âncora primária = múltiplos do share estadual do próprio candidato (LQ); âncora secundária = share da cadeira marginal.** A régua viaja entre eleições/candidatos e ancora no que "eleger" significa. **Rejeitado:** manter 35/20/10 (degeneração comprovada na aritmética); quantis puros como classe (quantil não tem semântica operacional de meta — fica para escala do mapa B13).
- **Classe continua sugestão automática, não editável.** Precedente A5-2 mantido; julgamento humano entra por E14 (nível) e `allocationDecision`. **Rejeitado:** override manual da classe (duas verdades no mesmo badge).
- **i18n e naming:** `computeDfTerritorialClass`, `DF_CLASS_ANCHORS`, fatores `dominance | ownShare | capture | competition`; rótulos pt-BR existentes (`Defesa`, `Ataque`, `Indecisa`, `Perdida`).
### Saída e apresentação
## Questões em aberto
`{ class, factors, lq, ownShare, fieldHeadroom, inCoreBlock }`, com `factors` ordenados por força (`dominance | ownShare | field | capture`). **A UI nunca mostra o rótulo sozinho** (anti-goal do relatório §6.4 — rótulo sem "por quê" compra excesso de confiança):
- **Manter o classificador antigo para leituras majoritárias?** Opções: substituir tudo | manter ambos com seletor de contexto. **Recomendação:** substituir nos consumidores de município (o produto é DF); manter as constantes antigas exportadas até B13 estabilizar, depois remover.
- **NEC/desequilíbrio entram na v1 do classificador?** **Recomendação:** sim como fator qualificador de "ataque" (disputa aberta), com fallback neutro quando o cálculo por município não estiver disponível — não bloquear a entrega pelos índices.
- **Detalhe** — badge + linha "por quê" (2 fatores) no topo do bloco de diagnósticos do `MunicipalityGoalAccountCard` (E8), que já renderiza exatamente os números que alimentam o classificador. Não nasceu card novo.
- **Dossiê (E16)** — badge autoexplicativo na capa ("Classe reduto", ao lado de prioridade e tendência) + a linha de fatores logo abaixo; o print CSS força `print-color-adjust: exact` nos badges, senão a impressora achata os três em cinza.
- **Lista** — coluna nova **"Classe"**, logo após "2022" (mesma origem TSE), com `MunicipalitySortableHead` (`sortKey="classe"`, `filterParam="class"`). A célula é **só o chip**: os fatores por extenso a tornavam a célula mais larga de uma tabela que não rola na horizontal, então o "por quê" saiu para a tooltip da célula (hover/foco/toque) e ficou na célula como `sr-only`. Isso foi feito pela mecânica genérica do **B23** (`cellTooltip` em `CampaignTableColumn` + `shared/CampaignCellTooltip`, com `MunicipalityHoverTooltip` promovido para `shared/CampaignHoverTooltip`), não por um componente desta coluna. `sem_base` renderiza travessão, não badge, e não declara tooltip.
- **Conceitos (E18)** — dois verbetes novos na categoria `diagnostico`: `dominancia-relativa` (LQ) e `classe-territorial`.
## Abordagem proposta
Paleta (documentada em `DESIGN.md` § Status Badge): reduto `estimate-confirmed`, expansão `estimate-pending`, manutenção `secondary`, marginal `outline`, `sem_base` sem badge.
```mermaid
flowchart LR
Base["municipalityElectoralBaseline<br/>(série + válidos)"]
Pot["municipalityPotential (E8)<br/>captura/LQ/teto"]
Class["computeDfTerritorialClass<br/>(âncoras relativas, multi-eixo)"]
UI["insights do município + overview + filtro"]
Base --> Class
Pot --> Class
Class --> UI
```
### Lista: filtro e ordenação derivados
Componentes:
O filtro por classe é o **único** que não é constraint do Payload (a classe é derivada, não armazenada). Consequência tratada em `municipalityPageData.ts`:
- **`src/lib/electionInsights.ts`** (ou `dfTerritorialClass.ts` ao lado): classificador puro + âncoras versionadas; manter helpers de badge/label existentes (`territorialClassBadgeVariant`, `territorialClassLabel`).
- **Consumidores:** componente de insights do município (stack atual), `MunicipalityListOverview.tsx` (distribuição), `municipalityPageData.ts` (cálculo no load, sem N+1 — reusa agregados do bundle).
- **Sem migration, sem collection, sem server action.**
- `state.classes` fica **fora** de `buildMunicipalityListWhere`;
- `territorialClassFilterPredicate` força o caminho `limit: 0, pagination: false` mesmo quando o sort é nativo — senão a página devolveria menos de 25 linhas e `totalDocs` mentiria;
- o mesmo predicado se aplica a `staffScope.municipalities` antes do rollup (visão geral E8/E9 conta o escopo filtrado) e às três facetas dos popovers de coluna.
## Dependências
URL: `?class=` repetível, canonicalizado como `trend` (marcar todas = param ausente); sort key `classe` com default `desc` (reduto primeiro), ordinal por `territorialClassSortWeight` (`sem_base` sempre no fim).
- Dura: **E8** (LQ/captura/teto derivados). Suave: E15 (calibração futura dos cortes); B13 (consome as classes).
- Reusa: `municipalityElectoralBaseline.ts`, `municipalityCandidateComparison.ts` (share de outros candidatos para competição), helpers de badge existentes.
## Dados → decisão → apresentação
- **Decisão nomeada** — Coordenador: "esta semana, onde a perna vai: defender reduto dormente ou abrir rede em expansão?" Assessor: "este município é reduto meu ou eu ainda não existo aqui?"
- **Forma** — badge + duas frases de "por quê" (degrau mais pobre que resolve: rótulo com contexto numérico), reusando `Badge`, `MunicipalityHoverTooltip`/`MetricExplanation` e a coluna de tabela.
- **Rejeitado** — chart de distribuição das classes; quantis como classe (quantil vira escala do mapa em B13); % estadual absoluto como métrica (anti-goal `PRODUCT.md` §5).
- **Anti-goal vigiado** — rótulo sem o "por quê".
## Correções do rascunho (Passo 7)
Registradas para que ninguém reabra decisões já resolvidas:
1. **`src/lib/electionInsights.ts` não existe** (deletado no Pass 2 W4a, com os limiares 35/20/10 da era núcleos e os helpers `territorialClassBadgeVariant`/`territorialClassLabel`). E10 nasceu em módulo novo, em terreno limpo — não havia classificador antigo "para manter exportado até B13".
2. **Não existe "card de insights do município"**. O lar da classe é o `MunicipalityGoalAccountCard` (E8).
3. **Caminhos**: `MunicipalityListOverview.tsx` está em `src/components/campaign/municipality/`; o loader é `municipalityPageData.ts` (não existe `municipalityData.ts`).
4. **Vocabulário**: `Reduto/Expansão/Manutenção/Marginal/Sem base` substitui `Defesa/Ataque/Indecisa/Perdida`.
5. **Mais barato que o rascunho supunha**: tudo sai do artefato commitado. `getStatewideFederalTotals(year)` foi extraído (memoizado) em `bahiaElectionAggregates.ts` e `municipalityVoteRank.ts` passou a reusá-lo, em vez de somar o estado por conta própria.
6. **NEC/competição fora da v1** (questão aberta 2 resolvida como "não"): nem `municipalityElectoralBaseline` nem `municipalityCandidateComparison` trazem os rivais do município (a comparação é opt-in, até 5 candidatos). NEC exigiria agregado SQL novo — o rabbit hole que o próprio rascunho nomeia. O eixo competição entra pelo proxy barato do artefato (`campoFederalVotesByYear` → share intracampo) como **fator**, não como classe. Gatilho para voltar: E11 precisar do eixo competição além do proxy.
7. **Coluna nova assumida sem B17**: o E9 tinha evitado coluna nova; aqui a decisão é explícita e do produto (10ª coluna staff). O seletor de colunas (B17) fica mais valioso.
## Verificação
- `tests/unit/municipalityTerritorialClass.unit.spec.ts` — as 4 classes com input sintético, bordas (válidos 0, teto ausente, LQ exatamente no corte) e um **pin de distribuição** sobre os 435 slugs reais: nenhuma classe vazia e nenhuma acima de ~70% — exatamente o modo de falha que E10 existe para corrigir.
- `tests/unit/municipalityList.unit.spec.ts` — parse/serialize de `?class=`, canonicalização "todas = ausente", sort `classe`, ausência do filtro no `where`.
- `tests/unit/campaignComponents.unit.spec.ts` — a classe nunca renderiza sem o "por quê" (texto no markup, nunca `title`); `sem_base` sai como travessão.
- `tests/unit/campaignTable.unit.spec.ts` — contrato do `cellTooltip`: coluna sem tooltip renderiza sem wrapper, linha sem conteúdo idem, e o conteúdo do Radix não existe no markup do servidor (é por isso que a cópia `sr-only` na célula é obrigatória).
- `tests/int/municipalityPageData.int.spec.ts` — o filtro derivado devolve só a classe pedida, mantém `totalDocs`/visão geral honestos e o sort `classe` ordena reduto antes de marginal.
## Não escopo
- Escala visual do mapa (B13); override/nível humano (E14); calibração empírica dos cortes (E15); classificação em nível TI (E12 usa rollup próprio).
- Escala visual do mapa e símbolo proporcional → **B13** (consome `computeMunicipalityTerritorialClass` como está).
- Nível humano N0–N4 / override → **E14**. Calibração empírica dos cortes → **E15**. Classificação em nível TI → **E12** (rollup próprio).
- **NEC / desequilíbrio local** — adiado com gatilho (ver correção 6).
- **Distribuição de classes na visão geral da lista** — adiado: `CampaignMetricStrip` já tem 4 métricas e distribuição não é métrica; as contagens do popover de filtro dão a leitura.
## Rabbit holes
## Assumido / a validar com produto
- **Calibração prematura.** Discutir "LQ 2 ou 3?" sem backtest é bikeshedding — valores iniciais documentados como ilustrativos; E15 decide.
- **Índices de competição por município exigirem query nova pesada.** Se NEC/desequilíbrio por município não saírem baratos do que `municipalityCandidateComparison.ts` já carrega, degradar para fator neutro — não criar agregado SQL novo neste item.
## Adiado com gatilho
- **Fator "tendência da série" na classe.** Gatilho: E15 mostrar que a série 2014→2022 melhora a separação ex-ante.
- Corte de "campo grande não capturado" = mediana do catálogo (relativo, sem número mágico) — _(assumido; E15 calibra)_.
- Ano de referência fixo em 2022 (o teto do campo é 2022-only por limitação do artefato) — _(assumido)_.
- Classe é coluna staff, invisível para `leader` (que já não acessa a lista) — _(assumido)_.
## Referências
- `docs/roadmap.md` (Inteligência de campanha, E10) · [plano-mestre](inteligencia-campanha.md)
- `docs/research/relatorio-entrevista-persona-campanha.md` D4 (degeneração + âncoras), §5 nota mapa/métricas
- `docs/plans/insight-classificacao-territorial.md` (as-built A5-2 — limiares antigos, UI, testes)
- `src/lib/electionInsights.ts`, `src/utilities/municipalityElectoralBaseline.ts`, `src/utilities/municipalityCandidateComparison.ts`, `src/components/campaign/MunicipalityListOverview.tsx`
- AGENTS.md — limiares versionados, naming
- `docs/research/relatorio-entrevista-persona-campanha.md` D4 (degeneração dos limiares + âncoras relativas), §6.4 (rótulo sem "por quê")
- `docs/plans/insight-classificacao-territorial.md` (as-built A5-2 — limiares antigos, hoje removidos do código)
- `src/utilities/municipalityTerritorialClass.ts`, `src/utilities/municipalityPotential.ts`, `src/lib/bahiaElectionAggregates.ts`, `src/utilities/municipalityLabels.ts`, `src/utilities/municipalityPageData.ts`

View file

@ -147,7 +147,7 @@ Componentes:
- **Delta semanal persistido.** Revisitar quando C12 entregar trajetória (gatilho: primeira semana com 2 snapshots).
- **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.
- **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. **Reforço 2026-07-25 (E10):** a classe territorial entrou neste card como uma linha **acima** da `dl` (`TerritorialClassRow`), não como 5ª métrica dentro dela — o gatilho continua **não** disparado, mas o `/simplify` do E10 registrou que essa linha já reimplementa o estilo do trigger de `GoalAccountMetric` (o comportamento de toque/dismiss é compartilhado via `CampaignHoverTooltip`, então só o botão duplica). Quando o gatilho disparar, decidir o idioma antes de replicar o componente pela terceira vez — ver "Adiado com gatilho" de [escala-dry-pos-e10.md](escala-dry-pos-e10.md).
## Referências

View file

@ -0,0 +1,97 @@
# Escala e DRY pós-E10 (classificação territorial relativa)
Status: rascunho
Atualizado em: 2026-07-25
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, **E10+**, fill-in de engenharia)
Impeccable: A — N/A (sem superfície UI nova; as quatro fases preservam o comportamento visível de E10)
Appetite: ~0,75–1 dia eng; quatro fases independentes, nenhuma com migration, collection ou `Consent`
Responsável: —
## Dados → decisão → apresentação
- **Vou apresentar dados?** Não (N/A) — o lote é custo de leitura/render de métricas já entregues (A11, E8, E9, E10); nenhuma métrica, série ou escala nova.
- **Anti-goals de dado:** N/A.
## Contexto
**E10** ([classificacao-territorial-relativa.md](classificacao-territorial-relativa.md), entregue 2026-07-25) classificou os 435 municípios em Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE commitado, com coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, mais o seam genérico `cellTooltip` na `CampaignTable`. O `/simplify` da entrega (três revisores em paralelo — quality / performance / reuse, 2026-07-25) aplicou o cleanup barato na hora e deixou quatro achados **maiores que cleanup**, registrados aqui.
O achado mais importante não é novo: o A11 já havia registrado, em "Adiado com gatilho", a _hidratação única do sort derivado_ com o gatilho **"3º path derivado no loader"**. E10 é o **quarto** (`votos` A11, `deficit` e `frescor` E9, `classe` E10) — **o gatilho disparou**, e é por isso que este lote existe em vez de mais uma linha adiada.
**Já resolvido no simplify (não reabrir):** `campoVotes` (somado em todo rollup estadual, sem leitor) e `classification.fieldHeadroom` (duplicava o fator `field`) removidos; `formatTerritorialClassWhy` virou de fato a fonte única do `slice(0, 2)` **e** do fallback "sem série do TSE", eliminando o sentinela de string vazia (`why || TERRITORIAL_CLASS_NO_DATA`) nas duas superfícies de detalhe; a classe territorial virou um componente só com prop `layout: 'table' | 'card'`, como `VotePositionReadout`/`SignalAgeReadout` vizinhos; `parseExhaustiveEnumParam` em `campaignListUrl.ts` absorveu `trend` e `class` (o guard `includes` era morto — `allParamValues` já deduplica por `Set`); o bloco central passou a caminhar o `computeVoteRankByYear` memoizado do A11 em vez de reordenar os 435 slugs; o filtro de classe entrou no memo de `facetRows` (era aplicado 3× a arrays que costumam ser o mesmo objeto); `Intl.NumberFormat` hoisted; e dois comentários que **afirmavam o contrário do código** foram corrigidos (o de `CampaignCellTooltip` alegava que um elemento criado no servidor não sobreviveria ao clone de ref — `MunicipalityBaselineCard` o desmente desde o E8; e a regra do `sem_base`, que o `AGENTS.md` dava como "sempre travessão" enquanto card e dossiê entregam pílula).
## Objetivos
- Uma leitura da tabela `municipality` por request na lista de municípios, em vez de duas com o mesmo `where`.
- O que é puro sobre o artefato commitado é memoizado **por processo**, não recomputado por request.
- Um `TooltipProvider` por árvore `/campanha`, para o agrupamento de delay do Radix voltar a funcionar.
- Os quatro filtros multi do mobile saem da mesma definição de dado que os single-select vizinhos já usam.
- Guardrails: sem migration, sem collection, sem `Consent`, sem server action; contrato de URL da lista (`?sort=`/`?class=`/`?trend=`/…) e comportamento visível de E10/E9/A11 inalterados; `overrideAccess: false` preservado em toda leitura com `user`.
## Decisões travadas
- **F1 resolve o gatilho do A11 em vez de criar helper genérico de hidratação.** O A11 pediu "hidratação única do sort derivado"; o caminho barato é fazer `loadMunicipalityScope` aceitar docs já carregados quando `isPagedByPayload === false`, não desenhar uma camada de repositório. Fonte: `/simplify` E10 (2026-07-25) + gatilho do [ranking-votos-municipio.md](ranking-votos-municipio.md). **Rejeitado:** materializar a classe/deficit em coluna do Postgres (volta a ser schema e desfaz a pureza que é o ponto do E10); cache cross-request da lista (dado vivo de 2026 — proibido pela escada de cache do `engineering-standards.mdc`).
- **F2 separa o que é puro do que depende do banco.** `computeAllMunicipalityPotentials()` é puro sobre o artefato; só `deriveSuggestedGoalsByScenario` depende de `campaignGoals`. O memo de processo cobre o primeiro e o `cache()` de request continua cobrindo o segundo. **Rejeitado:** memoizar `computeStatewideSuggestedGoals` inteiro por processo (erraria na hora em que o CG editasse a meta estadual no admin).
- **F3 não muda o idioma de disclosure, só onde o Provider mora.** Hoist do `TooltipProvider` para o layout de `(app)`; `CampaignHoverTooltip` mantém estado controlado, o `onPointerUp` de toque e o dismiss por `pointerdown` conquistados no critique do E8 — nada disso vive no Provider. **Rejeitado:** trocar Tooltip por Popover nas células (muda o gesto em toda a lista; é decisão de produto do B22/B23, não deste lote).
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`loadMunicipalityScopeFromDocs`, `municipalityPotentialBySlug`, `mobileMultiFilterDefinitions`), strings visíveis em pt-BR.
## Questões em aberto
- **F3 deve virar um `TooltipProvider` global ou um por superfície de lista?** **Opções:** (a) um no layout de `(app)`; (b) um por página de lista; (c) manter como está. **Recomendação:** (a) — é o padrão do Radix, e o `delayDuration={300}` atual já é uniforme em todos os call sites, então não há política por superfície a preservar.
- **F4 vale a pena com quatro cópias, ou espera a quinta?** **Opções:** (a) fazer agora; (b) adiar até B21/B33 trazerem outra lista com multi no mobile. **Recomendação:** (a) — os single-select do mesmo arquivo já saem de `municipalityFilterDefinitions` num `.map` (linha 220), então a assimetria é interna ao arquivo e barata de remover; não é abstração nova, é usar a que já existe.
## Abordagem proposta
```mermaid
flowchart LR
F1["F1 leitura única<br/>municipalityPageData"] --> Lista["/campanha/municipios"]
F2["F2 memo de processo<br/>municipalityPotential"] --> Lista
F2 --> Card["Card conta da cadeira"]
F3["F3 TooltipProvider<br/>no layout (app)"] --> Chrome["Tooltips /campanha"]
F4["F4 multi do mobile<br/>via definitions"] --> Filtros["MunicipalityFilters"]
```
Componentes:
- **`loadMunicipalityScope`** (`src/utilities/campaignMunicipalityScope.ts:77`): hoje sempre consulta. Ganha uma variante que recebe os docs já lidos (`municipalityListSelect` é **superset estrito** do select do escopo — `city`, `zoneNumber`, `lastUpdateAt`, `politicalTrend` a mais), mantendo o `cache()` por request e executando só a metade de `aggregatePledgesByMunicipality`. Consumido por `municipalityPageData.ts:342` quando `isPagedByPayload === false` (`:317`).
- **`computeAllMunicipalityPotentials`** (`src/utilities/municipalityPotential.ts:152`): passa a preencher um `Map<slug, MunicipalityPotential>` de módulo, no mesmo padrão de `statewideTotalsCache` (`bahiaElectionAggregates.ts:90`), `rankByYearCache` (`municipalityVoteRank.ts:18`) e `catalogContext` (`municipalityTerritorialClass.ts`). `computeStatewideSuggestedGoals` (`:296`) continua dentro do `cache()` de request porque lê `campaignGoals`.
- **`CampaignHoverTooltip`** (`src/components/campaign/shared/CampaignHoverTooltip.tsx:75`): remove o `<TooltipProvider delayDuration={300}>` interno; o provider passa a ser renderizado uma vez em `src/app/(campaign)/campanha/(app)/layout.tsx`. Hoje a lista monta ~30 (5 headers + 25 células de Classe via `CampaignCellTooltip`), e `skipDelayDuration` do Radix só agrupa dentro de um mesmo Provider.
- **`MunicipalityFilters`** (`src/components/campaign/municipality/MunicipalityFilters.tsx:203,261,279,297`): os quatro blocos `CampaignMobileMultiFilterField` (região, tendência, classe, assessor) viram um `.map` sobre as definitions multi, espelhando o `.map` dos single-select em `:220`. `municipalityFilterDefinitions` (`municipalityListFilters.ts:113`) já carrega `param`/`label`/`options`/`staffOnly`; o que falta é `selected` + `emptyLabel` por param.
- **Migration**: sem migration, sem collection, sem server action.
## Dependências
- Nenhuma dura de outro item aberto. F1 fecha o "Adiado com gatilho" de [ranking-votos-municipio.md](ranking-votos-municipio.md) e trata junto o full-scan de `expectedVotes`/`coverage` que o B15 documenta. F4 é suave com **B21**/**B33** (se a extração do head compartilhado chegar antes, herda a peça).
- Reusa: `campaignMunicipalityScope.ts`, `municipalityPageData.ts`, `municipalityPotential.ts`, `municipalityListFilters.ts`, `CampaignMobileMultiFilterField`, `CampaignHoverTooltip`, padrão de memo de `bahiaElectionAggregates.ts`.
## Não escopo
- **Seletor de colunas / filtros salvos** — **B17** / **B18**.
- **Tooltip por célula como feature de produto** (nomes de assessores, justificativa de tendência) — **B23**; este lote só move o Provider.
- **Coluna de rank, cenário na URL, símbolo proporcional** — A11 / "Cenário junto aos filtros" / **B13**.
- **`TerritoryListColumns` com formatador de percentual próprio** — descartado no triage: outra superfície, difere só no zero à direita, e unificar exige decisão de produto sobre a casa decimal.
## Rabbit holes
- **"Já que estou no loader, unifico todos os sorts derivados numa camada."** Se alguém "só completar": nasce um repositório de municípios e o `where` do Payload vira DSL própria. **Mitigação neste item:** F1 toca **um** branch (`isPagedByPayload === false`) e não muda assinatura pública de `loadMunicipalityScope`.
- **"Já que estou no Provider, revejo o disclosure do card."** Se alguém "só completar": cai no debate Tooltip×Popover que o critique do E8 deixou explicitamente adiado. **Mitigação:** F3 é hoist puro; qualquer mudança de gesto fica com o gatilho já registrado em [conta-da-cadeira.md](conta-da-cadeira.md).
- **"Memoizo tudo que é puro por processo."** Se alguém "só completar": memoiza algo que lê `campaignGoals`/pledges e a mesa vê número velho. **Mitigação:** F2 memoiza só o que não toca o banco; o teste do lote pina que editar `campaignGoals` muda a meta sugerida na hora.
## Adiado com gatilho
- **`TERRITORIAL_CLASSES` sem consumidor e as três derivações `Object.keys(...) as ...`.** Mover a tupla + tipos para módulo de contrato client-safe removeria o cast, mas `trend` usa o **mesmo** idioma no mesmo arquivo — corrigir só o novo deixa o arquivo menos consistente. Revisitar quando: 3º enum multi entrar na lista (o **B33** traz Partido em `/campanha/dobradinhas`), tratando trend+class+partido de uma vez.
- **`TerritorialClassRow` reimplementa o trigger do `GoalAccountMetric`.** Só o estilo do botão é duplicado — o comportamento de toque/dismiss vive no `CampaignHoverTooltip` compartilhado, então o risco de regressão assimétrica é menor do que parece. Revisitar sob o gatilho **já registrado** em [conta-da-cadeira.md](conta-da-cadeira.md): quando E9–E14 adicionarem uma 5ª métrica a essa `dl`/grid, decidir layout/idioma antes de replicar o componente de novo.
- **Três caches de módulo no classificador viram um só.** Legibilidade, não custo — o sort redundante já morreu no simplify. Revisitar quando **E15** recalibrar as âncoras (é quando alguém vai reler as três lifetimes de uma vez).
## Referências
- `docs/roadmap.md` (Fill-ins abertos — **E10+**)
- [classificacao-territorial-relativa.md](classificacao-territorial-relativa.md) — o pai do lote (E10 ✓)
- [ranking-votos-municipio.md](ranking-votos-municipio.md) — "Adiado com gatilho" que F1 fecha (3º path derivado)
- [conta-da-cadeira.md](conta-da-cadeira.md) — gatilho do disclosure do `GoalAccountMetric` (E8)
- `src/utilities/municipalityPageData.ts` (`:317` `isPagedByPayload`, `:319` `listQuery`, `:342` escopo) — F1
- `src/utilities/campaignMunicipalityScope.ts` (`:77`) — F1
- `src/utilities/municipalityPotential.ts` (`:152`, `:296`) — F2
- `src/components/campaign/shared/CampaignHoverTooltip.tsx` (`:75`) + `src/app/(campaign)/campanha/(app)/layout.tsx` — F3
- `src/components/campaign/municipality/MunicipalityFilters.tsx` (`:203`–`:313`) + `src/utilities/municipalityListFilters.ts` (`:113`) — F4
- AGENTS.md — `overrideAccess: false` com `user`, escada de cache (React `cache()` vs memo de processo vs artefato), gate por entrega

View file

@ -90,7 +90,7 @@ Componentes tocados: `src/lib/municipalityVoteRank.ts` (novo, puro + unit tests)
- **Seletor de ano na ordenação da lista.** Gatilho: pedido da mesa por leitura de série na lista.
- **Coluna de rank na fila do E9.** Entra junto com E9 (a fila referencia este helper).
- **Hidratação única do sort derivado `votos` (evitar find filtrado + `loadMunicipalityScope` redundante).** Gatilho: lista lenta no recorte amplo ou 3º path derivado no loader; B15 já documenta full-scan de `expectedVotes`/`coverage` — tratar junto.
- ~~**Hidratação única do sort derivado `votos` (evitar find filtrado + `loadMunicipalityScope` redundante).** Gatilho: lista lenta no recorte amplo ou 3º path derivado no loader; B15 já documenta full-scan de `expectedVotes`/`coverage` — tratar junto.~~ — **gatilho disparou em 2026-07-25**: com `classe` (E10) o loader chegou ao 4º path derivado (`votos`, `deficit`, `frescor`, `classe`). Registrado como **E10+ F1** · [plano](escala-dry-pos-e10.md).
## Já resolvido no simplify/critique (não reabrir)

File diff suppressed because one or more lines are too long

View file

@ -106,12 +106,8 @@ const GoalAccountCard = async ({
pledgeCoverage: MunicipalityPledgeCoverageView | null
pledgeAggregate: MunicipalityPledgeAggregate
}) => {
const { suggestedGoal, goalCoverage, potential } = await loadMunicipalityGoalAccount(
payload,
user,
{ slug, expectedVotes },
pledgeAggregate,
)
const { suggestedGoal, goalCoverage, potential, territorialClass } =
await loadMunicipalityGoalAccount(payload, user, { slug, expectedVotes }, pledgeAggregate)
return (
<MunicipalityGoalAccountCard
@ -121,6 +117,7 @@ const GoalAccountCard = async ({
suggestedGoal={suggestedGoal}
goalCoverage={goalCoverage}
potential={potential}
territorialClass={territorialClass}
/>
)
}

View file

@ -440,4 +440,13 @@ body,
text-decoration: none;
color: inherit;
}
/* Badges carry meaning by fill (prioridade, tendência, classe territorial);
without this the printer drops the background and every badge prints as
identical grey text. */
[data-dossier] [data-slot='badge'] {
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
border: 1px solid rgb(0 0 0 / 20%);
}
}

View file

@ -1,9 +1,8 @@
import { MunicipalityHoverTooltip } from '@/components/campaign/municipality/MunicipalityHoverTooltip'
import { formatElectionNumber } from '@/lib/electionFormat'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { formatElectionNumber, formatVoteSharePercent } from '@/lib/electionFormat'
import {
DEFAULT_VOTE_RANK_YEAR,
formatMunicipalityVoteRank,
formatMunicipalityVoteShare,
getMunicipalityVoteRank,
} from '@/lib/municipalityVoteRank'
import { computeVoteTrend } from '@/lib/voteTrend'
@ -53,11 +52,11 @@ export const MunicipalityBaselineCard = ({
className="flex flex-col gap-4 rounded-xl border p-4"
>
<div className="flex flex-col gap-1">
<MunicipalityHoverTooltip content={concentrationHint}>
<CampaignHoverTooltip content={concentrationHint}>
<h2 id="municipality-baseline-title" className="w-fit text-base font-medium">
Votação de {baseline.candidateName} ({baseline.candidateParty})
</h2>
</MunicipalityHoverTooltip>
</CampaignHoverTooltip>
<p className="text-sm text-muted-foreground">
Votos nominais para deputado federal (1º turno) na geografia deste município — fonte TSE.
</p>
@ -82,9 +81,9 @@ export const MunicipalityBaselineCard = ({
{position ? (
<dd
className="mt-1 flex flex-col gap-0.5 text-xs tabular-nums text-muted-foreground"
aria-label={`${formatMunicipalityVoteShare(position.share)} da votação estadual, ${formatMunicipalityVoteRank(position.rank)} de ${formatElectionNumber(position.totalUnits)}`}
aria-label={`${formatVoteSharePercent(position.share)} da votação estadual, ${formatMunicipalityVoteRank(position.rank)} de ${formatElectionNumber(position.totalUnits)}`}
>
<span>{formatMunicipalityVoteShare(position.share)}</span>
<span>{formatVoteSharePercent(position.share)}</span>
<span>{formatMunicipalityVoteRank(position.rank)}</span>
</dd>
) : (
@ -110,8 +109,8 @@ export const MunicipalityBaselineCard = ({
{latest && baseline.tally2022.votosValidos > 0 ? (
<p>
Dominância local:{' '}
{formatMunicipalityVoteShare(latest.votes / baseline.tally2022.votosValidos)} dos
válidos neste município (distinto da concentração acima).
{formatVoteSharePercent(latest.votes / baseline.tally2022.votosValidos)} dos válidos
neste município (distinto da concentração acima).
</p>
) : null}
</div>

View file

@ -19,10 +19,13 @@ import {
import type { MunicipalityDossierData } from '@/utilities/municipalityDossierData'
import {
formatMunicipalityGeographyLabel,
formatTerritorialClassWhy,
municipalityKindLabels,
municipalityPriorityLabels,
politicalTrendBadgeVariant,
politicalTrendLabels,
territorialClassBadgeVariant,
territorialClassLabels,
} from '@/utilities/municipalityLabels'
import type {
MunicipalityAdvisorSummary,
@ -104,6 +107,10 @@ export const MunicipalityDossier = ({
const strategy = view.strategy
const trendStatus = strategy?.politicalTrend.status ?? null
const goalAccount = data.goalAccount
const territorialClass = goalAccount?.territorialClass ?? null
const territorialClassWhy = territorialClass
? formatTerritorialClassWhy(territorialClass.factors)
: null
const usesMesaEstimate = strategy?.expectedVotes.central != null
return (
@ -138,7 +145,19 @@ export const MunicipalityDossier = ({
) : (
<Badge variant="outline">Tendência não registrada</Badge>
)}
{territorialClass ? (
// Self-labeling like its two neighbors: on paper "Reduto" alone,
// between "Prioridade" and "Tendência", reads as a fourth noun.
<Badge variant={territorialClassBadgeVariant[territorialClass.class]}>
Classe {territorialClassLabels[territorialClass.class].toLowerCase()}
</Badge>
) : null}
</div>
{/* The class never travels alone on paper either — printed, nobody can
hover the tooltip that carries the "por quê" on screen. */}
{territorialClassWhy ? (
<p className="text-xs text-muted-foreground">{territorialClassWhy}</p>
) : null}
<p className="text-sm text-muted-foreground">
{advisors.length
? `Assessoria: ${advisors.map((advisor) => advisor.name).join(', ')}`

View file

@ -275,6 +275,24 @@ export const MunicipalityFilters = ({
}
/>
) : null}
{showStaffFilters ? (
<CampaignMobileMultiFilterField
id="municipality-filter-class"
label="Classe"
emptyLabel="Todas"
options={getMunicipalityFilterDefinition('class').options ?? []}
selected={state.classes ?? []}
onToggle={(value) =>
navigateTo(
toggleMunicipalityMultiFilterValue(
{ ...state, q: normalizedText(search) },
'class',
value,
),
)
}
/>
) : null}
{showStaffFilters && advisorFilterOptions.length ? (
<CampaignMobileMultiFilterField
id="municipality-filter-advisor"

View file

@ -1,9 +1,10 @@
import Link from 'next/link'
import type { ReactNode } from 'react'
import { MunicipalityHoverTooltip } from '@/components/campaign/municipality/MunicipalityHoverTooltip'
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { CampaignInfoHint } from '@/components/campaign/shared/CampaignInfoHint'
import { Badge } from '@/components/ui/Badge'
import { Progress } from '@/components/ui/Progress'
import {
CAMPAIGN_CONCEPTS_PATH,
@ -19,7 +20,13 @@ import {
goalCoverageProgressPercent,
type MunicipalityGoalCoverage,
} from '@/utilities/goalCoverage'
import {
formatTerritorialClassWhy,
territorialClassBadgeVariant,
territorialClassLabels,
} from '@/utilities/municipalityLabels'
import type { MunicipalityPotential, RollOff } from '@/utilities/municipalityPotential'
import type { MunicipalityTerritorialClassification } from '@/utilities/municipalityTerritorialClass'
import type { MunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews'
/**
@ -29,7 +36,7 @@ import type { MunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews
* 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
* seek out. Reuses `CampaignHoverTooltip` (same shadcn Tooltip stack
* wired for baseline/sortable-header hints), which handles the tap-to-open
* itself.
*
@ -50,7 +57,7 @@ const GoalAccountMetric = ({
value: string
explanation: ReactNode
}) => (
<MunicipalityHoverTooltip content={explanation} side="right" align="start" sideOffset={8}>
<CampaignHoverTooltip content={explanation} side="right" align="start" sideOffset={8}>
<button
type="button"
aria-label={`${label}: mais informações`}
@ -61,7 +68,7 @@ const GoalAccountMetric = ({
</dt>
<dd className="tabular-nums">{value}</dd>
</button>
</MunicipalityHoverTooltip>
</CampaignHoverTooltip>
)
/**
@ -100,6 +107,57 @@ const MetricExplanation = ({
</div>
)
/**
* E10 — the class is the one-word reading of the four diagnostics right
* below it, so it opens that block instead of getting a card of its own.
* Never the label alone: the two dominant factors ride with it (research
* §6.4 — a bare label reads as a verdict and buys false confidence).
*/
const TerritorialClassRow = ({
territorialClass,
}: {
territorialClass: MunicipalityTerritorialClassification
}) => {
const why = formatTerritorialClassWhy(territorialClass.factors)
return (
<div className="flex flex-col gap-0.5">
<CampaignHoverTooltip
content={
<MetricExplanation
lead={
<>
<strong>Classe</strong> resume o que este município pede: defender (Reduto), abrir
rede (Expansão), manter o padrão (Manutenção) ou não gastar perna (Marginal). Sem
série do TSE, a classe fica em Sem base.
</>
}
formula="Leitura relativa: o desempenho aqui contra o padrão estadual do próprio candidato, cruzado com quanto da votação dele vem daqui e quanto voto do campo segue sem captura. É sugestão, não sentença."
conceptID="classe-territorial"
/>
}
side="right"
align="start"
sideOffset={8}
>
<button
type="button"
aria-label={`Classe: ${territorialClassLabels[territorialClass.class]}. Mais informações`}
className="flex min-h-11 w-fit items-center gap-2 rounded-md px-1.5 py-1 text-left outline-none hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="text-xs text-muted-foreground underline decoration-dotted decoration-muted-foreground/70 underline-offset-2">
Classe
</span>
<Badge variant={territorialClassBadgeVariant[territorialClass.class]}>
{territorialClassLabels[territorialClass.class]}
</Badge>
</button>
</CampaignHoverTooltip>
<p className="px-1.5 text-xs text-muted-foreground">{why}</p>
</div>
)
}
const formatRollOff = (rollOff: RollOff | null): string => {
if (!rollOff) return 'Sem majoritária 2022 disponível'
const votesLabel = formatElectionNumber(rollOff.votes)
@ -123,6 +181,7 @@ export const MunicipalityGoalAccountCard = ({
suggestedGoal,
goalCoverage,
potential,
territorialClass,
}: {
municipalityID: number
expectedVotes: VoteEstimateScenarioViewModel
@ -130,6 +189,7 @@ export const MunicipalityGoalAccountCard = ({
suggestedGoal: number
goalCoverage: MunicipalityGoalCoverage
potential: MunicipalityPotential
territorialClass: MunicipalityTerritorialClassification
}) => {
const usesMesaEstimate = expectedVotes.central != null
const latestIntraFieldShareYear = Math.max(
@ -211,72 +271,75 @@ export const MunicipalityGoalAccountCard = ({
</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."
conceptID="teto-do-campo"
/>
}
/>
<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."
conceptID="captura"
/>
}
/>
<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."
conceptID="share-intracampo"
/>
}
/>
<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)."
conceptID="roll-off"
/>
}
/>
</dl>
<div className="flex flex-col gap-3 border-t pt-3">
<TerritorialClassRow territorialClass={territorialClass} />
<dl className="grid grid-cols-2 gap-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."
conceptID="teto-do-campo"
/>
}
/>
<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."
conceptID="captura"
/>
}
/>
<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."
conceptID="share-intracampo"
/>
}
/>
<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)."
conceptID="roll-off"
/>
}
/>
</dl>
</div>
{!usesMesaEstimate && suggestedGoal <= 0 ? (
<p className="text-xs text-muted-foreground">
Sem votação própria em 2022 aqui, então não há meta sugerida — é município de expansão:

View file

@ -46,6 +46,7 @@ const multiParamByFilter = {
region: 'region',
advisor: 'advisor',
trend: 'trend',
class: 'class',
kind: null,
coverage: null,
} as const satisfies Record<MunicipalityFilterParam, MunicipalityMultiFilterParam | null>

View file

@ -23,16 +23,19 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { formatElectionNumber } from '@/lib/electionFormat'
import { formatMunicipalityVoteRank, formatMunicipalityVoteShare } from '@/lib/municipalityVoteRank'
import { formatElectionNumber, formatVoteSharePercent } from '@/lib/electionFormat'
import { formatMunicipalityVoteRank } from '@/lib/municipalityVoteRank'
import { cn } from '@/lib/utils'
import { DEFAULT_VOTE_ESTIMATE_SCENARIO, voteEstimateScenarioLabels } from '@/lib/voteEstimate'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import {
formatMunicipalityConcentrationHint,
formatMunicipalityGeographyLabel,
formatTerritorialClassWhy,
municipalityKindLabels,
municipalityPriorityLabels,
territorialClassBadgeVariant,
territorialClassLabels,
} from '@/utilities/municipalityLabels'
import {
clearMunicipalityListFilters,
@ -91,7 +94,7 @@ const VotePositionReadout = ({
position: NonNullable<MunicipalityListViewModel['votePosition2022']>
layout: 'table' | 'card'
}) => {
const share = formatMunicipalityVoteShare(position.share)
const share = formatVoteSharePercent(position.share)
const rank = formatMunicipalityVoteRank(position.rank)
const metaLine = `${formatElectionNumber(position.votes)} · ${rank}`
const ariaLabel = `${share} da votação estadual, ${formatElectionNumber(position.votes)} votos, ${rank} de ${formatElectionNumber(position.totalUnits)}`
@ -110,6 +113,49 @@ const VotePositionReadout = ({
)
}
const CLASS_COLUMN_HINT =
'Leitura relativa de 2022: o desempenho aqui contra o padrão estadual do próprio candidato. Reduto (bem acima do padrão), Expansão (abaixo, mas com campo a ocupar), Manutenção (no padrão), Marginal (abaixo, com pouco campo) e — sem série do TSE. É sugestão de leitura, não decisão: cada classe traz o porquê ao passar o mouse ou tocar nela.'
/**
* E10 classe. In the table only the pill is visible — spelling the factors out
* under it made this the widest cell in a grid that cannot scroll
* horizontally, so the "por quê" moves to the column's `cellTooltip` (hover,
* focus and tap) and stays as `sr-only` text; the card has the width to show
* it outright. Either way the class never reaches anyone as a bare verdict,
* which is the one thing it must never be.
*/
const TerritorialClassReadout = ({
municipality,
layout,
}: {
municipality: MunicipalityListViewModel
layout: 'table' | 'card'
}) => {
const isTable = layout === 'table'
const why = formatTerritorialClassWhy(municipality.territorialClassFactors)
if (municipality.territorialClass === 'sem_base') {
// Same idiom as the "2022" column: absent data is a dash, not a pill.
return isTable ? (
<span className="text-muted-foreground">
<span aria-hidden="true">—</span>
<span className="sr-only">{why}</span>
</span>
) : (
<span className="text-xs text-muted-foreground">{why}</span>
)
}
return (
<>
<Badge variant={territorialClassBadgeVariant[municipality.territorialClass]}>
{territorialClassLabels[municipality.territorialClass]}
</Badge>
<span className={isTable ? 'sr-only' : 'text-xs text-muted-foreground'}>{why}</span>
</>
)
}
/**
* E9 frescor: how long since anybody recorded anything here (staff update or
* leadership pledge).
@ -291,6 +337,30 @@ const municipalityListColumns = ({
},
...(isStaffView
? ([
{
// Sits right after "2022": both read the same TSE artifact, and the
// class is the one-word summary of the votes beside it.
id: 'classe',
head: (
<MunicipalitySortableHead
state={state}
sortKey="classe"
filterParam="class"
tooltip={CLASS_COLUMN_HINT}
>
Classe
</MunicipalitySortableHead>
),
cell: (municipality) => (
<TerritorialClassReadout municipality={municipality} layout="table" />
),
// The factors behind the label — the cell keeps them as `sr-only`
// text, so this stays a redundant affordance (see `cellTooltip`).
cellTooltip: (municipality) =>
municipality.territorialClass === 'sem_base'
? null
: formatTerritorialClassWhy(municipality.territorialClassFactors),
},
{
id: 'advisors',
head: (
@ -424,8 +494,12 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
// The scenario picker is client state, so the server can only order by one
// scenario — named here so the ordering never looks arbitrary.
const deficitHint = `Ordena pelo que falta para a meta (meta − comprometido) no cenário ${voteEstimateScenarioLabels[DEFAULT_VOTE_ESTIMATE_SCENARIO]}, independente do cenário selecionado acima.`
const columns = municipalityListColumns({ ...props, concentrationHint, signalHint, deficitHint })
const columns = municipalityListColumns({
...props,
concentrationHint,
signalHint,
deficitHint,
})
return (
<>
@ -455,6 +529,12 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
</div>
{isStaffView ? (
<dl className="grid grid-cols-2 gap-2 text-sm">
<div className="col-span-2">
<dt className="text-muted-foreground">Classe</dt>
<dd className="flex flex-wrap items-center gap-2">
<TerritorialClassReadout municipality={municipality} layout="card" />
</dd>
</div>
<div>
<dt className="text-muted-foreground">Votos estimados</dt>
<dd className="flex justify-center">

View file

@ -3,7 +3,7 @@
import type { ReactNode } from 'react'
import { MunicipalityHeaderFilter } from '@/components/campaign/municipality/MunicipalityHeaderFilter'
import { MunicipalityHoverTooltip } from '@/components/campaign/municipality/MunicipalityHoverTooltip'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { CampaignSortableHead } from '@/components/campaign/shared/CampaignSortableHead'
import type {
MunicipalityFilterOption,
@ -73,12 +73,12 @@ export const MunicipalitySortableHead = ({
wrapSortControl={
tooltip
? (control) => (
<MunicipalityHoverTooltip
<CampaignHoverTooltip
content={tooltip}
align={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
>
{control}
</MunicipalityHoverTooltip>
</CampaignHoverTooltip>
)
: undefined
}

View file

@ -0,0 +1,31 @@
'use client'
import type { ReactNode } from 'react'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
/**
* Cell-level tooltip for the campaign list system — the render half of
* `CampaignTableColumn.cellTooltip`. Columns declare the extra reading; the
* table wraps the cell in this.
*
* The trigger `<span>` is created HERE rather than by the column, so a column
* declares only the data (`cellTooltip`) and the table owns the affordance —
* every cell tooltip in the system then behaves and is sized identically.
*
* It deliberately takes no `tabIndex` — 435 rows × N columns of dead tab stops
* would be a worse trade than the redundancy contract documented on
* `cellTooltip`, which requires the tooltip's information to already exist as
* text in the cell (visible or `sr-only`).
*/
export const CampaignCellTooltip = ({
content,
children,
}: {
content: ReactNode
children: ReactNode
}) => (
<CampaignHoverTooltip content={content} side="bottom" align="start">
<span className="inline-flex max-w-full">{children}</span>
</CampaignHoverTooltip>
)

View file

@ -6,11 +6,13 @@ import { cloneElement, useEffect, useRef, useState } from 'react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
/**
* 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
* Hover/focus explanation with no extra chrome — wraps an existing control,
* heading or table cell. Shared by the campaign list system: column headers
* (`MunicipalitySortableHead`), card metrics, and cell content via
* `CampaignTable`'s `cellTooltip`. 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
@ -29,7 +31,7 @@ type TappableElement = ReactElement<{
ref?: Ref<HTMLElement>
}>
export const MunicipalityHoverTooltip = ({
export const CampaignHoverTooltip = ({
content,
children,
side = 'bottom',

View file

@ -1,5 +1,6 @@
import { Fragment, type ReactNode } from 'react'
import { CampaignCellTooltip } from '@/components/campaign/shared/CampaignCellTooltip'
import {
Table,
TableBody,
@ -27,6 +28,18 @@ export type CampaignTableColumn<Row> = {
*/
head: ReactNode
cell: (row: Row) => ReactNode
/**
* Extra reading for the cell content, shown on hover/focus/tap. Return
* `null` for a row and that row gets no wrapper at all — a column whose
* detail only exists on some rows (a trend with no justification) stays
* exactly as it renders today on the others.
*
* Accessibility contract: the tooltip is a REDUNDANT affordance, never the
* only path to the information. Only declare it on a column whose cell
* already carries that content as text — visible, or `sr-only` when the
* visible form is a badge/avatar. The table adds no tab stops for it.
*/
cellTooltip?: (row: Row) => ReactNode
cellClassName?: string | ((row: Row) => string | undefined)
/** B17 seams — a mandatory column is not hideable; hidden-by-default starts unchecked. */
mandatory?: boolean
@ -106,18 +119,27 @@ export const CampaignTable = <Row,>({
key={rowKey(row)}
className={typeof rowClassName === 'function' ? rowClassName(row) : rowClassName}
>
{columns.map((column) => (
<TableCell
key={column.id}
className={
typeof column.cellClassName === 'function'
? column.cellClassName(row)
: column.cellClassName
}
>
{column.cell(row)}
</TableCell>
))}
{columns.map((column) => {
const cell = column.cell(row)
const tooltip = column.cellTooltip?.(row)
return (
<TableCell
key={column.id}
className={
typeof column.cellClassName === 'function'
? column.cellClassName(row)
: column.cellClassName
}
>
{tooltip ? (
<CampaignCellTooltip content={tooltip}>{cell}</CampaignCellTooltip>
) : (
cell
)}
</TableCell>
)
})}
</TableRow>
))}
</TableBody>

View file

@ -78,3 +78,33 @@ const EMPTY_BASELINE: MunicipalityFederalBaseline = {
*/
export const getMunicipalityFederalBaseline = (slug: string): MunicipalityFederalBaseline =>
artifact.municipalities[slug] ?? EMPTY_BASELINE
/** Statewide sums of the artifact for one year — the denominators of every relative reading. */
type StatewideFederalTotals = {
/** Candidate's own nominal votes across every catalog municipality. */
ownVotes: number
/** Federal T1 valid votes across every catalog municipality. */
validVotes: number
}
const statewideTotalsCache = new Map<number, StatewideFederalTotals>()
/**
* The artifact stores no statewide rollup, so every relative metric (A11's
* share of own vote, E10's LQ) needs the same sums over the 435 slugs. Cached
* at module scope — the artifact is immutable for the process lifetime.
*/
export const getStatewideFederalTotals = (year: number): StatewideFederalTotals => {
const cached = statewideTotalsCache.get(year)
if (cached) return cached
const yearKey = String(year)
const totals: StatewideFederalTotals = { ownVotes: 0, validVotes: 0 }
for (const baseline of Object.values(artifact.municipalities)) {
totals.ownVotes += baseline.votesByYear[yearKey] ?? 0
totals.validVotes += baseline.validVotesByYear[yearKey] ?? 0
}
statewideTotalsCache.set(year, totals)
return totals
}

View file

@ -9,10 +9,10 @@
* would let the copy drift silently. Every entry must describe what the code
* actually computes; when a formula changes, this text changes with it.
*
* v1 covers the E8 "conta da cadeira" numbers. Each later slice of the
* intelligence program (E9 fila, E10 classificação relativa, B13 escala do
* mapa, E11 sugestões, E12 TI, E13 giros, E14 níveis) appends its own entries
* as part of its own delivery.
* v1 covers the E8 "conta da cadeira" numbers; E10 added dominância relativa
* and a classe territorial. Each later slice of the intelligence program (B13
* escala do mapa, E11 sugestões, E12 TI, E13 giros, E14 níveis) appends its
* own entries as part of its own delivery.
*/
export type CampaignConceptId =
@ -21,6 +21,8 @@ export type CampaignConceptId =
| 'captura'
| 'share-intracampo'
| 'roll-off'
| 'dominancia-relativa'
| 'classe-territorial'
| 'meta'
| 'cobertura-da-meta'
@ -115,6 +117,35 @@ export const campaignIntelligenceConcepts: ReadonlyArray<CampaignIntelligenceCon
'Mede quanto eleitor comparece, vota para presidente e desiste de escolher deputado. Roll-off alto indica eleitorado disponível para quem chegar com nome e razão para votar — mas é diagnóstico de contexto, não um reservatório de votos garantidos. Também usa só 2022.',
whereItAppears: 'Card "Conta da cadeira", no detalhe do município ("Roll-off (2022)").',
},
{
id: 'dominancia-relativa',
categoryID: 'diagnostico',
title: 'Dominância relativa',
oneLiner:
'Quanto o desempenho do Jorge Solla no município se afasta do padrão dele no estado inteiro.',
formula:
'dominância = (votos de Solla ÷ votos válidos do município, em 2022) ÷ (votos de Solla ÷ votos válidos da Bahia, em 2022). Acima de 1 é acima do padrão dele; abaixo de 1 é abaixo.',
example:
'Um município onde ele fez 4% dos válidos, quando o padrão estadual dele é 2%, tem dominância 2 — "aqui rendemos o dobro do nosso normal". Onde ele fez 1,2%, a dominância é 0,6: "aqui rendemos 40% abaixo do nosso padrão".',
whyItMatters:
'Em deputado federal, o percentual absoluto engana: 3% dos válidos parece pouco e pode ser o melhor município da campanha. A comparação útil é com o próprio padrão, não com 50% + 1 — que é conta de eleição majoritária. É a leitura que separa "pequeno" de "fraco".',
whereItAppears:
'Como o "por quê" da classe, no card "Conta da cadeira", na capa do dossiê e na coluna "Classe" da lista de municípios.',
},
{
id: 'classe-territorial',
categoryID: 'diagnostico',
title: 'Classe do município',
oneLiner: 'Uma palavra para o que o município pede: Reduto, Expansão, Manutenção ou Marginal.',
formula:
'Reduto = dominância 2 ou mais. Marginal = dominância abaixo de 0,5 com pouco voto do campo sem captura (abaixo da mediana dos 435 municípios). Expansão = dominância abaixo de 0,5 mas com campo grande sem captura, ou município do bloco que concentra metade da votação dele. Manutenção = o meio, entre 0,5 e 2. Sem base = município sem série do TSE.',
example:
'Dominância 0,3 com 9.000 votos do campo sem captura é Expansão: o campo está lá e a campanha não chegou. A mesma dominância com 300 votos do campo disponíveis é Marginal — não compensa a perna.',
whyItMatters:
'Responde onde a perna vai na semana: defender reduto dormente ou abrir rede em expansão. A classe é sugestão, não sentença — ela nunca aparece sozinha, sempre com os dois fatores que a produziram, e a mesa pode decidir contra ela. Os cortes exatos (2 e 0,5) são ilustrativos: valem até o backtest contra 2014–2022 calibrá-los.',
whereItAppears:
'Card "Conta da cadeira" e capa do dossiê, no detalhe do município, e a coluna "Classe" da lista de municípios (com filtro e ordenação).',
},
{
id: 'meta',
categoryID: 'meta',

View file

@ -6,4 +6,19 @@
*/
const numberFormatter = new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 0 })
/** Shares and multiples: one decimal at most, never a forced trailing zero. */
export const oneDecimalFormatter = new Intl.NumberFormat('pt-BR', {
maximumFractionDigits: 1,
minimumFractionDigits: 0,
})
export const formatElectionNumber = (value: number): string => numberFormatter.format(value)
/**
* A ratio (0..1) as a share label with at most one decimal — the shape used
* for "% da própria votação estadual". Lives here, not next to the ranking it
* was written for, so client bundles can format a share without pulling the
* committed TSE artifact along with it.
*/
export const formatVoteSharePercent = (share: number): string =>
`${oneDecimalFormatter.format(share * 100)}%`

View file

@ -1,6 +1,7 @@
import {
federalBaselineMunicipalitySlugs,
getMunicipalityFederalBaseline,
getStatewideFederalTotals,
} from '@/lib/bahiaElectionAggregates'
import { formatElectionNumber } from '@/lib/electionFormat'
@ -37,8 +38,7 @@ export const computeVoteRankByYear = (
votes: getMunicipalityFederalBaseline(slug).votesByYear[yearKey] ?? 0,
}))
let candidateTotal = 0
for (const row of rows) candidateTotal += row.votes
const candidateTotal = getStatewideFederalTotals(year).ownVotes
rows.sort((a, b) => {
if (b.votes !== a.votes) return b.votes - a.votes
@ -74,12 +74,6 @@ export const getMunicipalityVoteRank = (
export const formatMunicipalityVoteRank = (rank: number): string => `${formatElectionNumber(rank)}º`
export const formatMunicipalityVoteShare = (share: number): string =>
`${(share * 100).toLocaleString('pt-BR', {
maximumFractionDigits: 1,
minimumFractionDigits: 0,
})}%`
/** Compare for list sort=votos. Zero-vote rows always sort last (any dir). */
export const compareMunicipalityVotesForSort = (
aVotes: number,

View file

@ -15,6 +15,21 @@ export const allParamValues = (value: string | string[] | undefined): string[] =
return [...seen]
}
/**
* A repeatable enum param where selecting EVERY member means "todas" — which
* is the same filter as selecting none, and canonicalizes to the absent param
* so both produce one URL (B18's frozen contract). `allParamValues` already
* dropped duplicates and unknown tokens are ignored, so a hand-typed URL can
* never widen the filter.
*/
export const parseExhaustiveEnumParam = <T extends string>(
raw: string | string[] | undefined,
allowed: ReadonlySet<string>,
): T[] => {
const values = allParamValues(raw).filter((token): token is T => allowed.has(token))
return values.length < allowed.size ? values : []
}
export const normalizedText = (value: string | undefined): string | undefined => {
if (typeof value !== 'string') return undefined
const normalized = value.trim()

View file

@ -25,6 +25,10 @@ import {
type MunicipalityPotential,
type SuggestedGoalByScenario,
} from '@/utilities/municipalityPotential'
import {
computeMunicipalityTerritorialClass,
type MunicipalityTerritorialClassification,
} from '@/utilities/municipalityTerritorialClass'
import {
emptyMunicipalityPledgeAggregate,
type MunicipalityPledgeAggregate,
@ -111,6 +115,8 @@ export type MunicipalityGoalAccount = {
/** Fixed to the `central` scenario — the detail page has no scenario selector. */
goalCoverage: MunicipalityGoalCoverage
potential: MunicipalityPotential
/** E10 — the one-word reading of `potential` plus the factors behind it. */
territorialClass: MunicipalityTerritorialClassification
}
/**
@ -140,5 +146,6 @@ export const loadMunicipalityGoalAccount = async (
suggestedGoal: suggestedGoalByScenario[DEFAULT_VOTE_ESTIMATE_SCENARIO],
goalCoverage,
potential,
territorialClass: computeMunicipalityTerritorialClass(municipality.slug),
}
}

View file

@ -1,11 +1,22 @@
/**
* Municipality display labels shared by list, detail, dossier and map
* surfaces. Client-safe: types + string tables only (split out of the former
* `municipalityUi.ts` in Pass 2 W1).
* surfaces. Client-safe: string tables plus the pure formatters that turn a
* derived value into its pt-BR copy — no Payload, no DB, and no value import
* from a `server-only` module (split out of the former `municipalityUi.ts` in
* Pass 2 W1).
*/
import { formatElectionNumber } from '@/lib/electionFormat'
import {
formatElectionNumber,
formatVoteSharePercent,
oneDecimalFormatter,
} from '@/lib/electionFormat'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import type { CampaignUser, Municipality } from '@/payload-types'
import { formatRatioAsPercentLabel } from '@/utilities/goalCoverage'
import type {
MunicipalityTerritorialClass,
TerritorialFactor,
} from '@/utilities/municipalityTerritorialClass'
export const municipalityKindLabels: Record<Municipality['kind'], string> = {
municipio: 'Município',
@ -38,6 +49,78 @@ export const municipalityListCoverageLabels: Record<'com_assessor' | 'sem_assess
sem_assessor: 'Sem assessor',
}
/** E10 — operational class of the município (vocabulary of the research report). */
export const territorialClassLabels: Record<MunicipalityTerritorialClass, string> = {
reduto: 'Reduto',
expansao: 'Expansão',
manutencao: 'Manutenção',
marginal: 'Marginal',
sem_base: 'Sem base',
}
/**
* Four fills that survive a 25-row scan, plus one that deliberately doesn't.
* Reduto reads as achievement (green, like a confirmed estimate); expansão —
* the class the allocation queue exists to send people to — takes the amber
* this app already spends on "needs attention" (cold signals, disputed
* support), never the destructive red the priority badge owns on the same row;
* manutenção is routine grey; marginal is a hairline pill.
*
* `sem_base` is absence of data, not a state, so it gets the flattest fill and
* reads differently by surface: the list renders the em dash the neighboring
* "2022" column already uses for "nothing here", while the detail card and the
* dossier keep the pill because there the class sits in a labeled field ("Classe
* …") where a dash would read as a rendering failure rather than as missing data.
*/
export const territorialClassBadgeVariant = {
reduto: 'estimate-confirmed',
expansao: 'estimate-pending',
manutencao: 'secondary',
marginal: 'outline',
sem_base: 'ghost',
} as const
/** Band where the multiple rounds to "1×", which says nothing — name it instead. */
const AT_STANDARD_LQ = { min: 0.95, max: 1.15 } as const
/**
* The "por quê" behind a class, in the mesa's own phrasing — "aqui a votação
* está 40% abaixo do padrão estadual" is the sentence the research report says
* survives the table; "LQ 0,6" is not. The possessive names the candidate
* ("do candidato"), never "seu", which an assessor reads as their own.
*/
const formatTerritorialClassFactor = (factor: TerritorialFactor): string => {
switch (factor.id) {
case 'dominance':
if (factor.value >= AT_STANDARD_LQ.min && factor.value < AT_STANDARD_LQ.max) {
return 'no padrão estadual do candidato'
}
return factor.value >= 1
? `${oneDecimalFormatter.format(factor.value)}× o padrão estadual do candidato`
: `${formatRatioAsPercentLabel(1 - factor.value)} abaixo do padrão estadual do candidato`
case 'ownShare':
return `${formatVoteSharePercent(factor.value)} da votação estadual do candidato`
case 'field':
return `${formatElectionNumber(Math.round(factor.value))} votos do campo sem captura`
case 'capture':
return `captura de ${formatRatioAsPercentLabel(factor.value)} do campo`
}
}
/** Copy for "no TSE series here" — the class label alone would read as a bug. */
const TERRITORIAL_CLASS_NO_DATA = 'Sem série do TSE para este município.'
/**
* The two dominant factors as one line — single source for the rule, which the
* list cell, the detail card and the printed dossier each used to carry. Falls
* back to the "no series" copy so a caller can never render a blank line where
* the "por quê" belongs.
*/
export const formatTerritorialClassWhy = (factors: readonly TerritorialFactor[]): string =>
factors.length === 0
? TERRITORIAL_CLASS_NO_DATA
: factors.slice(0, 2).map(formatTerritorialClassFactor).join(' · ')
export const formatMunicipalityConcentrationHint = (
totalUnits: number = municipalityCatalog.length,
): string =>

View file

@ -10,6 +10,7 @@ import {
municipalityListCoverageLabels,
municipalityPriorityLabels,
politicalTrendLabels,
territorialClassLabels,
type PoliticalTrendStatus,
} from '@/utilities/municipalityLabels'
import {
@ -19,9 +20,17 @@ import {
serializeCanonicalMunicipalityListSearchParams,
type MunicipalityListState,
} from '@/utilities/municipalityListUrl'
import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass'
/** Column-header filter affordances (B16+). `name` = Município (priority + slugs). */
export type MunicipalityFilterParam = 'name' | 'region' | 'kind' | 'coverage' | 'trend' | 'advisor'
export type MunicipalityFilterParam =
| 'name'
| 'region'
| 'kind'
| 'coverage'
| 'trend'
| 'class'
| 'advisor'
type MunicipalityFilterSelectionMode = 'single' | 'multi' | 'toggle'
@ -96,6 +105,18 @@ export const municipalityFilterDefinitions: MunicipalityFilterDefinition[] = [
(trend) => ({ value: trend, label: politicalTrendLabels[trend] }),
),
},
{
param: 'class',
label: 'Classe',
staffOnly: true,
selection: 'multi',
options: (
Object.keys(territorialClassLabels) as Array<keyof typeof territorialClassLabels>
).map((territorialClass) => ({
value: territorialClass,
label: territorialClassLabels[territorialClass],
})),
},
]
const municipalityFilterDefinitionByParam = Object.fromEntries(
@ -111,7 +132,7 @@ export const getMunicipalitySingleFilterValue = (
param: 'kind' | 'coverage',
): string | undefined => state[param]
export type MunicipalityMultiFilterParam = 'region' | 'slug' | 'advisor' | 'trend'
export type MunicipalityMultiFilterParam = 'region' | 'slug' | 'advisor' | 'trend' | 'class'
export const getMunicipalityMultiFilterValues = (
state: MunicipalityListState,
@ -120,6 +141,7 @@ export const getMunicipalityMultiFilterValues = (
if (param === 'region') return state.regions ?? []
if (param === 'slug') return state.slugs ?? []
if (param === 'trend') return state.trends ?? []
if (param === 'class') return state.classes ?? []
return (state.advisors ?? []).map(String)
}
@ -213,6 +235,11 @@ const trendStatusCount = Object.keys(politicalTrendLabels).length
const isPoliticalTrendStatus = (value: string): value is PoliticalTrendStatus =>
value in politicalTrendLabels
const territorialClassCount = Object.keys(territorialClassLabels).length
const isTerritorialClass = (value: string): value is MunicipalityTerritorialClass =>
value in territorialClassLabels
/**
* B16+ href fast path: option rows only need the WOULD-BE href, so the toggled
* state is derived with the same canonical rules the parser applies (advisors
@ -246,10 +273,14 @@ export const buildMunicipalityFilterOptionHref = (
.sort((left, right) => left - right)
if (advisors.length) next.advisors = advisors
else delete next.advisors
} else {
} else if (param === 'trend') {
const trends = toggled.filter(isPoliticalTrendStatus)
if (trends.length && trends.length < trendStatusCount) next.trends = trends
else delete next.trends
} else if (param === 'class') {
const classes = toggled.filter(isTerritorialClass)
if (classes.length && classes.length < territorialClassCount) next.classes = classes
else delete next.classes
}
const query = serializeCanonicalMunicipalityListSearchParams(next).toString()
@ -274,6 +305,8 @@ export const isMunicipalityColumnFilterActive = (
return Boolean(state.coverage)
case 'trend':
return Boolean(state.trends?.length)
case 'class':
return Boolean(state.classes?.length)
}
}
@ -302,6 +335,11 @@ export const formatMunicipalityActiveFiltersSummary = (
`Tendência ${state.trends.map((trend) => politicalTrendLabels[trend].toLowerCase()).join(', ')}`,
)
}
if (state.classes?.length) {
parts.push(
`Classe ${state.classes.map((territorialClass) => territorialClassLabels[territorialClass].toLowerCase()).join(', ')}`,
)
}
if (state.q) parts.push(`Busca "${state.q}"`)
return parts.length ? parts.join(' · ') : null

View file

@ -14,11 +14,17 @@ import {
buildListHref,
firstValue,
normalizedText,
parseExhaustiveEnumParam,
resolveListUrl,
strictDecimalInteger,
type RawSearchParams as CampaignListRawSearchParams,
} from '@/utilities/campaignListUrl'
import { politicalTrendLabels, type PoliticalTrendStatus } from '@/utilities/municipalityLabels'
import {
politicalTrendLabels,
territorialClassLabels,
type PoliticalTrendStatus,
} from '@/utilities/municipalityLabels'
import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass'
import type { Municipality } from '@/payload-types'
@ -35,6 +41,7 @@ export type MunicipalityListSortKey =
| 'votos'
| 'deficit'
| 'frescor'
| 'classe'
export type MunicipalityListSortDirection = 'asc' | 'desc'
@ -60,6 +67,7 @@ export const municipalityListSortLabels: Record<MunicipalityListSortKey, string>
votos: '2022',
deficit: 'Cobertura da meta',
frescor: 'Frescor do sinal',
classe: 'Classe',
}
export type MunicipalityListState = {
@ -79,6 +87,13 @@ export type MunicipalityListState = {
* encoded as absent, canonicalized by `parseMunicipalityListParams`.
*/
trends?: PoliticalTrendStatus[]
/**
* Multi-select (OR) E10 territorial classes. Derived from the committed TSE
* artifact, so — unlike every filter above — it is NOT part of
* `buildMunicipalityListWhere`: `municipalityPageData` applies it in memory.
* Never holds the full set (same "todas" canonicalization as `trends`).
*/
classes?: MunicipalityTerritorialClass[]
/** Candidate number for the map comparison mode (does not filter the list). */
compare?: number
sort?: MunicipalityListSortKey
@ -96,6 +111,7 @@ const municipalityListParamNames = [
'coverage',
'priority',
'trend',
'class',
'compare',
'sort',
'dir',
@ -117,6 +133,8 @@ const sortKeysWithDescDefault: MunicipalityListSortKey[] = [
// Both open on the worst case: biggest uncovered deficit, coldest signal.
'deficit',
'frescor',
// Ordinal, not alphabetical: descending means reduto first.
'classe',
]
export const defaultMunicipalityListSortDir = (
@ -151,6 +169,11 @@ export const formatMunicipalityListSortSummary = (
? 'Ordenado por frescor (sinal mais frio primeiro)'
: 'Ordenado por frescor (sinal mais recente primeiro)'
}
if (sort === 'classe') {
return dir === 'desc'
? 'Ordenado por classe (reduto primeiro)'
: 'Ordenado por classe (marginal primeiro)'
}
const label = municipalityListSortLabels[sort]
return dir === 'desc' ? `Ordenado por ${label} ↓` : `Ordenado por ${label} ↑`
}
@ -195,18 +218,7 @@ const parseAdvisorsParam = (raw: string | string[] | undefined): number[] => {
}
const politicalTrendStatusSet = new Set<string>(Object.keys(politicalTrendLabels))
/** Selecting every trend means "todas", which is the same thing as selecting none. */
const parseTrendsParam = (raw: string | string[] | undefined): PoliticalTrendStatus[] => {
const trends: PoliticalTrendStatus[] = []
for (const token of allParamValues(raw)) {
if (!politicalTrendStatusSet.has(token)) continue
const trend = token as PoliticalTrendStatus
if (trends.includes(trend)) continue
trends.push(trend)
}
return trends.length < politicalTrendStatusSet.size ? trends : []
}
const territorialClassSet = new Set<string>(Object.keys(territorialClassLabels))
export const municipalityListStateToRawParams = (
state: MunicipalityListState,
@ -221,6 +233,7 @@ export const municipalityListStateToRawParams = (
coverage: state.coverage,
priority: state.priority,
trend: state.trends,
class: state.classes,
compare: state.compare === undefined ? undefined : String(state.compare),
sort: state.sort,
dir: state.dir,
@ -237,7 +250,14 @@ export const parseMunicipalityListParams = (
const rawKind = firstValue(params.kind)
const rawCoverage = firstValue(params.coverage)
const rawPriority = firstValue(params.priority)
const trends = parseTrendsParam(params.trend)
const trends = parseExhaustiveEnumParam<PoliticalTrendStatus>(
params.trend,
politicalTrendStatusSet,
)
const classes = parseExhaustiveEnumParam<MunicipalityTerritorialClass>(
params.class,
territorialClassSet,
)
const rawCompare = strictDecimalInteger(firstValue(params.compare))
const rawSort = firstValue(params.sort) as MunicipalityListSortKey | undefined
const sort = rawSort && municipalityListSortKeySet.has(rawSort) ? rawSort : undefined
@ -256,6 +276,7 @@ export const parseMunicipalityListParams = (
: {}),
...(rawPriority === 'alta' ? { priority: 'alta' } : {}),
...(trends.length ? { trends } : {}),
...(classes.length ? { classes } : {}),
...(rawCompare && rawCompare <= 99999 ? { compare: rawCompare } : {}),
...(sort ? { sort } : {}),
...(dir ? { dir } : {}),
@ -284,6 +305,9 @@ export const buildMunicipalityListWhere = (state: MunicipalityListState): Where
}
if (state.priority) filters.push({ priority: { equals: state.priority } })
if (state.trends?.length) filters.push({ 'politicalTrend.status': { in: state.trends } })
// `state.classes` is absent on purpose: the class is derived from the TSE
// artifact, not stored, so it can't be a Payload constraint. `municipalityPageData`
// filters it in memory over the unpaginated scope.
return filters.length ? { and: filters } : {}
}
@ -312,6 +336,9 @@ export const serializeCanonicalMunicipalityListSearchParams = (
if (canonicalState.coverage) params.set('coverage', canonicalState.coverage)
if (canonicalState.priority) params.set('priority', canonicalState.priority)
for (const trend of canonicalState.trends ?? []) params.append('trend', trend)
for (const territorialClass of canonicalState.classes ?? []) {
params.append('class', territorialClass)
}
if (canonicalState.compare) params.set('compare', String(canonicalState.compare))
// Omit the default pair (staff: deficit+desc). Keep `sort` whenever the pair
// is non-default so `dir` is never orphaned (e.g. votos+asc → sort=votos&dir=asc).
@ -381,6 +408,9 @@ const formatMunicipalitySortOptionLabel = (
if (key === 'frescor') {
return dir === 'asc' ? `${base} (mais recente)` : `${base} (mais frio)`
}
if (key === 'classe') {
return dir === 'asc' ? `${base} (marginal primeiro)` : `${base} (reduto primeiro)`
}
return dir === 'asc' ? `${base} (A–Z)` : `${base} (Z–A)`
}

View file

@ -27,6 +27,10 @@ import {
type MunicipalityListState,
} from '@/utilities/municipalityListUrl'
import { resolveMunicipalityLastSignalAt } from '@/utilities/municipalitySignal'
import {
computeMunicipalityTerritorialClass,
territorialClassSortWeight,
} from '@/utilities/municipalityTerritorialClass'
import {
municipalityListSelect,
toMunicipalityDetailViewModel,
@ -91,6 +95,22 @@ const emptyMunicipalityListFilterFacets: MunicipalityListFilterFacets = {
type MunicipalityFacetRow = Pick<Municipality, 'slug' | 'region' | 'advisors'>
/**
* E10's class filter is the only one that isn't a Payload constraint (the
* class is derived from the committed TSE artifact, not stored), so every
* place that would otherwise trust `where` — the page query, the overview
* scope and the facets — has to apply this predicate itself. Returns `null`
* when no class is selected, which is also the signal to keep the cheap
* database-paginated path.
*/
const territorialClassFilterPredicate = (
state: MunicipalityListState,
): ((slug: string) => boolean) | null => {
if (!state.classes?.length) return null
const selected = new Set(state.classes)
return (slug) => selected.has(computeMunicipalityTerritorialClass(slug).class)
}
/**
* Each facet applies every filter owned by ANOTHER popover (so it only offers
* values that still return rows) while omitting the ones its own popover owns —
@ -106,9 +126,15 @@ const loadMunicipalityListFilterFacets = async (
/** The scope read is a PROMISE so the facets can join the page's main `Promise.all` (B16+). */
loadedScope: { where: Where; rows: Promise<MunicipalityFacetRow[]> } | null,
): Promise<MunicipalityListFilterFacets> => {
// No facet popover owns the class filter, so every facet read keeps it
// applied — inside the memo, so a `where` shared by two facets is filtered once.
const classMatches = territorialClassFilterPredicate(state)
const applyClassFilter = (rows: MunicipalityFacetRow[]): MunicipalityFacetRow[] =>
classMatches ? rows.filter((row) => classMatches(row.slug)) : rows
const rowsByWhere = new Map<string, Promise<MunicipalityFacetRow[]>>()
if (loadedScope) {
rowsByWhere.set(JSON.stringify(loadedScope.where), loadedScope.rows)
rowsByWhere.set(JSON.stringify(loadedScope.where), loadedScope.rows.then(applyClassFilter))
}
const facetRows = (omit: Partial<MunicipalityListState>): Promise<MunicipalityFacetRow[]> => {
const where = buildMunicipalityListWhere({ ...state, ...omit })
@ -127,7 +153,7 @@ const loadMunicipalityListFilterFacets = async (
user,
overrideAccess: false,
})
.then((result) => result.docs as MunicipalityFacetRow[])
.then((result) => applyClassFilter(result.docs as MunicipalityFacetRow[]))
rowsByWhere.set(key, rows)
return rows
}
@ -244,7 +270,18 @@ const applyDerivedMunicipalitySort = (
return now - new Date(lastSignalAt).getTime()
})
}
case 'classe':
// Ordinal weight, not the label: descending means reduto first, and
// `sem_base` (no weight) lands at the end in either direction.
return sortByNullableValue(
docs,
dir,
(municipality) =>
territorialClassSortWeight[computeMunicipalityTerritorialClass(municipality.slug).class],
)
default:
// Native keys reach here when a derived FILTER forced the in-memory
// path; the query already sorted them, so the order is preserved.
return docs
}
}
@ -272,7 +309,12 @@ export const loadMunicipalityListPageBundle = async (
const ranks = computeVoteRankByYear(DEFAULT_VOTE_RANK_YEAR)
const nativeSortField = payloadSortFieldByKey[sortKey]
const isNative = nativeSortField !== undefined
const classMatches = territorialClassFilterPredicate(state)
// A derived filter can't be expressed in `where`, so it forces the
// load-everything path even when the SORT itself is native — otherwise the
// page would drop rows out of a 25-row window and `totalDocs` would count
// municípios the filter excludes.
const isPagedByPayload = nativeSortField !== undefined && !classMatches
const listQuery = payload.find({
collection: 'municipality',
@ -281,11 +323,13 @@ export const loadMunicipalityListPageBundle = async (
select: municipalityListSelect,
user,
overrideAccess: false,
...(isNative
// Kept outside the pagination branch: with a class filter the query is
// still the cheapest place to order by a native field.
...(nativeSortField ? { sort: `${sortDir === 'desc' ? '-' : ''}${nativeSortField}` } : {}),
...(isPagedByPayload
? {
limit: municipalityPageSize,
page: state.page,
sort: `${sortDir === 'desc' ? '-' : ''}${nativeSortField}`,
}
: {
limit: 0,
@ -330,23 +374,29 @@ export const loadMunicipalityListPageBundle = async (
// (zeroed) next to the empty state instead of disappearing with the rows.
if (staffScope) {
pledgeAggregates = staffScope.pledgeAggregates
const rollup = rollupMunicipalityStaffVotes(staffScope.municipalities, pledgeAggregates)
// Never mutate the scope: `loadMunicipalityScope` is request-cached and
// shared with the facets. Filtering here is what keeps the overview
// (E8 coverage, E9 shame column) counting the filtered scope.
const scopedMunicipalities = classMatches
? staffScope.municipalities.filter((municipality) => classMatches(municipality.slug))
: staffScope.municipalities
const rollup = rollupMunicipalityStaffVotes(scopedMunicipalities, pledgeAggregates)
const goalCoverageBundle = await loadMunicipalityGoalCoverageBundle(
payload,
user,
staffScope.municipalities,
scopedMunicipalities,
pledgeAggregates,
)
goalCoverageByMunicipalityID = goalCoverageBundle.coverageByMunicipalityID
overview = {
municipalityCount: staffScope.municipalities.length,
municipalityCount: scopedMunicipalities.length,
staffVoteTotalByScenario: { ...rollup.staffVoteTotalByScenario },
pledgeCount: rollup.pledgeCount,
missingEstimateCount: rollup.missingEstimateCount,
withAdvisorCount: staffScope.municipalities.filter(
withAdvisorCount: scopedMunicipalities.filter(
(municipality) => (municipality.advisors ?? []).length > 0,
).length,
priorityWithoutAdvisorCount: staffScope.municipalities.filter(
priorityWithoutAdvisorCount: scopedMunicipalities.filter(
(municipality) =>
municipality.priority === 'alta' && (municipality.advisors ?? []).length === 0,
).length,
@ -358,22 +408,22 @@ export const loadMunicipalityListPageBundle = async (
let totalDocs: number
let totalPages: number
if (isNative) {
if (isPagedByPayload) {
// Payload select narrows the inferred type; the selected fields cover the view model.
pageDocs = listResult.docs as Municipality[]
totalDocs = listResult.totalDocs
totalPages = listResult.totalPages
} else {
const allDocs = applyDerivedMunicipalitySort(
listResult.docs as Municipality[],
sortKey,
sortDir,
{
ranks,
goalCoverageByMunicipalityID,
pledgeAggregates,
},
)
const scopedDocs = classMatches
? (listResult.docs as Municipality[]).filter((municipality) =>
classMatches(municipality.slug),
)
: (listResult.docs as Municipality[])
const allDocs = applyDerivedMunicipalitySort(scopedDocs, sortKey, sortDir, {
ranks,
goalCoverageByMunicipalityID,
pledgeAggregates,
})
totalDocs = allDocs.length
totalPages = Math.max(1, Math.ceil(totalDocs / municipalityPageSize))
const start = (state.page - 1) * municipalityPageSize

View file

@ -0,0 +1,234 @@
import {
federalBaselineMunicipalitySlugs,
getMunicipalityFederalBaseline,
getStatewideFederalTotals,
type MunicipalityFederalBaseline,
} from '@/lib/bahiaElectionAggregates'
import { ELECTION_YEAR_2022 } from '@/lib/electionResults'
import { computeVoteRankByYear } from '@/lib/municipalityVoteRank'
import { captureRate, ownVotes2022, projectedFieldCeiling } from '@/utilities/municipalityPotential'
/**
* E10 "classificação territorial relativa" — the operational class of one
* município, read from the committed `bahia-federal-baseline.json` artifact
* alone (pure functions, no DB, no `campaignGoals`, no user).
*
* Why relative anchors: the class the campaign inherited from the majoritarian
* era used absolute shares of local valid votes (35/20/10). In a federal-deputy
* race the candidate's local share is typically 1–5%, so every município in the
* state collapsed into the worst class. The research report (§6, "limiares
* 35/20/10 degeneram em DF") prescribes anchoring on multiples of the
* candidate's OWN statewide share (a location quotient) and reading more than
* one axis: dominance + how much of his own vote lives here + how much field
* vote is still uncaptured.
*/
/** Reference year: the field ceiling is 2022-only in the artifact by design. */
const TERRITORIAL_CLASS_YEAR = ELECTION_YEAR_2022
export const TERRITORIAL_CLASSES = [
'reduto',
'expansao',
'manutencao',
'marginal',
'sem_base',
] as const
export type MunicipalityTerritorialClass = (typeof TERRITORIAL_CLASSES)[number]
/**
* Cuts are ILLUSTRATIVE, not calibrated: the research report gives the shape
* (LQ > 2–3 = reduto, ~1 = padrão, < 0,5 = fraqueza) and says the exact
* numbers can only come from a backtest against 2014–2022 — that is **E15**.
* They live here, named and versioned, so recalibration is a one-line diff
* with a test to prove what moved.
*/
export const TERRITORIAL_CLASS_ANCHORS = {
/** LQ at or above this = performing at least 2× his own statewide standard. */
strongLq: 2,
/** LQ below this = performing at less than half his own standard. */
weakLq: 0.5,
/** Municípios that together hold this share of his statewide vote are the "core block". */
coreCumulativeShare: 0.5,
} as const
/** The axes behind a class — the UI always shows the "por quê", never the label alone. */
type TerritorialFactorId = 'dominance' | 'ownShare' | 'field' | 'capture'
export type TerritorialFactor = {
id: TerritorialFactorId
/**
* `dominance` = LQ (ratio); `ownShare` = share of his statewide vote (0..1);
* `field` = uncaptured field votes (absolute); `capture` = capture rate (0..1).
*/
value: number
}
export type MunicipalityTerritorialClassification = {
class: MunicipalityTerritorialClass
/** Ordered by relevance to the class — surfaces show the first two. */
factors: TerritorialFactor[]
/** Location quotient vs. his own statewide share; null without a local electorate. */
lq: number | null
/** Share of his statewide vote that comes from here (the mesa's own anchor, A11). */
ownShare: number
/** True when the município belongs to the block holding `coreCumulativeShare` of his vote. */
inCoreBlock: boolean
}
export type TerritorialClassInput = {
ownVotes: number
validVotes: number
stateOwnVotes: number
stateValidVotes: number
/** Projected field ceiling minus own votes (E8), floored at 0. */
fieldHeadroom: number
/** Median `fieldHeadroom` across the catalog — the relative "big field" cut. */
medianFieldHeadroom: number
captureRate: number | null
inCoreBlock: boolean
}
const UNCLASSIFIED: MunicipalityTerritorialClassification = {
class: 'sem_base',
factors: [],
lq: null,
ownShare: 0,
inCoreBlock: false,
}
/**
* Pure classifier over explicit inputs (the artifact-backed wrapper below
* fills them in). Order of the rules is the reading order of the mesa:
* dominance first, then — only for the weak half — whether there is field
* left to win.
*/
export const classifyMunicipalityTerritory = (
input: TerritorialClassInput,
): MunicipalityTerritorialClassification => {
const { ownVotes, validVotes, stateOwnVotes, stateValidVotes } = input
if (validVotes <= 0 || stateValidVotes <= 0 || stateOwnVotes <= 0) return UNCLASSIFIED
const stateShare = stateOwnVotes / stateValidVotes
const lq = ownVotes / validVotes / stateShare
const ownShare = ownVotes / stateOwnVotes
const shared = { lq, ownShare, inCoreBlock: input.inCoreBlock }
const dominance: TerritorialFactor = { id: 'dominance', value: lq }
const ownShareFactor: TerritorialFactor = { id: 'ownShare', value: ownShare }
const fieldFactor: TerritorialFactor = { id: 'field', value: input.fieldHeadroom }
const captureFactor: TerritorialFactor[] =
input.captureRate === null ? [] : [{ id: 'capture', value: input.captureRate }]
if (lq >= TERRITORIAL_CLASS_ANCHORS.strongLq) {
// Reduto de conta vs. reduto simbólico is the `ownShare` factor's job
// (P10: the trophy município must not eat the agenda of the ones that
// close the quotient), not a fifth class.
return { ...shared, class: 'reduto', factors: [dominance, ownShareFactor, ...captureFactor] }
}
if (lq < TERRITORIAL_CLASS_ANCHORS.weakLq) {
// A município inside the core block carries real votes even while
// under-performing, so it can never read as "marginal" — the honest label
// there is expansão (big base, low penetration).
const hasFieldLeft = input.fieldHeadroom >= input.medianFieldHeadroom || input.inCoreBlock
return hasFieldLeft
? { ...shared, class: 'expansao', factors: [fieldFactor, dominance, ...captureFactor] }
: { ...shared, class: 'marginal', factors: [dominance, fieldFactor] }
}
return { ...shared, class: 'manutencao', factors: [dominance, ownShareFactor, ...captureFactor] }
}
/** Uncaptured field votes: projected 2022 field ceiling minus his own vote. */
const fieldHeadroomOf = (baseline: MunicipalityFederalBaseline): number =>
Math.max(0, projectedFieldCeiling(baseline) - ownVotes2022(baseline))
const median = (values: number[]): number => {
if (values.length === 0) return 0
const sorted = [...values].sort((a, b) => a - b)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
}
type CatalogContext = {
medianFieldHeadroom: number
coreBlockSlugs: ReadonlySet<string>
}
let catalogContext: CatalogContext | null = null
/**
* Catalog-wide reference values, computed once per process (the artifact is
* immutable): the median field headroom — a relative cut, so no magic vote
* count ages — and the set of municípios that, sorted by his own vote,
* accumulate `coreCumulativeShare` of his statewide total.
*/
const getCatalogContext = (): CatalogContext => {
if (catalogContext) return catalogContext
// Slugs whose majoritarian cell is missing would drag the median to zero and
// make every weak município read as "expansão".
const medianFieldHeadroom = median(
federalBaselineMunicipalitySlugs()
.map(getMunicipalityFederalBaseline)
.filter((baseline) => projectedFieldCeiling(baseline) > 0)
.map(fieldHeadroomOf),
)
// A11 already ranks every slug by own vote, descending, and memoizes it —
// walking that map is the same order this used to re-sort for itself.
const coreVoteTarget =
getStatewideFederalTotals(TERRITORIAL_CLASS_YEAR).ownVotes *
TERRITORIAL_CLASS_ANCHORS.coreCumulativeShare
const coreBlockSlugs = new Set<string>()
let accumulated = 0
for (const [slug, entry] of computeVoteRankByYear(TERRITORIAL_CLASS_YEAR)) {
if (accumulated >= coreVoteTarget) break
coreBlockSlugs.add(slug)
accumulated += entry.votes
}
catalogContext = { medianFieldHeadroom, coreBlockSlugs }
return catalogContext
}
const classificationBySlug = new Map<string, MunicipalityTerritorialClassification>()
/** Classification for one catalog slug — memoized, pure over the artifact. */
export const computeMunicipalityTerritorialClass = (
slug: string,
): MunicipalityTerritorialClassification => {
const cached = classificationBySlug.get(slug)
if (cached) return cached
const baseline = getMunicipalityFederalBaseline(slug)
const totals = getStatewideFederalTotals(TERRITORIAL_CLASS_YEAR)
const { medianFieldHeadroom, coreBlockSlugs } = getCatalogContext()
const classification = classifyMunicipalityTerritory({
ownVotes: ownVotes2022(baseline),
validVotes: baseline.validVotesByYear[String(TERRITORIAL_CLASS_YEAR)] ?? 0,
stateOwnVotes: totals.ownVotes,
stateValidVotes: totals.validVotes,
fieldHeadroom: fieldHeadroomOf(baseline),
medianFieldHeadroom,
captureRate: captureRate(baseline),
inCoreBlock: coreBlockSlugs.has(slug),
})
classificationBySlug.set(slug, classification)
return classification
}
/**
* Sort weight for the list's `classe` ordering: descending puts the
* municípios that carry the campaign (reduto) first and the ones without a
* baseline last, in either direction.
*/
export const territorialClassSortWeight: Record<MunicipalityTerritorialClass, number | null> = {
reduto: 4,
expansao: 3,
manutencao: 2,
marginal: 1,
sem_base: null,
}

View file

@ -16,6 +16,11 @@ import {
} from '@/utilities/goalCoverage'
import { type PoliticalTrendStatus } from '@/utilities/municipalityLabels'
import { resolveMunicipalityLastSignalAt } from '@/utilities/municipalitySignal'
import {
computeMunicipalityTerritorialClass,
type MunicipalityTerritorialClass,
type TerritorialFactor,
} from '@/utilities/municipalityTerritorialClass'
import { relationshipId } from '@/utilities/relationship'
import type { StateDeputySummary } from '@/utilities/stateDeputyData'
import type { MunicipalityPledgeAggregate } from '@/utilities/votePledgeViews'
@ -69,6 +74,10 @@ export type MunicipalityListViewModel = {
politicalTrendNote: string | null
pledges: MunicipalityPledgeAggregate
votePosition2022: MunicipalityVoteRankEntry | null
/** E10 — classe operacional derivada do artefato TSE. */
territorialClass: MunicipalityTerritorialClass
/** Os fatores que produziram a classe: a célula nunca mostra o rótulo sozinho. */
territorialClassFactors: TerritorialFactor[]
/** E8 "conta da cadeira" — meta × comprometido por cenário; null fora da staff view. */
goalCoverageByScenario: Record<VoteEstimateScenario, MunicipalityGoalCoverage>
}
@ -78,31 +87,37 @@ export const toMunicipalityListViewModel = (
pledges: MunicipalityPledgeAggregate | undefined,
votePosition2022: MunicipalityListViewModel['votePosition2022'],
goalCoverageByScenario?: Record<VoteEstimateScenario, MunicipalityGoalCoverage>,
): MunicipalityListViewModel => ({
id: municipality.id,
name: municipality.name,
slug: municipality.slug,
kind: municipality.kind,
city: municipality.city,
region: municipality.region,
ibgeCode: municipality.ibgeCode,
zoneNumber: municipality.zoneNumber ?? null,
advisorIDs: (municipality.advisors ?? [])
.map(relationshipId)
.filter((id): id is number => id !== null),
priority: municipality.priority === 'alta' ? 'alta' : 'normal',
lastUpdateAt: municipality.lastUpdateAt ?? null,
lastSignalAt: resolveMunicipalityLastSignalAt(
municipality.lastUpdateAt ?? null,
pledges?.lastPledgeAt ?? null,
),
expectedVotes: toVoteEstimateScenarioViewModel(municipality.expectedVotes),
politicalTrendStatus: municipality.politicalTrend?.status ?? null,
politicalTrendNote: municipality.politicalTrend?.note ?? null,
pledges: pledges ?? createEmptyMunicipalityPledgeAggregate(),
votePosition2022,
goalCoverageByScenario: goalCoverageByScenario ?? createEmptyGoalCoverageByScenario(),
})
): MunicipalityListViewModel => {
const territorialClass = computeMunicipalityTerritorialClass(municipality.slug)
return {
id: municipality.id,
name: municipality.name,
slug: municipality.slug,
kind: municipality.kind,
city: municipality.city,
region: municipality.region,
ibgeCode: municipality.ibgeCode,
zoneNumber: municipality.zoneNumber ?? null,
advisorIDs: (municipality.advisors ?? [])
.map(relationshipId)
.filter((id): id is number => id !== null),
priority: municipality.priority === 'alta' ? 'alta' : 'normal',
lastUpdateAt: municipality.lastUpdateAt ?? null,
lastSignalAt: resolveMunicipalityLastSignalAt(
municipality.lastUpdateAt ?? null,
pledges?.lastPledgeAt ?? null,
),
expectedVotes: toVoteEstimateScenarioViewModel(municipality.expectedVotes),
politicalTrendStatus: municipality.politicalTrend?.status ?? null,
politicalTrendNote: municipality.politicalTrend?.note ?? null,
pledges: pledges ?? createEmptyMunicipalityPledgeAggregate(),
votePosition2022,
territorialClass: territorialClass.class,
territorialClassFactors: territorialClass.factors,
goalCoverageByScenario: goalCoverageByScenario ?? createEmptyGoalCoverageByScenario(),
}
}
export type MunicipalityAdvisorSummary = {
id: number

View file

@ -7,6 +7,7 @@ import { beforeAll, describe, expect, it } from 'vitest'
import { getMunicipalityCatalogEntry, municipalityCatalog } from '@/lib/municipalityCatalog'
import config from '@/payload.config'
import { loadMunicipalityListPageBundle } from '@/utilities/municipalityPageData'
import { computeMunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass'
import { aggregatePledgesByMunicipality } from '@/utilities/votePledgeData'
import { rollupMunicipalityStaffVotes } from '@/utilities/votePledgeViews'
@ -277,6 +278,66 @@ describe('loadMunicipalityListPageBundle', () => {
expect(row?.pledges.lastPledgeAt).toBe(pledge.declaredAt)
})
/**
* E10: the class is derived from the committed TSE artifact, so the filter
* cannot be a Payload constraint. The bundle must still agree with itself —
* rows, `totalDocs` and the overview all counting the filtered scope.
*/
it('filters by the derived territorial class and keeps the overview honest', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const redutoEntry = municipalityCatalog.find(
(entry) => computeMunicipalityTerritorialClass(entry.slug).class === 'reduto',
)!
const marginalEntry = municipalityCatalog.find(
(entry) => computeMunicipalityTerritorialClass(entry.slug).class === 'marginal',
)!
const reduto = await fixtures.getMunicipality(redutoEntry.slug)
const marginal = await fixtures.getMunicipality(marginalEntry.slug)
const marker = `e10-classe-${Date.now()}`
for (const [municipality, suffix] of [
[reduto, 'reduto'],
[marginal, 'marginal'],
] as const) {
await payload.update({
collection: 'municipality',
id: municipality.id,
data: { name: `${marker}-${suffix}` },
depth: 0,
overrideAccess: true,
})
fixtures.touchMunicipality(municipality.id)
}
const filtered = await loadMunicipalityListPageBundle(payload, coordinator, {
q: marker,
class: 'reduto',
})
expect(filtered.municipalities.map((row) => row.slug)).toEqual([reduto.slug])
expect(filtered.totalDocs).toBe(1)
expect(filtered.overview?.municipalityCount).toBe(1)
expect(filtered.municipalities[0]!.territorialClass).toBe('reduto')
expect(filtered.municipalities[0]!.territorialClassFactors.length).toBeGreaterThan(0)
// A native sort key would otherwise let Payload paginate before the class
// filter runs, which would hand back a short page with an inflated total.
const byName = await loadMunicipalityListPageBundle(payload, coordinator, {
q: marker,
class: 'reduto',
sort: 'name',
})
expect(byName.municipalities.map((row) => row.slug)).toEqual([reduto.slug])
expect(byName.totalDocs).toBe(1)
const sorted = await loadMunicipalityListPageBundle(payload, coordinator, {
q: marker,
sort: 'classe',
})
expect(sorted.municipalities.map((row) => row.slug)).toEqual([reduto.slug, marginal.slug])
})
it('keeps advisor access and applies URL filters on top', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')

View file

@ -182,6 +182,11 @@ describe('campaign visual foundation', () => {
share: 0.031,
totalUnits: 435,
},
territorialClass: 'reduto',
territorialClassFactors: [
{ id: 'dominance', value: 2.4 },
{ id: 'ownShare', value: 0.031 },
],
goalCoverageByScenario: {
pessimistic: { goal: 1500, committed: 1200, coverageRatio: 0.8, deficit: 300 },
central: { goal: 1500, committed: 1200, coverageRatio: 0.8, deficit: 300 },
@ -206,6 +211,8 @@ describe('campaign visual foundation', () => {
politicalTrendNote: null,
pledges: createEmptyMunicipalityPledgeAggregate(),
votePosition2022: null,
territorialClass: 'expansao',
territorialClassFactors: [{ id: 'field', value: 9000 }],
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
},
]
@ -235,6 +242,68 @@ describe('campaign visual foundation', () => {
expect(html).toContain('Faltam 300 votos para a meta')
})
/**
* E10: the class is a suggestion, and a suggestion without its reason reads
* as a verdict (research §6.4). The reason moved into the column's
* `cellTooltip` so the cell stays narrow, so what this pins is that it is
* still TEXT in the markup (`sr-only`) rather than a `title` attribute,
* which reached neither keyboard nor touch. A município without a TSE
* series must not wear a badge that implies a reading.
*/
it('never renders a territorial class without its reason, and dashes the ones with no series', () => {
const html = renderWithAppRouter(
createElement(MunicipalityList, {
municipalities: [
stub<MunicipalityListViewModel>({
id: 1,
name: 'Seabra',
slug: 'seabra',
kind: 'municipio',
city: 'Seabra',
region: 'Chapada Diamantina',
advisorIDs: [],
priority: 'normal',
expectedVotes: toVoteEstimateScenarioViewModel(null),
pledges: createEmptyMunicipalityPledgeAggregate(),
votePosition2022: null,
territorialClass: 'reduto',
territorialClassFactors: [
{ id: 'dominance', value: 2.4 },
{ id: 'ownShare', value: 0.031 },
],
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
}),
stub<MunicipalityListViewModel>({
id: 2,
name: 'Sem série',
slug: 'sem-serie',
kind: 'municipio',
city: 'Sem série',
region: 'Chapada Diamantina',
advisorIDs: [],
priority: 'normal',
expectedVotes: toVoteEstimateScenarioViewModel(null),
pledges: createEmptyMunicipalityPledgeAggregate(),
votePosition2022: null,
territorialClass: 'sem_base',
territorialClassFactors: [],
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
}),
],
advisorNamesById: new Map(),
isStaffView: true,
...municipalityListDefaultProps,
}),
)
expect(html).toContain('Reduto')
// The factors ride along as text, in the mesa's phrasing — never a `title`.
expect(html).toContain('2,4× o padrão estadual do candidato')
expect(html).not.toContain('title="2,4×')
expect(html).not.toContain('Sem base')
expect(html).toContain('Sem série do TSE para este município.')
})
/**
* E9 allocation queue: the two row-level signals the queue is scanned for —
* a priority município nobody answers for, and a signal that went cold.
@ -264,6 +333,8 @@ describe('campaign visual foundation', () => {
politicalTrendNote: null,
pledges: createEmptyMunicipalityPledgeAggregate(),
votePosition2022: null,
territorialClass: 'expansao',
territorialClassFactors: [{ id: 'field', value: 9000 }],
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
},
],
@ -303,6 +374,8 @@ describe('campaign visual foundation', () => {
politicalTrendNote: null,
pledges: createEmptyMunicipalityPledgeAggregate(),
votePosition2022: null,
territorialClass: 'expansao',
territorialClassFactors: [{ id: 'field', value: 9000 }],
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
},
],
@ -345,6 +418,8 @@ describe('campaign visual foundation', () => {
lastPledgeAt: null,
},
votePosition2022: null,
territorialClass: 'expansao',
territorialClassFactors: [{ id: 'field', value: 9000 }],
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
},
],
@ -381,6 +456,8 @@ describe('campaign visual foundation', () => {
politicalTrendNote: null,
pledges: createEmptyMunicipalityPledgeAggregate(),
votePosition2022: null,
territorialClass: 'expansao',
territorialClassFactors: [{ id: 'field', value: 9000 }],
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
},
],

View file

@ -0,0 +1,63 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { CampaignTable, CampaignTableHead } from '@/components/campaign/shared/CampaignTable'
type Row = { id: number; name: string; note: string | null }
const rows: Row[] = [
{ id: 1, name: 'Seabra', note: 'Vereador migrou para a base' },
{ id: 2, name: 'Itaberaba', note: null },
]
const renderTable = (cellTooltip?: (row: Row) => string | null) =>
renderToStaticMarkup(
createElement(CampaignTable<Row>, {
columns: [
{
id: 'name',
head: createElement(CampaignTableHead, {}, 'Município'),
cell: (row) => row.name,
cellTooltip,
},
],
rows,
rowKey: (row) => row.id,
}),
)
/**
* B23 seam consumed by E10's "Classe" column: the tooltip is declared by the
* column, rendered by the table. These pin the two halves of the contract that
* a consumer would otherwise have to rediscover.
*/
describe('CampaignTable cellTooltip', () => {
it('renders the cell untouched when a column declares no tooltip', () => {
const html = renderTable()
expect(html).toContain('Seabra')
// No wrapper at all: the municipality list runs with `overflow-x-visible`
// and a sticky header, so an unrequested inline-flex span is a layout risk.
expect(html).not.toContain('inline-flex')
})
it('wraps only the rows whose tooltip has content', () => {
const html = renderTable((row) => row.note)
// Itaberaba has no note, so it must render exactly like the no-tooltip
// case — one trigger in the table, not two.
expect(html.match(/data-slot="tooltip-trigger"/g)).toHaveLength(1)
expect(html).toContain('>Itaberaba<')
})
/**
* Radix mounts tooltip content only once opened, so the reason never reaches
* the server markup. That is precisely why `cellTooltip`'s contract demands
* the same information already exist as text in the cell — a column that
* moves its only copy into the tooltip silently loses it for assistive tech.
*/
it('keeps the tooltip content out of the server markup', () => {
expect(renderTable((row) => row.note)).not.toContain('Vereador migrou para a base')
})
})

View file

@ -131,6 +131,70 @@ describe('municipality list sort params (A11 + B15 + E9)', () => {
expect(municipalityListSortLabels.coverage).toBe('Assessores')
expect(formatMunicipalityListSortSummary('coverage', 'asc')).toBe('Ordenado por Assessores ↑')
})
it('opens the classe sort on reduto (desc default) and names the direction', () => {
const base = parseMunicipalityListParams({})
expect(buildMunicipalitySortHref(base, 'classe')).toBe('/campanha/municipios?sort=classe')
const byClass = parseMunicipalityListParams({ sort: 'classe' })
expect(resolveMunicipalityListSort(byClass)).toEqual({ sort: 'classe', dir: 'desc' })
expect(formatMunicipalityListSortSummary('classe', 'desc')).toBe(
'Ordenado por classe (reduto primeiro)',
)
expect(formatMunicipalityListSortSummary('classe', 'asc')).toBe(
'Ordenado por classe (marginal primeiro)',
)
})
})
describe('E10 classe territorial filter (derived, not a Payload constraint)', () => {
it('parses repeated ?class= params and keeps them out of the where clause', () => {
const state = parseMunicipalityListParams({ class: ['reduto', 'expansao', 'reduto'] })
expect(state.classes).toEqual(['reduto', 'expansao'])
expect(isMunicipalityColumnFilterActive(state, 'class')).toBe(true)
// The class lives in the committed TSE artifact, so `where` must stay clean —
// `municipalityPageData` applies it in memory over the unpaginated scope.
expect(buildMunicipalityListWhere(state)).toEqual({})
expect(buildMunicipalityFilterHref(state)).toBe(
'/campanha/municipios?class=reduto&class=expansao',
)
})
it('discards unknown classes and treats "todas" as absent', () => {
expect(parseMunicipalityListParams({ class: 'perdida' }).classes).toBeUndefined()
const all = parseMunicipalityListParams({
class: ['reduto', 'expansao', 'manutencao', 'marginal', 'sem_base'],
})
expect(all.classes).toBeUndefined()
expect(isMunicipalityColumnFilterActive(all, 'class')).toBe(false)
expect(formatMunicipalityActiveFiltersSummary(all)).toBeNull()
// Ticking the last unchecked box lands on that same canonical state.
const allButOne = parseMunicipalityListParams({
class: ['reduto', 'expansao', 'manutencao', 'marginal'],
})
expect(
toggleMunicipalityMultiFilterValue(allButOne, 'class', 'sem_base').classes,
).toBeUndefined()
})
it('names the class in the active-filters summary', () => {
expect(
formatMunicipalityActiveFiltersSummary(
parseMunicipalityListParams({ class: ['reduto', 'expansao'] }),
),
).toBe('Classe reduto, expansão')
})
it('toggles a class in and out while preserving the other filters', () => {
const state = parseMunicipalityListParams({ region: 'Irecê', sort: 'votos' })
const on = toggleMunicipalityMultiFilterValue(state, 'class', 'reduto')
expect(on.classes).toEqual(['reduto'])
expect(buildMunicipalityFilterHref(on)).toBe(
'/campanha/municipios?region=Irec%C3%AA&class=reduto&sort=votos',
)
expect(toggleMunicipalityMultiFilterValue(on, 'class', 'reduto').classes).toBeUndefined()
})
})
describe('municipality list header filters (B16+)', () => {

View file

@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import {
classifyMunicipalityTerritory,
computeMunicipalityTerritorialClass,
TERRITORIAL_CLASS_ANCHORS,
TERRITORIAL_CLASSES,
territorialClassSortWeight,
type MunicipalityTerritorialClass,
type TerritorialClassInput,
} from '@/utilities/municipalityTerritorialClass'
/**
* Statewide reference: 100.000 own votes over 5.000.000 valid votes = a 2%
* statewide share, so a município with a 4% local share has LQ 2 (reduto) and
* one with 0,5% has LQ 0,25 (weak half).
*/
const input = (overrides: Partial<TerritorialClassInput> = {}): TerritorialClassInput => ({
ownVotes: 200,
validVotes: 10_000,
stateOwnVotes: 100_000,
stateValidVotes: 5_000_000,
fieldHeadroom: 1_000,
medianFieldHeadroom: 2_000,
captureRate: 0.1,
inCoreBlock: false,
...overrides,
})
describe('classifyMunicipalityTerritory', () => {
it('classifies as reduto when the local share is at least strongLq × his own standard', () => {
// 4% local vs 2% statewide = LQ 2
const result = classifyMunicipalityTerritory(input({ ownVotes: 400, validVotes: 10_000 }))
expect(result.class).toBe('reduto')
expect(result.lq).toBe(TERRITORIAL_CLASS_ANCHORS.strongLq)
expect(result.factors.slice(0, 2).map((factor) => factor.id)).toEqual(['dominance', 'ownShare'])
})
it('keeps a tiny reduto as reduto, with ownShare telling it apart from one that carries the account', () => {
const symbolic = classifyMunicipalityTerritory(input({ ownVotes: 60, validVotes: 1_000 }))
expect(symbolic.class).toBe('reduto')
// 60 / 100.000 = 0,06% of his statewide vote — the "reduto simbólico" of P10.
expect(symbolic.ownShare).toBeCloseTo(0.0006, 6)
})
it('classifies as manutenção inside the standard band', () => {
// 2% local = LQ 1
expect(classifyMunicipalityTerritory(input({ ownVotes: 200 })).class).toBe('manutencao')
// Just under the strong cut stays manutenção (boundary is inclusive on reduto)
expect(classifyMunicipalityTerritory(input({ ownVotes: 399 })).class).toBe('manutencao')
// Exactly at the weak cut is still manutenção (boundary is exclusive on marginal)
expect(classifyMunicipalityTerritory(input({ ownVotes: 100 })).lq).toBe(
TERRITORIAL_CLASS_ANCHORS.weakLq,
)
expect(classifyMunicipalityTerritory(input({ ownVotes: 100 })).class).toBe('manutencao')
})
it('classifies as expansão when he is weak but the field still has votes to win', () => {
const result = classifyMunicipalityTerritory(
input({ ownVotes: 50, fieldHeadroom: 9_000, medianFieldHeadroom: 2_000 }),
)
expect(result.class).toBe('expansao')
expect(result.factors[0]).toEqual({ id: 'field', value: 9_000 })
})
it('classifies as marginal when he is weak and the field is small', () => {
expect(
classifyMunicipalityTerritory(
input({ ownVotes: 50, fieldHeadroom: 100, medianFieldHeadroom: 2_000 }),
).class,
).toBe('marginal')
})
it('never calls a core-block município marginal, even with a small field', () => {
const result = classifyMunicipalityTerritory(
input({ ownVotes: 50, fieldHeadroom: 100, medianFieldHeadroom: 2_000, inCoreBlock: true }),
)
expect(result.class).toBe('expansao')
expect(result.inCoreBlock).toBe(true)
})
it('drops the capture factor when the field ceiling is unknown', () => {
const result = classifyMunicipalityTerritory(input({ captureRate: null }))
expect(result.factors.map((factor) => factor.id)).not.toContain('capture')
})
it('returns sem_base without a local electorate or a statewide denominator', () => {
expect(classifyMunicipalityTerritory(input({ validVotes: 0 })).class).toBe('sem_base')
expect(classifyMunicipalityTerritory(input({ stateOwnVotes: 0 })).class).toBe('sem_base')
expect(classifyMunicipalityTerritory(input({ stateValidVotes: 0 })).class).toBe('sem_base')
expect(classifyMunicipalityTerritory(input({ validVotes: 0 })).lq).toBeNull()
expect(classifyMunicipalityTerritory(input({ validVotes: 0 })).factors).toEqual([])
})
it('classifies a município with zero own votes as weak, not as an error', () => {
const result = classifyMunicipalityTerritory(input({ ownVotes: 0, fieldHeadroom: 9_000 }))
expect(result.class).toBe('expansao')
expect(result.lq).toBe(0)
})
})
describe('computeMunicipalityTerritorialClass over the committed artifact', () => {
const classifications = municipalityCatalog.map((entry) =>
computeMunicipalityTerritorialClass(entry.slug),
)
/**
* The regression this whole item exists to prevent: the inherited absolute
* thresholds (35/20/10) collapsed the entire state into one class, because a
* federal-deputy candidate's local share is 1–5% everywhere. Relative
* anchors must produce a usable spread over the real 435 municípios.
*/
it('produces a non-degenerate distribution over the 435 catalog slugs', () => {
const counts = new Map<MunicipalityTerritorialClass, number>()
for (const classification of classifications) {
counts.set(classification.class, (counts.get(classification.class) ?? 0) + 1)
}
for (const territorialClass of TERRITORIAL_CLASSES) {
if (territorialClass === 'sem_base') continue
expect(counts.get(territorialClass) ?? 0).toBeGreaterThan(0)
}
for (const [, count] of counts) {
expect(count / classifications.length).toBeLessThan(0.7)
}
})
it('gives every classified município a "por quê" and a finite LQ', () => {
for (const classification of classifications) {
if (classification.class === 'sem_base') continue
expect(classification.factors.length).toBeGreaterThanOrEqual(2)
expect(Number.isFinite(classification.lq ?? Number.NaN)).toBe(true)
}
})
it('keeps the core block at roughly half of his statewide vote', () => {
const coreShare = classifications
.filter((classification) => classification.inCoreBlock)
.reduce((total, classification) => total + classification.ownShare, 0)
expect(coreShare).toBeGreaterThan(0.4)
expect(coreShare).toBeLessThan(0.6)
})
it('memoizes: the same slug returns the identical object', () => {
const slug = municipalityCatalog[0].slug
expect(computeMunicipalityTerritorialClass(slug)).toBe(
computeMunicipalityTerritorialClass(slug),
)
})
})
describe('territorialClassSortWeight', () => {
it('ranks reduto first and leaves sem_base without a weight', () => {
expect(territorialClassSortWeight.reduto).toBeGreaterThan(
territorialClassSortWeight.expansao ?? 0,
)
expect(territorialClassSortWeight.expansao).toBeGreaterThan(
territorialClassSortWeight.manutencao ?? 0,
)
expect(territorialClassSortWeight.manutencao).toBeGreaterThan(
territorialClassSortWeight.marginal ?? 0,
)
expect(territorialClassSortWeight.sem_base).toBeNull()
})
})

View file

@ -1,12 +1,12 @@
import { describe, expect, it } from 'vitest'
import { federalBaselineMunicipalitySlugs } from '@/lib/bahiaElectionAggregates'
import { formatVoteSharePercent } from '@/lib/electionFormat'
import {
compareMunicipalityVotesForSort,
computeVoteRankByYear,
DEFAULT_VOTE_RANK_YEAR,
formatMunicipalityVoteRank,
formatMunicipalityVoteShare,
getMunicipalityVoteRank,
} from '@/lib/municipalityVoteRank'
@ -58,7 +58,7 @@ describe('municipalityVoteRank', () => {
it('formats rank as colocação only (catalog size lives in the header hint)', () => {
expect(formatMunicipalityVoteRank(12)).toBe('12º')
expect(formatMunicipalityVoteShare(0.031)).toMatch(/3[,.]1%/)
expect(formatVoteSharePercent(0.031)).toMatch(/3[,.]1%/)
})
it('sorts zero votes last in either direction', () => {