diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc
index 28fa21e8..a214e173 100644
--- a/.cursor/rules/projects/nucleos-eleitorais.mdc
+++ b/.cursor/rules/projects/nucleos-eleitorais.mdc
@@ -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).
diff --git a/AGENTS.md b/AGENTS.md
index c50b9d0a..c9907ccd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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"). 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, not a badge. **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**.
diff --git a/DESIGN.md b/DESIGN.md
index bdb13b81..6f6a61f1 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -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
diff --git a/docs/plans/classificacao-territorial-relativa.md b/docs/plans/classificacao-territorial-relativa.md
index c7a4be84..ba1e3df8 100644
--- a/docs/plans/classificacao-territorial-relativa.md
+++ b/docs/plans/classificacao-territorial-relativa.md
@@ -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 (série + válidos)"]
- Pot["municipalityPotential (E8) captura/LQ/teto"]
- Class["computeDfTerritorialClass (â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`
diff --git a/docs/roadmap.md b/docs/roadmap.md
index b07ab2c9..f1db7f16 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# Roadmap — Teqo
-Atualizado em: 2026-07-25 (**C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); **B33** ordenação e filtro no header da lista de dobradinhas — leva B15 ✓/B16 ✓ para `/campanha/dobradinhas` (sort por Nome/Partido, filtro por Partido); consome o header rico compartilhado que **B29** extrai (ou extrai o dele mesmo, se chegar primeiro — mesma corrida de ordem de chegada de B21/B29) — Não escopo do B29 já nomeava esta entrada; contagens derivadas (`municipalityCount`/`leadershipCount`) ficam fora do sort v1, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30** convite de completar cadastro por WhatsApp na lista de lideranças — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); **B28** e-mail + celular na lista de `/campanha/liderancas` — colunas com copy-on-click + WhatsApp como em `/campanha/assessores` (B19 ✓); edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); **B27** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` — a lista de checkboxes com botão "Salvar assessores" vira chips removíveis sobre um combobox com busca, gravando **por delta** (endpoint JSON espelho do `expected-votes/`, lock do B19 ✓), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); **B25** link do território na lista de municípios — a célula da coluna "Território" vira link para a linha daquele TI em `/campanha/territorios` (âncora, sem rota de detalhe); dep dura B21, [plano](plans/link-territorio-lista-municipios.md); **B24** auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` — tira o botão "Salvar" que o princípio 4 do `PRODUCT.md` proíbe e fecha o adiado "nota no popover" do B9, [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); **B23** tooltip de conteúdo nas células das listas — `cellTooltip` em `CampaignTableColumn`, consumidores as colunas "Assessores" e "Tendência" (justificativa do E4R no hover; sem nota, sem tooltip) de `/campanha/municipios`, [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); **B22** explicação por coluna no header das listas — tooltip de hover/foco/tap promovido para o sistema de listas compartilhado, [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6)
+Atualizado em: 2026-07-25 (**E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); **B33** ordenação e filtro no header da lista de dobradinhas — leva B15 ✓/B16 ✓ para `/campanha/dobradinhas` (sort por Nome/Partido, filtro por Partido); consome o header rico compartilhado que **B29** extrai (ou extrai o dele mesmo, se chegar primeiro — mesma corrida de ordem de chegada de B21/B29) — Não escopo do B29 já nomeava esta entrada; contagens derivadas (`municipalityCount`/`leadershipCount`) ficam fora do sort v1, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30** convite de completar cadastro por WhatsApp na lista de lideranças — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); **B28** e-mail + celular na lista de `/campanha/liderancas` — colunas com copy-on-click + WhatsApp como em `/campanha/assessores` (B19 ✓); edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); **B27** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` — a lista de checkboxes com botão "Salvar assessores" vira chips removíveis sobre um combobox com busca, gravando **por delta** (endpoint JSON espelho do `expected-votes/`, lock do B19 ✓), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); **B25** link do território na lista de municípios — a célula da coluna "Território" vira link para a linha daquele TI em `/campanha/territorios` (âncora, sem rota de detalhe); dep dura B21, [plano](plans/link-territorio-lista-municipios.md); **B24** auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` — tira o botão "Salvar" que o princípio 4 do `PRODUCT.md` proíbe e fecha o adiado "nota no popover" do B9, [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); **B23** tooltip de conteúdo nas células das listas — `cellTooltip` em `CampaignTableColumn`, consumidores as colunas "Assessores" e "Tendência" (justificativa do E4R no hover; sem nota, sem tooltip) de `/campanha/municipios`, [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); **B22** explicação por coluna no header das listas — tooltip de hover/foco/tap promovido para o sistema de listas compartilhado, [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6)
Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026)
@@ -53,6 +53,7 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md](
- **E9 (2026-07-24)** — fila de alocação na própria lista de municípios (sem rota nova, sem coluna nova, sem migration). Corrigiu antes a meta sintética do E8, que repartia os 150 mil proporcionalmente **só ao teto do campo** e por isso dava meta 2.911 a Vitória da Conquista (5.005 votos em 2022) e 813 a Campo Formoso (47) — ordenar por déficit sobre ela colocaria desertos no topo. `deriveSuggestedGoalsByScenario` (substitui `decomposeStateGoal`) ancora a meta na votação própria de 2022 por cenário: pessimista = base×(1−`margin`), central = base, otimista = base×(`stateGoal` ÷ Σ base), com clamp; `goalCoverage` passou a receber `SuggestedGoalByScenario`. Na fila: `lastPledgeAt` no agregado de pledges, `lastSignalAt` = máx(`lastUpdateAt`, pledge) no view model, sort keys `deficit` (**novo default do staff**) e `frescor` (cenário `central` fixo), frescor dobrado na coluna "Última atualização" (frio ≥ 21 dias), badge "sem responsável" em prioridade alta sem assessor, "coluna da vergonha" no detalhe da métrica de assessoria (`?priority=alta&coverage=sem_assessor`) e copy Praças→Municípios. Cortes: votos em jogo → **B13**, LQ/captura → **E10**, coluna dedicada de déficit → desnecessária. Plano: [fila-de-alocacao.md](plans/fila-de-alocacao.md). Desbloqueia **E11**.
- **E16 (2026-07-25)** — dossiê do município (pré-agenda, pedido O6): aba `?tab=dossie` staff-only no detalhe compondo os loaders existentes (`municipalityDossierData.ts` + `MunicipalityDossier.tsx`) — capa com data de geração, série TSE/rank (A11), conta da cadeira (E8), rede de lideranças com frescor, conjuntura/dobradinhas/encaminhamentos, agenda (`actionPlan`), sinais recentes (C12), perfil IBGE (A8 — reentrada do artefato, caveat para zonas); caps 8/5/3+2 com "ver tudo"; campo staff-only `municipality.budgetNotes` ("Emendas aportadas", G11 manual-first, migration `20260725_022155`); **visão print** via CSS (chrome oculto, shells destravados, tipografia A4 — 1–2 páginas). Plano: [dossie-municipio.md](plans/dossie-municipio.md).
- **B26 (2026-07-25)** — registrar sinal na célula "Último sinal" de `/campanha/municipios`: Popover (desktop) / Drawer (mobile) cria `municipalityUpdate` `kind: 'sinal'` sem sair da fila; submit explícito; `revalidateMunicipalityListPaths`; sem migration. Plano: [registrar-sinal-lista-municipios.md](plans/registrar-sinal-lista-municipios.md).
+- **E10 (2026-07-25)** — classificação territorial relativa: `municipalityTerritorialClass.ts`, função **pura sobre o artefato TSE commitado** (sem query, sem `campaignGoals`, sem `user`, sem migration), classificando os 435 municípios em **Reduto · Expansão · Manutenção · Marginal · Sem base** — vocabulário do research, que substitui o `Defesa/Ataque/Indecisa/Perdida` herdado do A5-2. Eixos: LQ contra o padrão estadual do próprio candidato (`strongLq` 2 / `weakLq` 0,5), importância na própria votação, teto do campo não capturado contra a **mediana do catálogo** e pertencimento ao bloco que soma 50% da votação (impede que um município grande com baixa penetração leia como "Marginal"). `getStatewideFederalTotals` foi extraído/memoizado em `bahiaElectionAggregates.ts` (`municipalityVoteRank` passou a reusá-lo). Superfícies: badge + "por quê" no card "Conta da cadeira" (E8), capa do dossiê (E16, com print CSS para o badge não achatar em cinza) e **coluna nova "Classe"** na lista com sort (`?sort=classe`) e filtro multi (`?class=` repetível) — o filtro é derivado, então força o caminho `limit: 0` e se aplica também ao escopo da visão geral e às facetas, senão `totalDocs` mentiria. Dois verbetes novos em `/campanha/conceitos` (E18). Cortes exatos são ilustrativos (calibração = **E15**); NEC/competição ficou fora com gatilho. Plano: [classificacao-territorial-relativa.md](plans/classificacao-territorial-relativa.md). Desbloqueia **B13** e a parte de classes do **E11**.
- **B16 (2026-07-25)** — filtros no header da lista de municípios: `MunicipalityHeaderFilter` (funil + Popover ao lado do sort do B15) com multi-seleção OR em município, território, assessores e tendência, busca acento-insensível e "Limpar"; mobile mantém os selects empilhados; header sticky com a página como scroller e empty state que troca só as linhas, preservando visão geral e filtros; coluna "Assessoria" removida (ordenação foi para "Assessores", com/sem assessor virou opção do popover e o alerta "sem responsável" do E9 passou a morar na célula de assessores). Plano: [filtros-no-header-lista-municipios.md](plans/filtros-no-header-lista-municipios.md).
- **B20 (2026-07-25)** — Municípios prioritários no Início staff: seção deixa de truncar 6 prioritárias sem critério; amostra de 8 ordenada por déficit de cobertura (E9/`central`) via `pickDashboardPriorityMunicipalities`; `highPriorityCount` no Badge; CTA “Ver todas” → `/campanha/municipios?priority=alta`; markup `list-none`. Plano: [municipios-prioritarios-inicio.md](plans/municipios-prioritarios-inicio.md).
@@ -62,15 +63,15 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md](
O discovery literatura→persona→entrevista ([relatório aprovado](research/relatorio-entrevista-persona-campanha.md); compêndio com ~67 fontes em [docs/research/](research/)) fixou o kernel: a disputa de DF é conta de quociente fragmentada; o gargalo é converter lealdade de campo em voto nominal município a município via rede; % estadual absoluto é anti-métrica. A **sessão real com o Coordenador Geral (2026-07-23 — [CUSTOMER.md](CUSTOMER.md))** confirmou as apostas centrais (fogo amigo intra-PT como ameaça nº 1; canal = ZAP sem registro datado; "coluna da vergonha" validada — Salvador cobrado 10×) e calibrou as âncoras: a leitura relativa da mesa é **% da própria votação** (concentração da captura própria — critério rígido de prioridade dele), não % do eleitorado local; a restrição dominante é **"perna"/estrutura**, não dinheiro; piso projetado **150 mil** (2022: 129k). O produto deve entregar **inteligência, não planilha chique**: metas derivadas, leitura relativa, fila de decisão priorizada, sugestões dado→decisão com humano no loop, registro ex-ante auditável. Plano-mestre: [inteligencia-campanha.md](plans/inteligencia-campanha.md) (incl. gaps de dados G1–G11 e desenho canônico da fila).
-| ID | Fatia | Plano | Entrega essencial | Classe | Appetite | Janela | Depende de |
-| --- | ---------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------ | -------- | --------- | -------------------- |
-| E10 | Classificação territorial relativa | [detalhes](plans/classificacao-territorial-relativa.md) | Âncoras relativas (LQ / share da cadeira marginal), multi-eixo; substitui 35/20/10 para DF | B | ~1d | 3 | E8 ✓ |
-| B13 | Escala relativa no mapa | [detalhes](plans/escala-relativa-mapa.md) | Quantis do candidato (default) + LQ + rank; símbolo proporcional por votos em jogo; % válidos mantido | B | ~2d | 3 | E8 ✓, E10 |
-| E14 | Níveis de envolvimento N0–N4 | [detalhes](plans/niveis-de-envolvimento.md) | `engagementLevel` com histerese e sinais de reversão; staff-only (vocabulário duplo) | B | ~1d | 3 | C12 |
-| E11 | Motor de sugestões v1 | [detalhes](plans/motor-de-sugestoes.md) | 8 padrões (P1/P2/P3/P5/P6/P7/K-A/K-B), menu com estatuto, aceitar/descartar → `allocationDecision`, triagem 1–5 | C | ~3d | 3 | E8 ✓, E9 ✓, E10, C12 |
-| E12 | Camada TI | [detalhes](plans/camada-territorios-identidade.md) | Rollups com salvaguardas MAUP, benchmark intra-TI (gatilhos T no motor = fase 2 de E11) | B | ~1,5d | 3 | E8 ✓ |
-| E13 | Planejador de presença/giros | [detalhes](plans/planejador-de-giros.md) | Elegibilidade (5 condições ✓/—), fases construção/consolidação/ativação, compositor de giro, visita pedida×justificada | C | ~1,5d | 3 | E8 ✓, C12 |
-| E15 | Backtest pós-eleição | [detalhes](plans/backtest-pos-eleicao.md) | Pledge (trajetória) vs. resultado por zona; calibração de limiares | A | ~1d | pós-04/10 | C12, TSE 2026 |
+| ID | Fatia | Plano | Entrega essencial | Classe | Appetite | Janela | Depende de |
+| ------- | -------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | --------- | -------------------- |
+| ~~E10~~ | ~~Classificação territorial relativa~~ | [detalhes](plans/classificacao-territorial-relativa.md) | **✓ (entregue 2026-07-25)** Reduto/Expansão/Manutenção/Marginal por LQ + importância + teto do campo; coluna, sort e filtro na lista | B | ~1,25d | 3 | E8 ✓ |
+| B13 | Escala relativa no mapa | [detalhes](plans/escala-relativa-mapa.md) | Quantis do candidato (default) + LQ + rank; símbolo proporcional por votos em jogo; % válidos mantido — as **classes já existem** (`computeMunicipalityTerritorialClass`, E10 ✓): consumir como está, sem reclassificar | B | ~2d | 3 | E8 ✓, E10 ✓ |
+| E14 | Níveis de envolvimento N0–N4 | [detalhes](plans/niveis-de-envolvimento.md) | `engagementLevel` com histerese e sinais de reversão; staff-only (vocabulário duplo) | B | ~1d | 3 | C12 |
+| E11 | Motor de sugestões v1 | [detalhes](plans/motor-de-sugestoes.md) | 8 padrões (P1/P2/P3/P5/P6/P7/K-A/K-B), menu com estatuto, aceitar/descartar → `allocationDecision`, triagem 1–5 | C | ~3d | 3 | E8 ✓, E9 ✓, E10, C12 |
+| E12 | Camada TI | [detalhes](plans/camada-territorios-identidade.md) | Rollups com salvaguardas MAUP, benchmark intra-TI (gatilhos T no motor = fase 2 de E11) | B | ~1,5d | 3 | E8 ✓ |
+| E13 | Planejador de presença/giros | [detalhes](plans/planejador-de-giros.md) | Elegibilidade (5 condições ✓/—), fases construção/consolidação/ativação, compositor de giro, visita pedida×justificada | C | ~1,5d | 3 | E8 ✓, C12 |
+| E15 | Backtest pós-eleição | [detalhes](plans/backtest-pos-eleicao.md) | Pledge (trajetória) vs. resultado por zona; calibração de limiares | A | ~1d | pós-04/10 | C12, TSE 2026 |
### Demais itens abertos
@@ -96,7 +97,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
- ~~**B15** ordenar lista de municípios pelo header da coluna~~ — **entregue 2026-07-24** (`?sort=`/`?dir=` na URL + clique no header (desktop) / select compacto (mobile); ordenação global no filtrado; **A11** consome o contrato (key `votos`)) · [plano](plans/ordenacao-colunas-lista-municipios.md)
- ~~**B16** filtros no header das colunas da lista de municípios~~ — **entregue 2026-07-24** (funil+Popover no `TableHead` ao lado do sort B15; barra slim busca+resumo+Limpar; mobile selects empilhados; optimistic + busca no Popover de TI) · [plano](plans/filtros-no-header-lista-municipios.md)
- **B22** explicação por coluna no header das listas — pedido de produto (2026-07-25): tooltip no hover de cada header de `/campanha/municipios` dizendo o que a coluna mede, **implementado no componente de header** para reuso. Hoje a capacidade existe pela metade (prop `tooltip` do `MunicipalitySortableHead`, usada em 3 de 9 colunas; primitivo preso ao domínio `municipality`; `CampaignTableHead` sem prop nenhuma): promover `MunicipalityHoverTooltip`→`shared/CampaignHoverTooltip`, prop `description` nos dois heads, copy das colunas de municípios com `oneLiner` do **E18 ✓** como fonte única nas métricas do E8; sem migration/action; dá evidência datada para o débito **O3**/glossário inline do R6 · ~0,5d · Janela 1–2 · Impeccable B · soft: B15 ✓/B16 ✓/E18 ✓ · cortável · [plano](plans/explicacao-colunas-header-listas.md)
-- **B23** tooltip de conteúdo nas células das listas — dois pedidos de produto (2026-07-25): (a) hover na célula de "Assessores" em `/campanha/municipios` mostra os nomes dos assessores do município (hoje só há até 3 avatares de **iniciais** + `sr-only`; do 4º em diante nem avatar); (b) hover na célula de "Tendência" mostra a **justificativa** registrada (`politicalTrend.note`, semeada em massa pelo **E4R ✓** — hoje já chega na célula, mas só como ``, e só é legível abrindo o detalhe), **sem tooltip quando não há nota**. Ambos **implementados como capacidade genérica da célula** para reuso em outros campos/páginas: `cellTooltip?: (row) => ReactNode` em `CampaignTableColumn`, envolvido pela `CampaignTable` no primitivo compartilhado (`CampaignHoverTooltip` — promovido no **B22** ou aqui, o que vier primeiro); sem novos tab stops (tooltip é redundante; texto acessível segue na célula) e, nas células que já são gatilho de Popover de edição rápida (assessores do coordenador, tendência), `openOnTouch={false}` + `disabled={open}` para não roubar o tap nem sobreviver atrás do Popover (B9/B19 ✓) — em touch a informação aparece dentro do próprio Popover (a nota da tendência entra ali como leitura, acima do select); sem migration/action/query nova · ~0,75d · Janela 1–2 · Impeccable B · soft: B22 (primitivo compartilhado), E4R ✓ (as notas) · cortável · [plano](plans/tooltip-celulas-listas.md)
+- **B23** tooltip de conteúdo nas células das listas — **a mecânica genérica já está no repo** (entregue dentro do **E10 ✓** em 2026-07-25, a pedido do produto: a célula "Classe" ficava larga demais com o "por quê" por extenso): `cellTooltip?: (row) => ReactNode` em `CampaignTableColumn`, envolvido pela `CampaignTable` em `shared/CampaignCellTooltip`, e `MunicipalityHoverTooltip` **já promovido** para `shared/CampaignHoverTooltip` (3 call sites migrados; B22 só consome). Contrato de acessibilidade pinado em `tests/unit/campaignTable.unit.spec.ts`: coluna sem `cellTooltip` renderiza sem wrapper, linha com tooltip vazia idem, e o conteúdo do Radix **não** existe no markup do servidor — por isso a informação tem de estar na célula como texto (`sr-only` atrás do badge, como a Classe faz). **O que resta do B23** são os dois consumidores originais e a convivência com o Popover (`openOnTouch` / `disabled`, que ainda não existem no primitivo) — dois pedidos de produto (2026-07-25): (a) hover na célula de "Assessores" em `/campanha/municipios` mostra os nomes dos assessores do município (hoje só há até 3 avatares de **iniciais** + `sr-only`; do 4º em diante nem avatar); (b) hover na célula de "Tendência" mostra a **justificativa** registrada (`politicalTrend.note`, semeada em massa pelo **E4R ✓** — hoje já chega na célula, mas só como ``, e só é legível abrindo o detalhe), **sem tooltip quando não há nota**. Ambos **implementados como capacidade genérica da célula** para reuso em outros campos/páginas: `cellTooltip?: (row) => ReactNode` em `CampaignTableColumn`, envolvido pela `CampaignTable` no primitivo compartilhado (`CampaignHoverTooltip` — promovido no **B22** ou aqui, o que vier primeiro); sem novos tab stops (tooltip é redundante; texto acessível segue na célula) e, nas células que já são gatilho de Popover de edição rápida (assessores do coordenador, tendência), `openOnTouch={false}` + `disabled={open}` para não roubar o tap nem sobreviver atrás do Popover (B9/B19 ✓) — em touch a informação aparece dentro do próprio Popover (a nota da tendência entra ali como leitura, acima do select); sem migration/action/query nova · ~0,4d (era ~0,75d; a capacidade genérica e a promoção do primitivo saíram no E10 ✓) · Janela 1–2 · Impeccable B · soft: B22 (só consome o primitivo agora), E4R ✓ (as notas) · cortável · [plano](plans/tooltip-celulas-listas.md)
- **B24** auto-save + justificativa no popover de Tendência da lista de municípios — pedido de produto (2026-07-25): o popover da coluna "Tendência" perde o botão **"Salvar"** (grava sozinho: status na hora, justificativa com debounce que **cancela o timer anterior**, flush no blur/fechamento) e ganha o campo para **editar a justificativa** (`politicalTrend.note`, hoje hidden input na lista e só editável em `/[slug]/editar`). Fecha duas dívidas datadas: o anti-goal literal do princípio 4 do `PRODUCT.md` ("Save buttons on Popovers and quick-edit cells") — a célula vizinha da mesma linha (votos estimados) já grava sozinha — e o "nota de tendência no popover, gatilho: feedback de uso" adiado no **B9 ✓**. Persistência por endpoint JSON próprio (`POST /campanha/municipios/political-trend`, espelho do `expected-votes/`), **sem revalidar** a lista de 435 linhas por gravação; `setMunicipalityPoliticalTrend` e o access ficam intactos; sem migration/collection/Consent · ~0,5d · Janela 1–2 · Impeccable B · soft: B23 (se vier antes, a nota read-only do Popover vira o textarea) · cortável · [plano](plans/autosave-tendencia-lista-municipios.md)
- **B25** link do território na lista de municípios — pedido de produto (2026-07-25): na coluna "Território" de `/campanha/municipios` o nome do TI vira link para **a página daquele território**. Hoje a célula é texto morto (`cell: (m) => m.region`) e a navegação é assimétrica: o painel do Início (**E17 ✓**) já linka TI → `/campanha/municipios?region=`, mas do município não se chega ao território. Destino = a página do **B21** ancorada na linha do TI (`/campanha/territorios#ti-` + `scroll-mt` e `target:` no precedente de `/campanha/conceitos`), **não** rota de detalhe `/campanha/territorios/` (Não escopo do B21, sem evidência nova: seria a linha do rollup + a lista filtrada, que já existem) nem `?region=` (tabela de uma linha destrói a comparação que é o job da tela). Âncora derivada por helper puro (`territoryAnchorId` sobre `slugify`), consumida pelas duas pontas e pinada por teste — TI não ganha slug persistido; sem migration/collection/action/query nova · ~0,25d · Janela 1–2 · Impeccable B · **dep dura: B21** · soft: B17 (coluna ocultável) · E12 é gatilho de revisitação da decisão "âncora vs rota de detalhe", não dependência · cortável · [plano](plans/link-territorio-lista-municipios.md)
- ~~**B26** registrar sinal a partir da célula "Último sinal"~~ — **entregue 2026-07-25** (Popover desktop / Drawer mobile; `municipalityUpdate` `kind: 'sinal'`; submit explícito; `revalidateMunicipalityListPaths`; sem migration) · [plano](plans/registrar-sinal-lista-municipios.md)
@@ -185,7 +186,7 @@ flowchart TD
E9i["E9 ✓ Fila de alocação"]
C12i["C12 ✓ Registro-fundação"]
E16i["E16 ✓ Dossiê do município"]
- E10i["E10 Classificação relativa"]
+ E10i["E10 ✓ Classificação relativa"]
B13i["B13 Escala relativa no mapa"]
E14i["E14 Níveis N0–N4"]
E11i["E11 Motor de sugestões v1"]
@@ -298,7 +299,7 @@ flowchart TD
B8F2 -.ZE Salvador.-> B14n
```
-Paralelizáveis agora: **E10**/**E12** (E8 ✓ desbloqueou ambos), **E11** (E9 ✓ entregue; ainda espera E10), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), **B21** (página dos TIs; sem dep dura — E17 ✓/B15 ✓/B16 ✓ já entregues; E12 pousa nela depois; **B25** é o único item que espera B21 — o link do território na lista só existe depois do destino), **B17** (seletor de colunas; soft B15 ✓/B16 ✓ — pousa no header de coluna atual), **B18** (filtros salvos + submenu Municípios; soft B16 ✓/B17 — botão na barra), **B22** (explicação por coluna no header; sem dep dura — B15 ✓/B16 ✓/E18 ✓), **B23** (tooltip nas células; sem dep dura — soft B22 só pelo primitivo compartilhado, quem chegar primeiro promove), **B24** (auto-save + justificativa no popover de Tendência; sem dep dura — soft B23 só pela ordem em que a nota entra no Popover), **B26** (registrar sinal na célula "Último sinal"; sem dep dura — E9 ✓/C12 ✓ já entregues; soft B24/B23 só pela coerência de contrato na mesma linha), **B27** (combobox + chips + auto-save no popover de Assessores; sem dep dura — B19 ✓ já deu o delta+lock e o padrão de chips; soft B24 pelo endpoint e B23 pelo gatilho), **B28** (e-mail + celular na lista de lideranças; sem dep dura — B19 ✓ é só o padrão soft de copy/WhatsApp; `phone` já está no view model), **B29** (sort + filtro no header da lista de lideranças; sem dep dura — B15 ✓/B16 ✓ deram o contrato e o popover; soft B21 e B28 só pela ordem de chegada: quem entrar depois herda o head compartilhado / encaixa as colunas do outro), fill-ins (renomear coluna Cobertura → quick win de densidade no header; remover coluna Tipo — quick win de densidade; ícone de prioridade na lista; Cenário junto aos filtros — encaixa na barra slim de B16 ✓ —; selecionados no topo nos filtros do header — helper puro que B21 reusa —; O0+, RS+). **B30** (convite WhatsApp na lista de lideranças; sem dep dura — reusa integralmente a action/collection existentes; soft B28 só pelo ícone na mesma linha, qualquer um pode ir primeiro), **B31** (coluna de dobradinhas na lista de lideranças; sem dep dura — o dado já está no view model e o catálogo já tem loader; soft B27 pela peça de célula, que só vira extração compartilhada no 3º call site, e B28/B29/B30 pela ordem de chegada na mesma tabela), **B32** (editar Status de apoio na célula da lista de lideranças; sem dep dura — o campo já existe e já é opcional no schema de update; soft B24 só pela ordem de extração do guard CSRF compartilhado, e B29/B31 só pela convenção "sem modo Editar" e a mesma tabela), **B33** (sort + filtro no header da lista de dobradinhas; sem dep dura — B15 ✓/B16 ✓ deram o contrato; soft B29/B21 só pela ordem de chegada na mesma corrida de extração do chrome compartilhado, quem entrar primeiro paga a peça e o outro só consome), **B34** (chips editáveis de municípios na lista de lideranças; sem dep dura — B19 ✓ já deu o delta+lock, o padrão de chips e o loader do catálogo a promover; soft B27/B31 pela mesma peça de célula editável — quem chegar depois herda o combobox/Popover em vez de reinventar —, e B28/B30/B32 só pela mesma tabela; _decisão a validar com produto_: Popover por célula em vez do toggle "Editar" pedido literalmente, mesmo precedente do B31/B32), **B37** (chips editáveis de municípios na lista de dobradinhas; sem dep dura — B19 ✓/B34 já dão a lib a promover e o desenho do Popover+combobox; soft B34 só pela ordem de chegada em quem faz a promoção da lib, B31 pela mesma família de pedido em outra tabela, B33 pela mesma tabela; _mesma decisão a validar com produto_ do B31/B32/B34: Popover por célula, não toggle). **B36** (coluna de lideranças na lista de dobradinhas; sem dep dura — reusa integralmente a mutação/schema/lock que o B31 desenha para a mesma relação, do lado oposto; soft B31 pela corrida de extração da célula/action compartilhadas, B19 ✓ pelo padrão de origem, B33 pela mesma tabela), **C13** (renomear Plano de Ação → Atividade; sem dep dura — não toca nenhuma das listas em curso (B26–B37 são municípios/lideranças/dobradinhas), então roda em paralelo a elas; a única ordem que importa é **antes de E13**, que constrói dentro da vertical renomeada, e antes do congelamento ~20/09 por carregar migration; PR mecânico e isolado, não misturar com feature). **D3** só após smoke + folga e só se o fluxo sede-digita (C12 ✓) não absorver os deltas do ZAP (não compete com E9).
+Paralelizáveis agora: **B13** (E10 ✓ entregou as classes; consome `computeMunicipalityTerritorialClass` como está), **E12** (E8 ✓), **E11** (E9 ✓/E10 ✓ entregues — livre), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), **B21** (página dos TIs; sem dep dura — E17 ✓/B15 ✓/B16 ✓ já entregues; E12 pousa nela depois; **B25** é o único item que espera B21 — o link do território na lista só existe depois do destino), **B17** (seletor de colunas; soft B15 ✓/B16 ✓ — pousa no header de coluna atual), **B18** (filtros salvos + submenu Municípios; soft B16 ✓/B17 — botão na barra), **B22** (explicação por coluna no header; sem dep dura — B15 ✓/B16 ✓/E18 ✓), **B23** (tooltip nas células; a mecânica genérica e a promoção do primitivo já saíram no E10 ✓ — restam os consumidores Assessores/Tendência e a convivência com o Popover), **B24** (auto-save + justificativa no popover de Tendência; sem dep dura — soft B23 só pela ordem em que a nota entra no Popover), **B26** (registrar sinal na célula "Último sinal"; sem dep dura — E9 ✓/C12 ✓ já entregues; soft B24/B23 só pela coerência de contrato na mesma linha), **B27** (combobox + chips + auto-save no popover de Assessores; sem dep dura — B19 ✓ já deu o delta+lock e o padrão de chips; soft B24 pelo endpoint e B23 pelo gatilho), **B28** (e-mail + celular na lista de lideranças; sem dep dura — B19 ✓ é só o padrão soft de copy/WhatsApp; `phone` já está no view model), **B29** (sort + filtro no header da lista de lideranças; sem dep dura — B15 ✓/B16 ✓ deram o contrato e o popover; soft B21 e B28 só pela ordem de chegada: quem entrar depois herda o head compartilhado / encaixa as colunas do outro), fill-ins (renomear coluna Cobertura → quick win de densidade no header; remover coluna Tipo — quick win de densidade; ícone de prioridade na lista; Cenário junto aos filtros — encaixa na barra slim de B16 ✓ —; selecionados no topo nos filtros do header — helper puro que B21 reusa —; O0+, RS+). **B30** (convite WhatsApp na lista de lideranças; sem dep dura — reusa integralmente a action/collection existentes; soft B28 só pelo ícone na mesma linha, qualquer um pode ir primeiro), **B31** (coluna de dobradinhas na lista de lideranças; sem dep dura — o dado já está no view model e o catálogo já tem loader; soft B27 pela peça de célula, que só vira extração compartilhada no 3º call site, e B28/B29/B30 pela ordem de chegada na mesma tabela), **B32** (editar Status de apoio na célula da lista de lideranças; sem dep dura — o campo já existe e já é opcional no schema de update; soft B24 só pela ordem de extração do guard CSRF compartilhado, e B29/B31 só pela convenção "sem modo Editar" e a mesma tabela), **B33** (sort + filtro no header da lista de dobradinhas; sem dep dura — B15 ✓/B16 ✓ deram o contrato; soft B29/B21 só pela ordem de chegada na mesma corrida de extração do chrome compartilhado, quem entrar primeiro paga a peça e o outro só consome), **B34** (chips editáveis de municípios na lista de lideranças; sem dep dura — B19 ✓ já deu o delta+lock, o padrão de chips e o loader do catálogo a promover; soft B27/B31 pela mesma peça de célula editável — quem chegar depois herda o combobox/Popover em vez de reinventar —, e B28/B30/B32 só pela mesma tabela; _decisão a validar com produto_: Popover por célula em vez do toggle "Editar" pedido literalmente, mesmo precedente do B31/B32), **B37** (chips editáveis de municípios na lista de dobradinhas; sem dep dura — B19 ✓/B34 já dão a lib a promover e o desenho do Popover+combobox; soft B34 só pela ordem de chegada em quem faz a promoção da lib, B31 pela mesma família de pedido em outra tabela, B33 pela mesma tabela; _mesma decisão a validar com produto_ do B31/B32/B34: Popover por célula, não toggle). **B36** (coluna de lideranças na lista de dobradinhas; sem dep dura — reusa integralmente a mutação/schema/lock que o B31 desenha para a mesma relação, do lado oposto; soft B31 pela corrida de extração da célula/action compartilhadas, B19 ✓ pelo padrão de origem, B33 pela mesma tabela), **C13** (renomear Plano de Ação → Atividade; sem dep dura — não toca nenhuma das listas em curso (B26–B37 são municípios/lideranças/dobradinhas), então roda em paralelo a elas; a única ordem que importa é **antes de E13**, que constrói dentro da vertical renomeada, e antes do congelamento ~20/09 por carregar migration; PR mecânico e isolado, não misturar com feature). **D3** só após smoke + folga e só se o fluxo sede-digita (C12 ✓) não absorver os deltas do ZAP (não compete com E9).
### Sequência por janela (só pendentes)
@@ -306,7 +307,7 @@ Paralelizáveis agora: **E10**/**E12** (E8 ✓ desbloqueou ambos), **E11** (E9
**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; ~~**E9** fila de alocação~~ (entregue 2026-07-24, antecipada da janela 2); ~~**E16** dossiê do município~~ (entregue 2026-07-25, antecipado da janela 2); D2 se sobrar folga.
-**Janela 3 — 16/08 → set:** A6 dobradinha (pós-TSE 15/08); **E10** classificação relativa → **B13** escala relativa no mapa; **E14** níveis N0–N4; **E11** motor de sugestões v1; **E12** camada TI; **E13** planejador de giros; **B8 F2** polígonos das zonas de Salvador; débitos sobreviventes. Se houver folga **e** os deltas do ZAP não estiverem sendo absorvidos pelo fluxo sede-digita (C12): começar **D3**.
+**Janela 3 — 16/08 → set:** A6 dobradinha (pós-TSE 15/08); ~~**E10** classificação relativa~~ (entregue 2026-07-25) → **B13** escala relativa no mapa; **E14** níveis N0–N4; **E11** motor de sugestões v1; **E12** camada TI; **E13** planejador de giros; **B8 F2** polígonos das zonas de Salvador; débitos sobreviventes. Se houver folga **e** os deltas do ZAP não estiverem sendo absorvidos pelo fluxo sede-digita (C12): começar **D3**.
**Janela 4 — set → 04/10:** C5 GOTV _(validar)_, congelamento ~20/09 (só bugfix/dados). **D4** envio 1:1 e **D5** inbox→rascunhos só se D3 estiver estável **antes** do congelamento (senão adiar pós-04/10). **E15** backtest pós-eleição (após 04/10).
diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx b/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx
index e1b2152d..e6b47c03 100644
--- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx
+++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx
@@ -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 (
)
}
diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css
index 0e9b4d19..7eb2dab3 100644
--- a/src/app/(frontend)/styles.css
+++ b/src/app/(frontend)/styles.css
@@ -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%);
+ }
}
diff --git a/src/components/campaign/municipality/MunicipalityBaselineCard.tsx b/src/components/campaign/municipality/MunicipalityBaselineCard.tsx
index 50961620..455990a7 100644
--- a/src/components/campaign/municipality/MunicipalityBaselineCard.tsx
+++ b/src/components/campaign/municipality/MunicipalityBaselineCard.tsx
@@ -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"
>
-
+
Votação de {baseline.candidateName} ({baseline.candidateParty})
-
+
Votos nominais para deputado federal (1º turno) na geografia deste município — fonte TSE.
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).
) : null}
diff --git a/src/components/campaign/municipality/MunicipalityDossier.tsx b/src/components/campaign/municipality/MunicipalityDossier.tsx
index 06e3ae96..2fb2d039 100644
--- a/src/components/campaign/municipality/MunicipalityDossier.tsx
+++ b/src/components/campaign/municipality/MunicipalityDossier.tsx
@@ -19,10 +19,14 @@ import {
import type { MunicipalityDossierData } from '@/utilities/municipalityDossierData'
import {
formatMunicipalityGeographyLabel,
+ formatTerritorialClassWhy,
municipalityKindLabels,
municipalityPriorityLabels,
politicalTrendBadgeVariant,
politicalTrendLabels,
+ TERRITORIAL_CLASS_NO_DATA,
+ territorialClassBadgeVariant,
+ territorialClassLabels,
} from '@/utilities/municipalityLabels'
import type {
MunicipalityAdvisorSummary,
@@ -104,6 +108,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) || TERRITORIAL_CLASS_NO_DATA
+ : null
const usesMesaEstimate = strategy?.expectedVotes.central != null
return (
@@ -138,7 +146,19 @@ export const MunicipalityDossier = ({
) : (
Tendência não registrada
)}
+ {territorialClass ? (
+ // Self-labeling like its two neighbors: on paper "Reduto" alone,
+ // between "Prioridade" and "Tendência", reads as a fourth noun.
+
+ Classe {territorialClassLabels[territorialClass.class].toLowerCase()}
+
+ ) : null}
+ {/* The class never travels alone on paper either — printed, nobody can
+ hover the tooltip that carries the "por quê" on screen. */}
+ {territorialClassWhy ? (
+
{territorialClassWhy}
+ ) : null}
{advisors.length
? `Assessoria: ${advisors.map((advisor) => advisor.name).join(', ')}`
diff --git a/src/components/campaign/municipality/MunicipalityFilters.tsx b/src/components/campaign/municipality/MunicipalityFilters.tsx
index ade2d3df..18fb24aa 100644
--- a/src/components/campaign/municipality/MunicipalityFilters.tsx
+++ b/src/components/campaign/municipality/MunicipalityFilters.tsx
@@ -275,6 +275,24 @@ export const MunicipalityFilters = ({
}
/>
) : null}
+ {showStaffFilters ? (
+
+ navigateTo(
+ toggleMunicipalityMultiFilterValue(
+ { ...state, q: normalizedText(search) },
+ 'class',
+ value,
+ ),
+ )
+ }
+ />
+ ) : null}
{showStaffFilters && advisorFilterOptions.length ? (
(
-
+
-
+
)
/**
@@ -100,6 +108,57 @@ const MetricExplanation = ({
)
+/**
+ * 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 (
+
+
+ Classe 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}
+ >
+
+
+
-
- Teto do campo projeta quantos votos válidos o campo pode alcançar
- em 2026.
- >
- }
- formula="Fórmula: votos do presidencial do campo em 2022 (1º turno), ajustados pelo crescimento de comparecimento projetado para a disputa de deputado federal."
- conceptID="teto-do-campo"
- />
- }
- />
-
- Captura mostra quanto do teto do campo Jorge Solla conquistou em
- 2022.
- >
- }
- formula="Fórmula: votos de Solla (2022) ÷ teto do campo (presidencial 2022). Só diagnóstico — não entra na meta."
- conceptID="captura"
- />
- }
- />
-
- Share intracampo mostra a fração dos votos do campo, só na
- disputa de deputado federal, que foi para Jorge Solla nesse ano.
- >
- }
- formula="O denominador é o próprio campo nessa disputa — não o teto presidencial de 2022, que é o denominador da Captura."
- conceptID="share-intracampo"
- />
- }
- />
-
- Roll-off mede quanto voto em branco/nulo a disputa de deputado
- federal atrai a mais que a presidencial, no mesmo pleito.
- >
- }
- formula="Fórmula: (brancos + nulos, deputado federal 2022) − (brancos + nulos, presidencial 2022)."
- conceptID="roll-off"
- />
- }
- />
-
+
+
+
+
+ Teto do campo projeta quantos votos válidos o campo pode
+ alcançar em 2026.
+ >
+ }
+ formula="Fórmula: votos do presidencial do campo em 2022 (1º turno), ajustados pelo crescimento de comparecimento projetado para a disputa de deputado federal."
+ conceptID="teto-do-campo"
+ />
+ }
+ />
+
+ Captura mostra quanto do teto do campo Jorge Solla conquistou
+ em 2022.
+ >
+ }
+ formula="Fórmula: votos de Solla (2022) ÷ teto do campo (presidencial 2022). Só diagnóstico — não entra na meta."
+ conceptID="captura"
+ />
+ }
+ />
+
+ Share intracampo mostra a fração dos votos do campo, só na
+ disputa de deputado federal, que foi para Jorge Solla nesse ano.
+ >
+ }
+ formula="O denominador é o próprio campo nessa disputa — não o teto presidencial de 2022, que é o denominador da Captura."
+ conceptID="share-intracampo"
+ />
+ }
+ />
+
+ Roll-off mede quanto voto em branco/nulo a disputa de deputado
+ federal atrai a mais que a presidencial, no mesmo pleito.
+ >
+ }
+ formula="Fórmula: (brancos + nulos, deputado federal 2022) − (brancos + nulos, presidencial 2022)."
+ conceptID="roll-off"
+ />
+ }
+ />
+
+
{!usesMesaEstimate && suggestedGoal <= 0 ? (
Sem votação própria em 2022 aqui, então não há meta sugerida — é município de expansão:
diff --git a/src/components/campaign/municipality/MunicipalityHeaderFilter.tsx b/src/components/campaign/municipality/MunicipalityHeaderFilter.tsx
index 5313e08a..153b4d72 100644
--- a/src/components/campaign/municipality/MunicipalityHeaderFilter.tsx
+++ b/src/components/campaign/municipality/MunicipalityHeaderFilter.tsx
@@ -46,6 +46,7 @@ const multiParamByFilter = {
region: 'region',
advisor: 'advisor',
trend: 'trend',
+ class: 'class',
kind: null,
coverage: null,
} as const satisfies Record
diff --git a/src/components/campaign/municipality/MunicipalityList.tsx b/src/components/campaign/municipality/MunicipalityList.tsx
index 397a3779..f3401901 100644
--- a/src/components/campaign/municipality/MunicipalityList.tsx
+++ b/src/components/campaign/municipality/MunicipalityList.tsx
@@ -23,16 +23,20 @@ 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,
+ TERRITORIAL_CLASS_NO_DATA,
+ territorialClassBadgeVariant,
+ territorialClassLabels,
} from '@/utilities/municipalityLabels'
import {
clearMunicipalityListFilters,
@@ -91,7 +95,7 @@ const VotePositionReadout = ({
position: NonNullable
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 +114,36 @@ const VotePositionReadout = ({
)
}
+/**
+ * E10 classe. Only the pill is visible — spelling the factors out under it
+ * made this the widest cell in a table that cannot scroll horizontally. The
+ * "por quê" moves to the column's `cellTooltip` (hover, focus and tap), and
+ * stays in the cell as `sr-only` text so the class still never reaches anyone
+ * as a bare verdict, which is the one thing it must never be.
+ */
+const TerritorialClassCell = ({ municipality }: { municipality: MunicipalityListViewModel }) => {
+ if (municipality.territorialClass === 'sem_base') {
+ // Same idiom as the "2022" column: absent data is a dash, not a pill.
+ return (
+
+ —
+ {TERRITORIAL_CLASS_NO_DATA}
+
+ )
+ }
+
+ return (
+ <>
+
+ {territorialClassLabels[municipality.territorialClass]}
+
+
+ {formatTerritorialClassWhy(municipality.territorialClassFactors)}
+
+ >
+ )
+}
+
/**
* E9 frescor: how long since anybody recorded anything here (staff update or
* leadership pledge).
@@ -213,10 +247,12 @@ const municipalityListColumns = ({
concentrationHint,
signalHint,
deficitHint,
+ classHint,
}: MunicipalityListProps & {
concentrationHint: string
signalHint: string
deficitHint: string
+ classHint: string
}): Array> => [
{
id: 'name',
@@ -291,6 +327,28 @@ 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: (
+
+ Classe
+
+ ),
+ cell: (municipality) => ,
+ // 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 +482,16 @@ 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 classHint =
+ '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.'
- const columns = municipalityListColumns({ ...props, concentrationHint, signalHint, deficitHint })
+ const columns = municipalityListColumns({
+ ...props,
+ concentrationHint,
+ signalHint,
+ deficitHint,
+ classHint,
+ })
return (
<>
@@ -455,6 +521,24 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
{isStaffView ? (
+ {/* The card has room for both factors the table trims to one. */}
+