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.

@@ -82,9 +81,9 @@ export const MunicipalityBaselineCard = ({ {position ? (
- {formatMunicipalityVoteShare(position.share)} + {formatVoteSharePercent(position.share)} {formatMunicipalityVoteRank(position.rank)}
) : ( @@ -110,8 +109,8 @@ export const MunicipalityBaselineCard = ({ {latest && baseline.tally2022.votosValidos > 0 ? (

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

{why || TERRITORIAL_CLASS_NO_DATA}

+
+ ) +} + const formatRollOff = (rollOff: RollOff | null): string => { if (!rollOff) return 'Sem majoritária 2022 disponível' const votesLabel = formatElectionNumber(rollOff.votes) @@ -123,6 +182,7 @@ export const MunicipalityGoalAccountCard = ({ suggestedGoal, goalCoverage, potential, + territorialClass, }: { municipalityID: number expectedVotes: VoteEstimateScenarioViewModel @@ -130,6 +190,7 @@ export const MunicipalityGoalAccountCard = ({ suggestedGoal: number goalCoverage: MunicipalityGoalCoverage potential: MunicipalityPotential + territorialClass: MunicipalityTerritorialClassification }) => { const usesMesaEstimate = expectedVotes.central != null const latestIntraFieldShareYear = Math.max( @@ -211,72 +272,75 @@ export const MunicipalityGoalAccountCard = ({

-
- - 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. */} +
+
Classe
+ {municipality.territorialClass === 'sem_base' ? ( +
{TERRITORIAL_CLASS_NO_DATA}
+ ) : ( +
+ + {territorialClassLabels[municipality.territorialClass]} + + + {formatTerritorialClassWhy(municipality.territorialClassFactors)} + +
+ )} +
Votos estimados
diff --git a/src/components/campaign/municipality/MunicipalitySortableHead.tsx b/src/components/campaign/municipality/MunicipalitySortableHead.tsx index fe56596f..bdf7491e 100644 --- a/src/components/campaign/municipality/MunicipalitySortableHead.tsx +++ b/src/components/campaign/municipality/MunicipalitySortableHead.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from 'react' import { MunicipalityHeaderFilter } from '@/components/campaign/municipality/MunicipalityHeaderFilter' -import { MunicipalityHoverTooltip } from '@/components/campaign/municipality/MunicipalityHoverTooltip' +import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip' import { CampaignSortableHead } from '@/components/campaign/shared/CampaignSortableHead' import type { MunicipalityFilterOption, @@ -73,12 +73,12 @@ export const MunicipalitySortableHead = ({ wrapSortControl={ tooltip ? (control) => ( - {control} - + ) : undefined } diff --git a/src/components/campaign/shared/CampaignCellTooltip.tsx b/src/components/campaign/shared/CampaignCellTooltip.tsx new file mode 100644 index 00000000..3efd3523 --- /dev/null +++ b/src/components/campaign/shared/CampaignCellTooltip.tsx @@ -0,0 +1,31 @@ +'use client' + +import type { ReactNode } from 'react' + +import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip' + +/** + * Cell-level tooltip for the campaign list system — the render half of + * `CampaignTableColumn.cellTooltip`. Columns declare the extra reading; the + * table wraps the cell in this. + * + * The trigger `` is created HERE, inside the client boundary, because + * `CampaignHoverTooltip` clones its child to attach a ref: an element built by + * the server component that owns the table would not survive that. + * + * It deliberately takes no `tabIndex` — 435 rows × N columns of dead tab stops + * would be a worse trade than the redundancy contract documented on + * `cellTooltip`, which requires the tooltip's information to already exist as + * text in the cell (visible or `sr-only`). + */ +export const CampaignCellTooltip = ({ + content, + children, +}: { + content: ReactNode + children: ReactNode +}) => ( + + {children} + +) diff --git a/src/components/campaign/municipality/MunicipalityHoverTooltip.tsx b/src/components/campaign/shared/CampaignHoverTooltip.tsx similarity index 91% rename from src/components/campaign/municipality/MunicipalityHoverTooltip.tsx rename to src/components/campaign/shared/CampaignHoverTooltip.tsx index 9ebb5640..d6d665d1 100644 --- a/src/components/campaign/municipality/MunicipalityHoverTooltip.tsx +++ b/src/components/campaign/shared/CampaignHoverTooltip.tsx @@ -6,8 +6,11 @@ import { cloneElement, useEffect, useRef, useState } from 'react' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' /** - * Hover/focus explanation with no extra chrome — wraps an existing control or - * heading. Radix's `TooltipTrigger` deliberately ignores touch pointer + * Hover/focus explanation with no extra chrome — wraps an existing control, + * heading or table cell. Shared by the campaign list system: column headers + * (`MunicipalitySortableHead`), card metrics, and cell content via + * `CampaignTable`'s `cellTooltip`. Radix's `TooltipTrigger` deliberately + * ignores touch pointer * events — no hover on touch, and a tap doesn't reliably focus a non-form * element either — which otherwise makes the whole tooltip unreachable on * phones (an `/impeccable critique` finding). The open state is controlled @@ -29,7 +32,7 @@ type TappableElement = ReactElement<{ ref?: Ref }> -export const MunicipalityHoverTooltip = ({ +export const CampaignHoverTooltip = ({ content, children, side = 'bottom', diff --git a/src/components/campaign/shared/CampaignTable.tsx b/src/components/campaign/shared/CampaignTable.tsx index f0a4fe79..0679dd1e 100644 --- a/src/components/campaign/shared/CampaignTable.tsx +++ b/src/components/campaign/shared/CampaignTable.tsx @@ -1,5 +1,6 @@ import { Fragment, type ReactNode } from 'react' +import { CampaignCellTooltip } from '@/components/campaign/shared/CampaignCellTooltip' import { Table, TableBody, @@ -27,6 +28,18 @@ export type CampaignTableColumn = { */ head: ReactNode cell: (row: Row) => ReactNode + /** + * Extra reading for the cell content, shown on hover/focus/tap. Return + * `null` for a row and that row gets no wrapper at all — a column whose + * detail only exists on some rows (a trend with no justification) stays + * exactly as it renders today on the others. + * + * Accessibility contract: the tooltip is a REDUNDANT affordance, never the + * only path to the information. Only declare it on a column whose cell + * already carries that content as text — visible, or `sr-only` when the + * visible form is a badge/avatar. The table adds no tab stops for it. + */ + cellTooltip?: (row: Row) => ReactNode cellClassName?: string | ((row: Row) => string | undefined) /** B17 seams — a mandatory column is not hideable; hidden-by-default starts unchecked. */ mandatory?: boolean @@ -106,18 +119,27 @@ export const CampaignTable = ({ key={rowKey(row)} className={typeof rowClassName === 'function' ? rowClassName(row) : rowClassName} > - {columns.map((column) => ( - - {column.cell(row)} - - ))} + {columns.map((column) => { + const cell = column.cell(row) + const tooltip = column.cellTooltip?.(row) + + return ( + + {tooltip ? ( + {cell} + ) : ( + cell + )} + + ) + })} ))} diff --git a/src/lib/bahiaElectionAggregates.ts b/src/lib/bahiaElectionAggregates.ts index 54634897..0af9fba7 100644 --- a/src/lib/bahiaElectionAggregates.ts +++ b/src/lib/bahiaElectionAggregates.ts @@ -78,3 +78,36 @@ const EMPTY_BASELINE: MunicipalityFederalBaseline = { */ export const getMunicipalityFederalBaseline = (slug: string): MunicipalityFederalBaseline => artifact.municipalities[slug] ?? EMPTY_BASELINE + +/** Statewide sums of the artifact for one year — the denominators of every relative reading. */ +export type StatewideFederalTotals = { + /** Candidate's own nominal votes across every catalog municipality. */ + ownVotes: number + /** Federal T1 valid votes across every catalog municipality. */ + validVotes: number + /** Curated campo-parties federal nominal votes across every catalog municipality. */ + campoVotes: number +} + +const statewideTotalsCache = new Map() + +/** + * The artifact stores no statewide rollup, so every relative metric (A11's + * share of own vote, E10's LQ) needs the same sums over the 435 slugs. Cached + * at module scope — the artifact is immutable for the process lifetime. + */ +export const getStatewideFederalTotals = (year: number): StatewideFederalTotals => { + const cached = statewideTotalsCache.get(year) + if (cached) return cached + + const yearKey = String(year) + const totals: StatewideFederalTotals = { ownVotes: 0, validVotes: 0, campoVotes: 0 } + for (const baseline of Object.values(artifact.municipalities)) { + totals.ownVotes += baseline.votesByYear[yearKey] ?? 0 + totals.validVotes += baseline.validVotesByYear[yearKey] ?? 0 + totals.campoVotes += baseline.campoFederalVotesByYear[yearKey] ?? 0 + } + + statewideTotalsCache.set(year, totals) + return totals +} diff --git a/src/lib/campaignIntelligenceConcepts.ts b/src/lib/campaignIntelligenceConcepts.ts index 0ee283f3..0b1f4142 100644 --- a/src/lib/campaignIntelligenceConcepts.ts +++ b/src/lib/campaignIntelligenceConcepts.ts @@ -9,10 +9,10 @@ * would let the copy drift silently. Every entry must describe what the code * actually computes; when a formula changes, this text changes with it. * - * v1 covers the E8 "conta da cadeira" numbers. Each later slice of the - * intelligence program (E9 fila, E10 classificação relativa, B13 escala do - * mapa, E11 sugestões, E12 TI, E13 giros, E14 níveis) appends its own entries - * as part of its own delivery. + * v1 covers the E8 "conta da cadeira" numbers; E10 added dominância relativa + * and a classe territorial. Each later slice of the intelligence program (B13 + * escala do mapa, E11 sugestões, E12 TI, E13 giros, E14 níveis) appends its + * own entries as part of its own delivery. */ export type CampaignConceptId = @@ -21,6 +21,8 @@ export type CampaignConceptId = | 'captura' | 'share-intracampo' | 'roll-off' + | 'dominancia-relativa' + | 'classe-territorial' | 'meta' | 'cobertura-da-meta' @@ -115,6 +117,35 @@ export const campaignIntelligenceConcepts: ReadonlyArray numberFormatter.format(value) + +/** + * A ratio (0..1) as a share label with at most one decimal — the shape used + * for "% da própria votação estadual". Lives here, not next to the ranking it + * was written for, so client bundles can format a share without pulling the + * committed TSE artifact along with it. + */ +export const formatVoteSharePercent = (share: number): string => + `${(share * 100).toLocaleString('pt-BR', { + maximumFractionDigits: 1, + minimumFractionDigits: 0, + })}%` diff --git a/src/lib/municipalityVoteRank.ts b/src/lib/municipalityVoteRank.ts index 2fcbe22b..37eadb77 100644 --- a/src/lib/municipalityVoteRank.ts +++ b/src/lib/municipalityVoteRank.ts @@ -1,6 +1,7 @@ import { federalBaselineMunicipalitySlugs, getMunicipalityFederalBaseline, + getStatewideFederalTotals, } from '@/lib/bahiaElectionAggregates' import { formatElectionNumber } from '@/lib/electionFormat' @@ -37,8 +38,7 @@ export const computeVoteRankByYear = ( votes: getMunicipalityFederalBaseline(slug).votesByYear[yearKey] ?? 0, })) - let candidateTotal = 0 - for (const row of rows) candidateTotal += row.votes + const candidateTotal = getStatewideFederalTotals(year).ownVotes rows.sort((a, b) => { if (b.votes !== a.votes) return b.votes - a.votes @@ -74,12 +74,6 @@ export const getMunicipalityVoteRank = ( export const formatMunicipalityVoteRank = (rank: number): string => `${formatElectionNumber(rank)}º` -export const formatMunicipalityVoteShare = (share: number): string => - `${(share * 100).toLocaleString('pt-BR', { - maximumFractionDigits: 1, - minimumFractionDigits: 0, - })}%` - /** Compare for list sort=votos. Zero-vote rows always sort last (any dir). */ export const compareMunicipalityVotesForSort = ( aVotes: number, diff --git a/src/utilities/municipalityGoalAccount.ts b/src/utilities/municipalityGoalAccount.ts index 6b86c67b..96a9a6ac 100644 --- a/src/utilities/municipalityGoalAccount.ts +++ b/src/utilities/municipalityGoalAccount.ts @@ -25,6 +25,10 @@ import { type MunicipalityPotential, type SuggestedGoalByScenario, } from '@/utilities/municipalityPotential' +import { + computeMunicipalityTerritorialClass, + type MunicipalityTerritorialClassification, +} from '@/utilities/municipalityTerritorialClass' import { emptyMunicipalityPledgeAggregate, type MunicipalityPledgeAggregate, @@ -111,6 +115,8 @@ export type MunicipalityGoalAccount = { /** Fixed to the `central` scenario — the detail page has no scenario selector. */ goalCoverage: MunicipalityGoalCoverage potential: MunicipalityPotential + /** E10 — the one-word reading of `potential` plus the factors behind it. */ + territorialClass: MunicipalityTerritorialClassification } /** @@ -140,5 +146,6 @@ export const loadMunicipalityGoalAccount = async ( suggestedGoal: suggestedGoalByScenario[DEFAULT_VOTE_ESTIMATE_SCENARIO], goalCoverage, potential, + territorialClass: computeMunicipalityTerritorialClass(municipality.slug), } } diff --git a/src/utilities/municipalityLabels.ts b/src/utilities/municipalityLabels.ts index 0772a094..e0357da5 100644 --- a/src/utilities/municipalityLabels.ts +++ b/src/utilities/municipalityLabels.ts @@ -3,9 +3,14 @@ * surfaces. Client-safe: types + string tables only (split out of the former * `municipalityUi.ts` in Pass 2 W1). */ -import { formatElectionNumber } from '@/lib/electionFormat' +import { formatElectionNumber, formatVoteSharePercent } from '@/lib/electionFormat' import { municipalityCatalog } from '@/lib/municipalityCatalog' import type { CampaignUser, Municipality } from '@/payload-types' +import { formatRatioAsPercentLabel } from '@/utilities/goalCoverage' +import type { + MunicipalityTerritorialClass, + TerritorialFactor, +} from '@/utilities/municipalityTerritorialClass' export const municipalityKindLabels: Record = { municipio: 'Município', @@ -38,6 +43,70 @@ export const municipalityListCoverageLabels: Record<'com_assessor' | 'sem_assess sem_assessor: 'Sem assessor', } +/** E10 — operational class of the município (vocabulary of the research report). */ +export const territorialClassLabels: Record = { + reduto: 'Reduto', + expansao: 'Expansão', + manutencao: 'Manutenção', + marginal: 'Marginal', + sem_base: 'Sem base', +} + +/** + * Four fills that survive a 25-row scan, plus one that deliberately doesn't. + * Reduto reads as achievement (green, like a confirmed estimate); expansão — + * the class the allocation queue exists to send people to — takes the amber + * this app already spends on "needs attention" (cold signals, disputed + * support), never the destructive red the priority badge owns on the same row; + * manutenção is routine grey; marginal is a hairline pill. `sem_base` is + * absence of data, not a state: surfaces render it as text, and the list uses + * the em dash the neighboring "2022" column already uses for "nothing here". + */ +export const territorialClassBadgeVariant = { + reduto: 'estimate-confirmed', + expansao: 'estimate-pending', + manutencao: 'secondary', + marginal: 'outline', + sem_base: 'ghost', +} as const + +/** Band where the multiple rounds to "1×", which says nothing — name it instead. */ +const AT_STANDARD_LQ = { min: 0.95, max: 1.15 } as const + +/** + * The "por quê" behind a class, in the mesa's own phrasing — "aqui a votação + * está 40% abaixo do padrão estadual" is the sentence the research report says + * survives the table; "LQ 0,6" is not. The possessive names the candidate + * ("do candidato"), never "seu", which an assessor reads as their own. + */ +const formatTerritorialClassFactor = (factor: TerritorialFactor): string => { + switch (factor.id) { + case 'dominance': + if (factor.value >= AT_STANDARD_LQ.min && factor.value < AT_STANDARD_LQ.max) { + return 'no padrão estadual do candidato' + } + return factor.value >= 1 + ? `${factor.value.toLocaleString('pt-BR', { maximumFractionDigits: 1 })}× o padrão estadual do candidato` + : `${Math.round((1 - factor.value) * 100)}% abaixo do padrão estadual do candidato` + case 'ownShare': + return `${formatVoteSharePercent(factor.value)} da votação estadual do candidato` + case 'field': + return `${formatElectionNumber(Math.round(factor.value))} votos do campo sem captura` + case 'capture': + return `captura de ${formatRatioAsPercentLabel(factor.value)} do campo` + } +} + +/** Copy for "no TSE series here" — the class label alone would read as a bug. */ +export const TERRITORIAL_CLASS_NO_DATA = 'Sem série do TSE para este município.' + +/** + * The two dominant factors as one line. Single source for the rule (list cell, + * detail card and printed dossier all showed the same `slice(0, 2)` before). + */ +export const formatTerritorialClassWhy = (factors: readonly TerritorialFactor[]): string => + factors.slice(0, 2).map(formatTerritorialClassFactor).join(' · ') + export const formatMunicipalityConcentrationHint = ( totalUnits: number = municipalityCatalog.length, ): string => diff --git a/src/utilities/municipalityListFilters.ts b/src/utilities/municipalityListFilters.ts index 78213c5d..57559d22 100644 --- a/src/utilities/municipalityListFilters.ts +++ b/src/utilities/municipalityListFilters.ts @@ -10,6 +10,7 @@ import { municipalityListCoverageLabels, municipalityPriorityLabels, politicalTrendLabels, + territorialClassLabels, type PoliticalTrendStatus, } from '@/utilities/municipalityLabels' import { @@ -19,9 +20,17 @@ import { serializeCanonicalMunicipalityListSearchParams, type MunicipalityListState, } from '@/utilities/municipalityListUrl' +import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass' /** Column-header filter affordances (B16+). `name` = Município (priority + slugs). */ -export type MunicipalityFilterParam = 'name' | 'region' | 'kind' | 'coverage' | 'trend' | 'advisor' +export type MunicipalityFilterParam = + | 'name' + | 'region' + | 'kind' + | 'coverage' + | 'trend' + | 'class' + | 'advisor' type MunicipalityFilterSelectionMode = 'single' | 'multi' | 'toggle' @@ -96,6 +105,18 @@ export const municipalityFilterDefinitions: MunicipalityFilterDefinition[] = [ (trend) => ({ value: trend, label: politicalTrendLabels[trend] }), ), }, + { + param: 'class', + label: 'Classe', + staffOnly: true, + selection: 'multi', + options: ( + Object.keys(territorialClassLabels) as Array + ).map((territorialClass) => ({ + value: territorialClass, + label: territorialClassLabels[territorialClass], + })), + }, ] const municipalityFilterDefinitionByParam = Object.fromEntries( @@ -111,7 +132,7 @@ export const getMunicipalitySingleFilterValue = ( param: 'kind' | 'coverage', ): string | undefined => state[param] -export type MunicipalityMultiFilterParam = 'region' | 'slug' | 'advisor' | 'trend' +export type MunicipalityMultiFilterParam = 'region' | 'slug' | 'advisor' | 'trend' | 'class' export const getMunicipalityMultiFilterValues = ( state: MunicipalityListState, @@ -120,6 +141,7 @@ export const getMunicipalityMultiFilterValues = ( if (param === 'region') return state.regions ?? [] if (param === 'slug') return state.slugs ?? [] if (param === 'trend') return state.trends ?? [] + if (param === 'class') return state.classes ?? [] return (state.advisors ?? []).map(String) } @@ -213,6 +235,11 @@ const trendStatusCount = Object.keys(politicalTrendLabels).length const isPoliticalTrendStatus = (value: string): value is PoliticalTrendStatus => value in politicalTrendLabels +const territorialClassCount = Object.keys(territorialClassLabels).length + +const isTerritorialClass = (value: string): value is MunicipalityTerritorialClass => + value in territorialClassLabels + /** * B16+ href fast path: option rows only need the WOULD-BE href, so the toggled * state is derived with the same canonical rules the parser applies (advisors @@ -246,10 +273,14 @@ export const buildMunicipalityFilterOptionHref = ( .sort((left, right) => left - right) if (advisors.length) next.advisors = advisors else delete next.advisors - } else { + } else if (param === 'trend') { const trends = toggled.filter(isPoliticalTrendStatus) if (trends.length && trends.length < trendStatusCount) next.trends = trends else delete next.trends + } else { + const classes = toggled.filter(isTerritorialClass) + if (classes.length && classes.length < territorialClassCount) next.classes = classes + else delete next.classes } const query = serializeCanonicalMunicipalityListSearchParams(next).toString() @@ -274,6 +305,8 @@ export const isMunicipalityColumnFilterActive = ( return Boolean(state.coverage) case 'trend': return Boolean(state.trends?.length) + case 'class': + return Boolean(state.classes?.length) } } @@ -302,6 +335,11 @@ export const formatMunicipalityActiveFiltersSummary = ( `Tendência ${state.trends.map((trend) => politicalTrendLabels[trend].toLowerCase()).join(', ')}`, ) } + if (state.classes?.length) { + parts.push( + `Classe ${state.classes.map((territorialClass) => territorialClassLabels[territorialClass].toLowerCase()).join(', ')}`, + ) + } if (state.q) parts.push(`Busca "${state.q}"`) return parts.length ? parts.join(' · ') : null diff --git a/src/utilities/municipalityListUrl.ts b/src/utilities/municipalityListUrl.ts index de6b23ab..5de4bbe3 100644 --- a/src/utilities/municipalityListUrl.ts +++ b/src/utilities/municipalityListUrl.ts @@ -18,7 +18,12 @@ import { strictDecimalInteger, type RawSearchParams as CampaignListRawSearchParams, } from '@/utilities/campaignListUrl' -import { politicalTrendLabels, type PoliticalTrendStatus } from '@/utilities/municipalityLabels' +import { + politicalTrendLabels, + territorialClassLabels, + type PoliticalTrendStatus, +} from '@/utilities/municipalityLabels' +import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass' import type { Municipality } from '@/payload-types' @@ -35,6 +40,7 @@ export type MunicipalityListSortKey = | 'votos' | 'deficit' | 'frescor' + | 'classe' export type MunicipalityListSortDirection = 'asc' | 'desc' @@ -60,6 +66,7 @@ export const municipalityListSortLabels: Record votos: '2022', deficit: 'Cobertura da meta', frescor: 'Frescor do sinal', + classe: 'Classe', } export type MunicipalityListState = { @@ -79,6 +86,13 @@ export type MunicipalityListState = { * encoded as absent, canonicalized by `parseMunicipalityListParams`. */ trends?: PoliticalTrendStatus[] + /** + * Multi-select (OR) E10 territorial classes. Derived from the committed TSE + * artifact, so — unlike every filter above — it is NOT part of + * `buildMunicipalityListWhere`: `municipalityPageData` applies it in memory. + * Never holds the full set (same "todas" canonicalization as `trends`). + */ + classes?: MunicipalityTerritorialClass[] /** Candidate number for the map comparison mode (does not filter the list). */ compare?: number sort?: MunicipalityListSortKey @@ -96,6 +110,7 @@ const municipalityListParamNames = [ 'coverage', 'priority', 'trend', + 'class', 'compare', 'sort', 'dir', @@ -117,6 +132,8 @@ const sortKeysWithDescDefault: MunicipalityListSortKey[] = [ // Both open on the worst case: biggest uncovered deficit, coldest signal. 'deficit', 'frescor', + // Ordinal, not alphabetical: descending means reduto first. + 'classe', ] export const defaultMunicipalityListSortDir = ( @@ -151,6 +168,11 @@ export const formatMunicipalityListSortSummary = ( ? 'Ordenado por frescor (sinal mais frio primeiro)' : 'Ordenado por frescor (sinal mais recente primeiro)' } + if (sort === 'classe') { + return dir === 'desc' + ? 'Ordenado por classe (reduto primeiro)' + : 'Ordenado por classe (marginal primeiro)' + } const label = municipalityListSortLabels[sort] return dir === 'desc' ? `Ordenado por ${label} ↓` : `Ordenado por ${label} ↑` } @@ -208,6 +230,20 @@ const parseTrendsParam = (raw: string | string[] | undefined): PoliticalTrendSta return trends.length < politicalTrendStatusSet.size ? trends : [] } +const territorialClassSet = new Set(Object.keys(territorialClassLabels)) + +/** Same "todas = nenhuma" canonicalization as `parseTrendsParam`. */ +const parseClassesParam = (raw: string | string[] | undefined): MunicipalityTerritorialClass[] => { + const classes: MunicipalityTerritorialClass[] = [] + for (const token of allParamValues(raw)) { + if (!territorialClassSet.has(token)) continue + const territorialClass = token as MunicipalityTerritorialClass + if (classes.includes(territorialClass)) continue + classes.push(territorialClass) + } + return classes.length < territorialClassSet.size ? classes : [] +} + export const municipalityListStateToRawParams = ( state: MunicipalityListState, page = state.page, @@ -221,6 +257,7 @@ export const municipalityListStateToRawParams = ( coverage: state.coverage, priority: state.priority, trend: state.trends, + class: state.classes, compare: state.compare === undefined ? undefined : String(state.compare), sort: state.sort, dir: state.dir, @@ -238,6 +275,7 @@ export const parseMunicipalityListParams = ( const rawCoverage = firstValue(params.coverage) const rawPriority = firstValue(params.priority) const trends = parseTrendsParam(params.trend) + const classes = parseClassesParam(params.class) const rawCompare = strictDecimalInteger(firstValue(params.compare)) const rawSort = firstValue(params.sort) as MunicipalityListSortKey | undefined const sort = rawSort && municipalityListSortKeySet.has(rawSort) ? rawSort : undefined @@ -256,6 +294,7 @@ export const parseMunicipalityListParams = ( : {}), ...(rawPriority === 'alta' ? { priority: 'alta' } : {}), ...(trends.length ? { trends } : {}), + ...(classes.length ? { classes } : {}), ...(rawCompare && rawCompare <= 99999 ? { compare: rawCompare } : {}), ...(sort ? { sort } : {}), ...(dir ? { dir } : {}), @@ -284,6 +323,9 @@ export const buildMunicipalityListWhere = (state: MunicipalityListState): Where } if (state.priority) filters.push({ priority: { equals: state.priority } }) if (state.trends?.length) filters.push({ 'politicalTrend.status': { in: state.trends } }) + // `state.classes` is absent on purpose: the class is derived from the TSE + // artifact, not stored, so it can't be a Payload constraint. `municipalityPageData` + // filters it in memory over the unpaginated scope. return filters.length ? { and: filters } : {} } @@ -312,6 +354,9 @@ export const serializeCanonicalMunicipalityListSearchParams = ( if (canonicalState.coverage) params.set('coverage', canonicalState.coverage) if (canonicalState.priority) params.set('priority', canonicalState.priority) for (const trend of canonicalState.trends ?? []) params.append('trend', trend) + for (const territorialClass of canonicalState.classes ?? []) { + params.append('class', territorialClass) + } if (canonicalState.compare) params.set('compare', String(canonicalState.compare)) // Omit the default pair (staff: deficit+desc). Keep `sort` whenever the pair // is non-default so `dir` is never orphaned (e.g. votos+asc → sort=votos&dir=asc). @@ -381,6 +426,9 @@ const formatMunicipalitySortOptionLabel = ( if (key === 'frescor') { return dir === 'asc' ? `${base} (mais recente)` : `${base} (mais frio)` } + if (key === 'classe') { + return dir === 'asc' ? `${base} (marginal primeiro)` : `${base} (reduto primeiro)` + } return dir === 'asc' ? `${base} (A–Z)` : `${base} (Z–A)` } diff --git a/src/utilities/municipalityPageData.ts b/src/utilities/municipalityPageData.ts index 63e78571..d74fcd6f 100644 --- a/src/utilities/municipalityPageData.ts +++ b/src/utilities/municipalityPageData.ts @@ -27,6 +27,10 @@ import { type MunicipalityListState, } from '@/utilities/municipalityListUrl' import { resolveMunicipalityLastSignalAt } from '@/utilities/municipalitySignal' +import { + computeMunicipalityTerritorialClass, + territorialClassSortWeight, +} from '@/utilities/municipalityTerritorialClass' import { municipalityListSelect, toMunicipalityDetailViewModel, @@ -91,6 +95,22 @@ const emptyMunicipalityListFilterFacets: MunicipalityListFilterFacets = { type MunicipalityFacetRow = Pick +/** + * E10's class filter is the only one that isn't a Payload constraint (the + * class is derived from the committed TSE artifact, not stored), so every + * place that would otherwise trust `where` — the page query, the overview + * scope and the facets — has to apply this predicate itself. Returns `null` + * when no class is selected, which is also the signal to keep the cheap + * database-paginated path. + */ +const territorialClassFilterPredicate = ( + state: MunicipalityListState, +): ((slug: string) => boolean) | null => { + if (!state.classes?.length) return null + const selected = new Set(state.classes) + return (slug) => selected.has(computeMunicipalityTerritorialClass(slug).class) +} + /** * Each facet applies every filter owned by ANOTHER popover (so it only offers * values that still return rows) while omitting the ones its own popover owns — @@ -132,11 +152,19 @@ const loadMunicipalityListFilterFacets = async ( return rows } - const [slugRows, regionRows, advisorRows] = await Promise.all([ + // No facet popover owns the class filter, so all three keep it applied. + const classMatches = territorialClassFilterPredicate(state) + const withClassFilter = (rows: MunicipalityFacetRow[]): MunicipalityFacetRow[] => + classMatches ? rows.filter((row) => classMatches(row.slug)) : rows + + const [rawSlugRows, rawRegionRows, rawAdvisorRows] = await Promise.all([ facetRows({ slugs: undefined, priority: undefined }), facetRows({ regions: undefined }), facetRows({ advisors: undefined, coverage: undefined }), ]) + const slugRows = withClassFilter(rawSlugRows) + const regionRows = withClassFilter(rawRegionRows) + const advisorRows = withClassFilter(rawAdvisorRows) // Selected values are unioned in: a selection must stay visible to be undone. const availableSlugs = new Set([...slugRows.map((row) => row.slug), ...(state.slugs ?? [])]) @@ -244,7 +272,18 @@ const applyDerivedMunicipalitySort = ( return now - new Date(lastSignalAt).getTime() }) } + case 'classe': + // Ordinal weight, not the label: descending means reduto first, and + // `sem_base` (no weight) lands at the end in either direction. + return sortByNullableValue( + docs, + dir, + (municipality) => + territorialClassSortWeight[computeMunicipalityTerritorialClass(municipality.slug).class], + ) default: + // Native keys reach here when a derived FILTER forced the in-memory + // path; the query already sorted them, so the order is preserved. return docs } } @@ -272,7 +311,12 @@ export const loadMunicipalityListPageBundle = async ( const ranks = computeVoteRankByYear(DEFAULT_VOTE_RANK_YEAR) const nativeSortField = payloadSortFieldByKey[sortKey] - const isNative = nativeSortField !== undefined + const classMatches = territorialClassFilterPredicate(state) + // A derived filter can't be expressed in `where`, so it forces the + // load-everything path even when the SORT itself is native — otherwise the + // page would drop rows out of a 25-row window and `totalDocs` would count + // municípios the filter excludes. + const isPagedByPayload = nativeSortField !== undefined && !classMatches const listQuery = payload.find({ collection: 'municipality', @@ -281,11 +325,13 @@ export const loadMunicipalityListPageBundle = async ( select: municipalityListSelect, user, overrideAccess: false, - ...(isNative + // Kept outside the pagination branch: with a class filter the query is + // still the cheapest place to order by a native field. + ...(nativeSortField ? { sort: `${sortDir === 'desc' ? '-' : ''}${nativeSortField}` } : {}), + ...(isPagedByPayload ? { limit: municipalityPageSize, page: state.page, - sort: `${sortDir === 'desc' ? '-' : ''}${nativeSortField}`, } : { limit: 0, @@ -330,23 +376,29 @@ export const loadMunicipalityListPageBundle = async ( // (zeroed) next to the empty state instead of disappearing with the rows. if (staffScope) { pledgeAggregates = staffScope.pledgeAggregates - const rollup = rollupMunicipalityStaffVotes(staffScope.municipalities, pledgeAggregates) + // Never mutate the scope: `loadMunicipalityScope` is request-cached and + // shared with the facets. Filtering here is what keeps the overview + // (E8 coverage, E9 shame column) counting the filtered scope. + const scopedMunicipalities = classMatches + ? staffScope.municipalities.filter((municipality) => classMatches(municipality.slug)) + : staffScope.municipalities + const rollup = rollupMunicipalityStaffVotes(scopedMunicipalities, pledgeAggregates) const goalCoverageBundle = await loadMunicipalityGoalCoverageBundle( payload, user, - staffScope.municipalities, + scopedMunicipalities, pledgeAggregates, ) goalCoverageByMunicipalityID = goalCoverageBundle.coverageByMunicipalityID overview = { - municipalityCount: staffScope.municipalities.length, + municipalityCount: scopedMunicipalities.length, staffVoteTotalByScenario: { ...rollup.staffVoteTotalByScenario }, pledgeCount: rollup.pledgeCount, missingEstimateCount: rollup.missingEstimateCount, - withAdvisorCount: staffScope.municipalities.filter( + withAdvisorCount: scopedMunicipalities.filter( (municipality) => (municipality.advisors ?? []).length > 0, ).length, - priorityWithoutAdvisorCount: staffScope.municipalities.filter( + priorityWithoutAdvisorCount: scopedMunicipalities.filter( (municipality) => municipality.priority === 'alta' && (municipality.advisors ?? []).length === 0, ).length, @@ -358,22 +410,22 @@ export const loadMunicipalityListPageBundle = async ( let totalDocs: number let totalPages: number - if (isNative) { + if (isPagedByPayload) { // Payload select narrows the inferred type; the selected fields cover the view model. pageDocs = listResult.docs as Municipality[] totalDocs = listResult.totalDocs totalPages = listResult.totalPages } else { - const allDocs = applyDerivedMunicipalitySort( - listResult.docs as Municipality[], - sortKey, - sortDir, - { - ranks, - goalCoverageByMunicipalityID, - pledgeAggregates, - }, - ) + const scopedDocs = classMatches + ? (listResult.docs as Municipality[]).filter((municipality) => + classMatches(municipality.slug), + ) + : (listResult.docs as Municipality[]) + const allDocs = applyDerivedMunicipalitySort(scopedDocs, sortKey, sortDir, { + ranks, + goalCoverageByMunicipalityID, + pledgeAggregates, + }) totalDocs = allDocs.length totalPages = Math.max(1, Math.ceil(totalDocs / municipalityPageSize)) const start = (state.page - 1) * municipalityPageSize diff --git a/src/utilities/municipalityTerritorialClass.ts b/src/utilities/municipalityTerritorialClass.ts new file mode 100644 index 00000000..5c969b75 --- /dev/null +++ b/src/utilities/municipalityTerritorialClass.ts @@ -0,0 +1,246 @@ +import { + federalBaselineMunicipalitySlugs, + getMunicipalityFederalBaseline, + getStatewideFederalTotals, + type MunicipalityFederalBaseline, +} from '@/lib/bahiaElectionAggregates' +import { ELECTION_YEAR_2022 } from '@/lib/electionResults' +import { captureRate, ownVotes2022, projectedFieldCeiling } from '@/utilities/municipalityPotential' + +/** + * E10 "classificação territorial relativa" — the operational class of one + * município, read from the committed `bahia-federal-baseline.json` artifact + * alone (pure functions, no DB, no `campaignGoals`, no user). + * + * Why relative anchors: the class the campaign inherited from the majoritarian + * era used absolute shares of local valid votes (35/20/10). In a federal-deputy + * race the candidate's local share is typically 1–5%, so every município in the + * state collapsed into the worst class. The research report (§6, "limiares + * 35/20/10 degeneram em DF") prescribes anchoring on multiples of the + * candidate's OWN statewide share (a location quotient) and reading more than + * one axis: dominance + how much of his own vote lives here + how much field + * vote is still uncaptured. + */ + +/** Reference year: the field ceiling is 2022-only in the artifact by design. */ +const TERRITORIAL_CLASS_YEAR = ELECTION_YEAR_2022 + +export const TERRITORIAL_CLASSES = [ + 'reduto', + 'expansao', + 'manutencao', + 'marginal', + 'sem_base', +] as const + +export type MunicipalityTerritorialClass = (typeof TERRITORIAL_CLASSES)[number] + +/** + * Cuts are ILLUSTRATIVE, not calibrated: the research report gives the shape + * (LQ > 2–3 = reduto, ~1 = padrão, < 0,5 = fraqueza) and says the exact + * numbers can only come from a backtest against 2014–2022 — that is **E15**. + * They live here, named and versioned, so recalibration is a one-line diff + * with a test to prove what moved. + */ +export const TERRITORIAL_CLASS_ANCHORS = { + /** LQ at or above this = performing at least 2× his own statewide standard. */ + strongLq: 2, + /** LQ below this = performing at less than half his own standard. */ + weakLq: 0.5, + /** Municípios that together hold this share of his statewide vote are the "core block". */ + coreCumulativeShare: 0.5, +} as const + +/** The axes behind a class — the UI always shows the "por quê", never the label alone. */ +type TerritorialFactorId = 'dominance' | 'ownShare' | 'field' | 'capture' + +export type TerritorialFactor = { + id: TerritorialFactorId + /** + * `dominance` = LQ (ratio); `ownShare` = share of his statewide vote (0..1); + * `field` = uncaptured field votes (absolute); `capture` = capture rate (0..1). + */ + value: number +} + +export type MunicipalityTerritorialClassification = { + class: MunicipalityTerritorialClass + /** Ordered by relevance to the class — surfaces show the first two. */ + factors: TerritorialFactor[] + /** Location quotient vs. his own statewide share; null without a local electorate. */ + lq: number | null + /** Share of his statewide vote that comes from here (the mesa's own anchor, A11). */ + ownShare: number + /** Field votes (projected 2022 ceiling) his candidacy did not capture. */ + fieldHeadroom: number + /** True when the município belongs to the block holding `coreCumulativeShare` of his vote. */ + inCoreBlock: boolean +} + +export type TerritorialClassInput = { + ownVotes: number + validVotes: number + stateOwnVotes: number + stateValidVotes: number + /** Projected field ceiling minus own votes (E8), floored at 0. */ + fieldHeadroom: number + /** Median `fieldHeadroom` across the catalog — the relative "big field" cut. */ + medianFieldHeadroom: number + captureRate: number | null + inCoreBlock: boolean +} + +const UNCLASSIFIED: MunicipalityTerritorialClassification = { + class: 'sem_base', + factors: [], + lq: null, + ownShare: 0, + fieldHeadroom: 0, + inCoreBlock: false, +} + +/** + * Pure classifier over explicit inputs (the artifact-backed wrapper below + * fills them in). Order of the rules is the reading order of the mesa: + * dominance first, then — only for the weak half — whether there is field + * left to win. + */ +export const classifyMunicipalityTerritory = ( + input: TerritorialClassInput, +): MunicipalityTerritorialClassification => { + const { ownVotes, validVotes, stateOwnVotes, stateValidVotes } = input + if (validVotes <= 0 || stateValidVotes <= 0 || stateOwnVotes <= 0) return UNCLASSIFIED + + const stateShare = stateOwnVotes / stateValidVotes + const lq = ownVotes / validVotes / stateShare + const ownShare = ownVotes / stateOwnVotes + const shared = { + lq, + ownShare, + fieldHeadroom: input.fieldHeadroom, + inCoreBlock: input.inCoreBlock, + } + const dominance: TerritorialFactor = { id: 'dominance', value: lq } + const ownShareFactor: TerritorialFactor = { id: 'ownShare', value: ownShare } + const fieldFactor: TerritorialFactor = { id: 'field', value: input.fieldHeadroom } + const captureFactor: TerritorialFactor[] = + input.captureRate === null ? [] : [{ id: 'capture', value: input.captureRate }] + + if (lq >= TERRITORIAL_CLASS_ANCHORS.strongLq) { + // Reduto de conta vs. reduto simbólico is the `ownShare` factor's job + // (P10: the trophy município must not eat the agenda of the ones that + // close the quotient), not a fifth class. + return { ...shared, class: 'reduto', factors: [dominance, ownShareFactor, ...captureFactor] } + } + + if (lq < TERRITORIAL_CLASS_ANCHORS.weakLq) { + // A município inside the core block carries real votes even while + // under-performing, so it can never read as "marginal" — the honest label + // there is expansão (big base, low penetration). + const hasFieldLeft = input.fieldHeadroom >= input.medianFieldHeadroom || input.inCoreBlock + return hasFieldLeft + ? { ...shared, class: 'expansao', factors: [fieldFactor, dominance, ...captureFactor] } + : { ...shared, class: 'marginal', factors: [dominance, fieldFactor] } + } + + return { ...shared, class: 'manutencao', factors: [dominance, ownShareFactor, ...captureFactor] } +} + +/** Uncaptured field votes: projected 2022 field ceiling minus his own vote. */ +const fieldHeadroomOf = (baseline: MunicipalityFederalBaseline): number => + Math.max(0, projectedFieldCeiling(baseline) - ownVotes2022(baseline)) + +const median = (values: number[]): number => { + if (values.length === 0) return 0 + const sorted = [...values].sort((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] +} + +type CatalogContext = { + medianFieldHeadroom: number + coreBlockSlugs: ReadonlySet +} + +let catalogContext: CatalogContext | null = null + +/** + * Catalog-wide reference values, computed once per process (the artifact is + * immutable): the median field headroom — a relative cut, so no magic vote + * count ages — and the set of municípios that, sorted by his own vote, + * accumulate `coreCumulativeShare` of his statewide total. + */ +const getCatalogContext = (): CatalogContext => { + if (catalogContext) return catalogContext + + const rows = federalBaselineMunicipalitySlugs().map((slug) => { + const baseline = getMunicipalityFederalBaseline(slug) + return { + slug, + ownVotes: ownVotes2022(baseline), + fieldHeadroom: fieldHeadroomOf(baseline), + hasFieldData: projectedFieldCeiling(baseline) > 0, + } + }) + + // Slugs whose majoritarian cell is missing would drag the median to zero and + // make every weak município read as "expansão". + const medianFieldHeadroom = median( + rows.filter((row) => row.hasFieldData).map((row) => row.fieldHeadroom), + ) + + const stateOwnVotes = getStatewideFederalTotals(TERRITORIAL_CLASS_YEAR).ownVotes + const coreBlockSlugs = new Set() + let accumulated = 0 + for (const row of [...rows].sort( + (a, b) => b.ownVotes - a.ownVotes || a.slug.localeCompare(b.slug), + )) { + if (accumulated >= stateOwnVotes * TERRITORIAL_CLASS_ANCHORS.coreCumulativeShare) break + coreBlockSlugs.add(row.slug) + accumulated += row.ownVotes + } + + catalogContext = { medianFieldHeadroom, coreBlockSlugs } + return catalogContext +} + +const classificationBySlug = new Map() + +/** Classification for one catalog slug — memoized, pure over the artifact. */ +export const computeMunicipalityTerritorialClass = ( + slug: string, +): MunicipalityTerritorialClassification => { + const cached = classificationBySlug.get(slug) + if (cached) return cached + + const baseline = getMunicipalityFederalBaseline(slug) + const totals = getStatewideFederalTotals(TERRITORIAL_CLASS_YEAR) + const { medianFieldHeadroom, coreBlockSlugs } = getCatalogContext() + + const classification = classifyMunicipalityTerritory({ + ownVotes: ownVotes2022(baseline), + validVotes: baseline.validVotesByYear[String(TERRITORIAL_CLASS_YEAR)] ?? 0, + stateOwnVotes: totals.ownVotes, + stateValidVotes: totals.validVotes, + fieldHeadroom: fieldHeadroomOf(baseline), + medianFieldHeadroom, + captureRate: captureRate(baseline), + inCoreBlock: coreBlockSlugs.has(slug), + }) + + classificationBySlug.set(slug, classification) + return classification +} + +/** + * Sort weight for the list's `classe` ordering: descending puts the + * municípios that carry the campaign (reduto) first and the ones without a + * baseline last, in either direction. + */ +export const territorialClassSortWeight: Record = { + reduto: 4, + expansao: 3, + manutencao: 2, + marginal: 1, + sem_base: null, +} diff --git a/src/utilities/municipalityViewModels.ts b/src/utilities/municipalityViewModels.ts index fd6fa589..94237b40 100644 --- a/src/utilities/municipalityViewModels.ts +++ b/src/utilities/municipalityViewModels.ts @@ -16,6 +16,11 @@ import { } from '@/utilities/goalCoverage' import { type PoliticalTrendStatus } from '@/utilities/municipalityLabels' import { resolveMunicipalityLastSignalAt } from '@/utilities/municipalitySignal' +import { + computeMunicipalityTerritorialClass, + type MunicipalityTerritorialClass, + type TerritorialFactor, +} from '@/utilities/municipalityTerritorialClass' import { relationshipId } from '@/utilities/relationship' import type { StateDeputySummary } from '@/utilities/stateDeputyData' import type { MunicipalityPledgeAggregate } from '@/utilities/votePledgeViews' @@ -69,6 +74,10 @@ export type MunicipalityListViewModel = { politicalTrendNote: string | null pledges: MunicipalityPledgeAggregate votePosition2022: MunicipalityVoteRankEntry | null + /** E10 — classe operacional derivada do artefato TSE. */ + territorialClass: MunicipalityTerritorialClass + /** Os dois fatores que produziram a classe: a célula nunca mostra o rótulo sozinho. */ + territorialClassFactors: TerritorialFactor[] /** E8 "conta da cadeira" — meta × comprometido por cenário; null fora da staff view. */ goalCoverageByScenario: Record } @@ -78,31 +87,37 @@ export const toMunicipalityListViewModel = ( pledges: MunicipalityPledgeAggregate | undefined, votePosition2022: MunicipalityListViewModel['votePosition2022'], goalCoverageByScenario?: Record, -): MunicipalityListViewModel => ({ - id: municipality.id, - name: municipality.name, - slug: municipality.slug, - kind: municipality.kind, - city: municipality.city, - region: municipality.region, - ibgeCode: municipality.ibgeCode, - zoneNumber: municipality.zoneNumber ?? null, - advisorIDs: (municipality.advisors ?? []) - .map(relationshipId) - .filter((id): id is number => id !== null), - priority: municipality.priority === 'alta' ? 'alta' : 'normal', - lastUpdateAt: municipality.lastUpdateAt ?? null, - lastSignalAt: resolveMunicipalityLastSignalAt( - municipality.lastUpdateAt ?? null, - pledges?.lastPledgeAt ?? null, - ), - expectedVotes: toVoteEstimateScenarioViewModel(municipality.expectedVotes), - politicalTrendStatus: municipality.politicalTrend?.status ?? null, - politicalTrendNote: municipality.politicalTrend?.note ?? null, - pledges: pledges ?? createEmptyMunicipalityPledgeAggregate(), - votePosition2022, - goalCoverageByScenario: goalCoverageByScenario ?? createEmptyGoalCoverageByScenario(), -}) +): MunicipalityListViewModel => { + const territorialClass = computeMunicipalityTerritorialClass(municipality.slug) + + return { + id: municipality.id, + name: municipality.name, + slug: municipality.slug, + kind: municipality.kind, + city: municipality.city, + region: municipality.region, + ibgeCode: municipality.ibgeCode, + zoneNumber: municipality.zoneNumber ?? null, + advisorIDs: (municipality.advisors ?? []) + .map(relationshipId) + .filter((id): id is number => id !== null), + priority: municipality.priority === 'alta' ? 'alta' : 'normal', + lastUpdateAt: municipality.lastUpdateAt ?? null, + lastSignalAt: resolveMunicipalityLastSignalAt( + municipality.lastUpdateAt ?? null, + pledges?.lastPledgeAt ?? null, + ), + expectedVotes: toVoteEstimateScenarioViewModel(municipality.expectedVotes), + politicalTrendStatus: municipality.politicalTrend?.status ?? null, + politicalTrendNote: municipality.politicalTrend?.note ?? null, + pledges: pledges ?? createEmptyMunicipalityPledgeAggregate(), + votePosition2022, + territorialClass: territorialClass.class, + territorialClassFactors: territorialClass.factors.slice(0, 2), + goalCoverageByScenario: goalCoverageByScenario ?? createEmptyGoalCoverageByScenario(), + } +} export type MunicipalityAdvisorSummary = { id: number diff --git a/tests/int/municipalityPageData.int.spec.ts b/tests/int/municipalityPageData.int.spec.ts index 8f4f1141..aa43dd7d 100644 --- a/tests/int/municipalityPageData.int.spec.ts +++ b/tests/int/municipalityPageData.int.spec.ts @@ -7,6 +7,7 @@ import { beforeAll, describe, expect, it } from 'vitest' import { getMunicipalityCatalogEntry, municipalityCatalog } from '@/lib/municipalityCatalog' import config from '@/payload.config' import { loadMunicipalityListPageBundle } from '@/utilities/municipalityPageData' +import { computeMunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass' import { aggregatePledgesByMunicipality } from '@/utilities/votePledgeData' import { rollupMunicipalityStaffVotes } from '@/utilities/votePledgeViews' @@ -277,6 +278,66 @@ describe('loadMunicipalityListPageBundle', () => { expect(row?.pledges.lastPledgeAt).toBe(pledge.declaredAt) }) + /** + * E10: the class is derived from the committed TSE artifact, so the filter + * cannot be a Payload constraint. The bundle must still agree with itself — + * rows, `totalDocs` and the overview all counting the filtered scope. + */ + it('filters by the derived territorial class and keeps the overview honest', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + + const redutoEntry = municipalityCatalog.find( + (entry) => computeMunicipalityTerritorialClass(entry.slug).class === 'reduto', + )! + const marginalEntry = municipalityCatalog.find( + (entry) => computeMunicipalityTerritorialClass(entry.slug).class === 'marginal', + )! + const reduto = await fixtures.getMunicipality(redutoEntry.slug) + const marginal = await fixtures.getMunicipality(marginalEntry.slug) + + const marker = `e10-classe-${Date.now()}` + for (const [municipality, suffix] of [ + [reduto, 'reduto'], + [marginal, 'marginal'], + ] as const) { + await payload.update({ + collection: 'municipality', + id: municipality.id, + data: { name: `${marker}-${suffix}` }, + depth: 0, + overrideAccess: true, + }) + fixtures.touchMunicipality(municipality.id) + } + + const filtered = await loadMunicipalityListPageBundle(payload, coordinator, { + q: marker, + class: 'reduto', + }) + expect(filtered.municipalities.map((row) => row.slug)).toEqual([reduto.slug]) + expect(filtered.totalDocs).toBe(1) + expect(filtered.overview?.municipalityCount).toBe(1) + expect(filtered.municipalities[0]!.territorialClass).toBe('reduto') + expect(filtered.municipalities[0]!.territorialClassFactors.length).toBeGreaterThan(0) + + // A native sort key would otherwise let Payload paginate before the class + // filter runs, which would hand back a short page with an inflated total. + const byName = await loadMunicipalityListPageBundle(payload, coordinator, { + q: marker, + class: 'reduto', + sort: 'name', + }) + expect(byName.municipalities.map((row) => row.slug)).toEqual([reduto.slug]) + expect(byName.totalDocs).toBe(1) + + const sorted = await loadMunicipalityListPageBundle(payload, coordinator, { + q: marker, + sort: 'classe', + }) + expect(sorted.municipalities.map((row) => row.slug)).toEqual([reduto.slug, marginal.slug]) + }) + it('keeps advisor access and applies URL filters on top', async () => { const fixtures = campaignFixtures() const advisor = await fixtures.createCampaignUser('advisor') diff --git a/tests/unit/campaignComponents.unit.spec.ts b/tests/unit/campaignComponents.unit.spec.ts index e5a1da2f..6fb09a23 100644 --- a/tests/unit/campaignComponents.unit.spec.ts +++ b/tests/unit/campaignComponents.unit.spec.ts @@ -182,6 +182,11 @@ describe('campaign visual foundation', () => { share: 0.031, totalUnits: 435, }, + territorialClass: 'reduto', + territorialClassFactors: [ + { id: 'dominance', value: 2.4 }, + { id: 'ownShare', value: 0.031 }, + ], goalCoverageByScenario: { pessimistic: { goal: 1500, committed: 1200, coverageRatio: 0.8, deficit: 300 }, central: { goal: 1500, committed: 1200, coverageRatio: 0.8, deficit: 300 }, @@ -206,6 +211,8 @@ describe('campaign visual foundation', () => { politicalTrendNote: null, pledges: createEmptyMunicipalityPledgeAggregate(), votePosition2022: null, + territorialClass: 'expansao', + territorialClassFactors: [{ id: 'field', value: 9000 }], goalCoverageByScenario: createEmptyGoalCoverageByScenario(), }, ] @@ -235,6 +242,68 @@ describe('campaign visual foundation', () => { expect(html).toContain('Faltam 300 votos para a meta') }) + /** + * E10: the class is a suggestion, and a suggestion without its reason reads + * as a verdict (research §6.4). The reason moved into the column's + * `cellTooltip` so the cell stays narrow, so what this pins is that it is + * still TEXT in the markup (`sr-only`) rather than a `title` attribute, + * which reached neither keyboard nor touch. A município without a TSE + * series must not wear a badge that implies a reading. + */ + it('never renders a territorial class without its reason, and dashes the ones with no series', () => { + const html = renderWithAppRouter( + createElement(MunicipalityList, { + municipalities: [ + stub({ + id: 1, + name: 'Seabra', + slug: 'seabra', + kind: 'municipio', + city: 'Seabra', + region: 'Chapada Diamantina', + advisorIDs: [], + priority: 'normal', + expectedVotes: toVoteEstimateScenarioViewModel(null), + pledges: createEmptyMunicipalityPledgeAggregate(), + votePosition2022: null, + territorialClass: 'reduto', + territorialClassFactors: [ + { id: 'dominance', value: 2.4 }, + { id: 'ownShare', value: 0.031 }, + ], + goalCoverageByScenario: createEmptyGoalCoverageByScenario(), + }), + stub({ + id: 2, + name: 'Sem série', + slug: 'sem-serie', + kind: 'municipio', + city: 'Sem série', + region: 'Chapada Diamantina', + advisorIDs: [], + priority: 'normal', + expectedVotes: toVoteEstimateScenarioViewModel(null), + pledges: createEmptyMunicipalityPledgeAggregate(), + votePosition2022: null, + territorialClass: 'sem_base', + territorialClassFactors: [], + goalCoverageByScenario: createEmptyGoalCoverageByScenario(), + }), + ], + advisorNamesById: new Map(), + isStaffView: true, + ...municipalityListDefaultProps, + }), + ) + + expect(html).toContain('Reduto') + // The factors ride along as text, in the mesa's phrasing — never a `title`. + expect(html).toContain('2,4× o padrão estadual do candidato') + expect(html).not.toContain('title="2,4×') + expect(html).not.toContain('Sem base') + expect(html).toContain('Sem série do TSE para este município.') + }) + /** * E9 allocation queue: the two row-level signals the queue is scanned for — * a priority município nobody answers for, and a signal that went cold. @@ -264,6 +333,8 @@ describe('campaign visual foundation', () => { politicalTrendNote: null, pledges: createEmptyMunicipalityPledgeAggregate(), votePosition2022: null, + territorialClass: 'expansao', + territorialClassFactors: [{ id: 'field', value: 9000 }], goalCoverageByScenario: createEmptyGoalCoverageByScenario(), }, ], @@ -303,6 +374,8 @@ describe('campaign visual foundation', () => { politicalTrendNote: null, pledges: createEmptyMunicipalityPledgeAggregate(), votePosition2022: null, + territorialClass: 'expansao', + territorialClassFactors: [{ id: 'field', value: 9000 }], goalCoverageByScenario: createEmptyGoalCoverageByScenario(), }, ], @@ -345,6 +418,8 @@ describe('campaign visual foundation', () => { lastPledgeAt: null, }, votePosition2022: null, + territorialClass: 'expansao', + territorialClassFactors: [{ id: 'field', value: 9000 }], goalCoverageByScenario: createEmptyGoalCoverageByScenario(), }, ], @@ -381,6 +456,8 @@ describe('campaign visual foundation', () => { politicalTrendNote: null, pledges: createEmptyMunicipalityPledgeAggregate(), votePosition2022: null, + territorialClass: 'expansao', + territorialClassFactors: [{ id: 'field', value: 9000 }], goalCoverageByScenario: createEmptyGoalCoverageByScenario(), }, ], diff --git a/tests/unit/campaignTable.unit.spec.ts b/tests/unit/campaignTable.unit.spec.ts new file mode 100644 index 00000000..94d4ef4b --- /dev/null +++ b/tests/unit/campaignTable.unit.spec.ts @@ -0,0 +1,63 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' + +import { CampaignTable, CampaignTableHead } from '@/components/campaign/shared/CampaignTable' + +type Row = { id: number; name: string; note: string | null } + +const rows: Row[] = [ + { id: 1, name: 'Seabra', note: 'Vereador migrou para a base' }, + { id: 2, name: 'Itaberaba', note: null }, +] + +const renderTable = (cellTooltip?: (row: Row) => string | null) => + renderToStaticMarkup( + createElement(CampaignTable, { + columns: [ + { + id: 'name', + head: createElement(CampaignTableHead, {}, 'Município'), + cell: (row) => row.name, + cellTooltip, + }, + ], + rows, + rowKey: (row) => row.id, + }), + ) + +/** + * B23 seam consumed by E10's "Classe" column: the tooltip is declared by the + * column, rendered by the table. These pin the two halves of the contract that + * a consumer would otherwise have to rediscover. + */ +describe('CampaignTable cellTooltip', () => { + it('renders the cell untouched when a column declares no tooltip', () => { + const html = renderTable() + + expect(html).toContain('Seabra') + // No wrapper at all: the municipality list runs with `overflow-x-visible` + // and a sticky header, so an unrequested inline-flex span is a layout risk. + expect(html).not.toContain('inline-flex') + }) + + it('wraps only the rows whose tooltip has content', () => { + const html = renderTable((row) => row.note) + + // Itaberaba has no note, so it must render exactly like the no-tooltip + // case — one trigger in the table, not two. + expect(html.match(/data-slot="tooltip-trigger"/g)).toHaveLength(1) + expect(html).toContain('>Itaberaba<') + }) + + /** + * Radix mounts tooltip content only once opened, so the reason never reaches + * the server markup. That is precisely why `cellTooltip`'s contract demands + * the same information already exist as text in the cell — a column that + * moves its only copy into the tooltip silently loses it for assistive tech. + */ + it('keeps the tooltip content out of the server markup', () => { + expect(renderTable((row) => row.note)).not.toContain('Vereador migrou para a base') + }) +}) diff --git a/tests/unit/municipalityList.unit.spec.ts b/tests/unit/municipalityList.unit.spec.ts index 469e1b54..a5227806 100644 --- a/tests/unit/municipalityList.unit.spec.ts +++ b/tests/unit/municipalityList.unit.spec.ts @@ -131,6 +131,70 @@ describe('municipality list sort params (A11 + B15 + E9)', () => { expect(municipalityListSortLabels.coverage).toBe('Assessores') expect(formatMunicipalityListSortSummary('coverage', 'asc')).toBe('Ordenado por Assessores ↑') }) + + it('opens the classe sort on reduto (desc default) and names the direction', () => { + const base = parseMunicipalityListParams({}) + expect(buildMunicipalitySortHref(base, 'classe')).toBe('/campanha/municipios?sort=classe') + const byClass = parseMunicipalityListParams({ sort: 'classe' }) + expect(resolveMunicipalityListSort(byClass)).toEqual({ sort: 'classe', dir: 'desc' }) + expect(formatMunicipalityListSortSummary('classe', 'desc')).toBe( + 'Ordenado por classe (reduto primeiro)', + ) + expect(formatMunicipalityListSortSummary('classe', 'asc')).toBe( + 'Ordenado por classe (marginal primeiro)', + ) + }) +}) + +describe('E10 classe territorial filter (derived, not a Payload constraint)', () => { + it('parses repeated ?class= params and keeps them out of the where clause', () => { + const state = parseMunicipalityListParams({ class: ['reduto', 'expansao', 'reduto'] }) + expect(state.classes).toEqual(['reduto', 'expansao']) + expect(isMunicipalityColumnFilterActive(state, 'class')).toBe(true) + // The class lives in the committed TSE artifact, so `where` must stay clean — + // `municipalityPageData` applies it in memory over the unpaginated scope. + expect(buildMunicipalityListWhere(state)).toEqual({}) + expect(buildMunicipalityFilterHref(state)).toBe( + '/campanha/municipios?class=reduto&class=expansao', + ) + }) + + it('discards unknown classes and treats "todas" as absent', () => { + expect(parseMunicipalityListParams({ class: 'perdida' }).classes).toBeUndefined() + + const all = parseMunicipalityListParams({ + class: ['reduto', 'expansao', 'manutencao', 'marginal', 'sem_base'], + }) + expect(all.classes).toBeUndefined() + expect(isMunicipalityColumnFilterActive(all, 'class')).toBe(false) + expect(formatMunicipalityActiveFiltersSummary(all)).toBeNull() + + // Ticking the last unchecked box lands on that same canonical state. + const allButOne = parseMunicipalityListParams({ + class: ['reduto', 'expansao', 'manutencao', 'marginal'], + }) + expect( + toggleMunicipalityMultiFilterValue(allButOne, 'class', 'sem_base').classes, + ).toBeUndefined() + }) + + it('names the class in the active-filters summary', () => { + expect( + formatMunicipalityActiveFiltersSummary( + parseMunicipalityListParams({ class: ['reduto', 'expansao'] }), + ), + ).toBe('Classe reduto, expansão') + }) + + it('toggles a class in and out while preserving the other filters', () => { + const state = parseMunicipalityListParams({ region: 'Irecê', sort: 'votos' }) + const on = toggleMunicipalityMultiFilterValue(state, 'class', 'reduto') + expect(on.classes).toEqual(['reduto']) + expect(buildMunicipalityFilterHref(on)).toBe( + '/campanha/municipios?region=Irec%C3%AA&class=reduto&sort=votos', + ) + expect(toggleMunicipalityMultiFilterValue(on, 'class', 'reduto').classes).toBeUndefined() + }) }) describe('municipality list header filters (B16+)', () => { diff --git a/tests/unit/municipalityTerritorialClass.unit.spec.ts b/tests/unit/municipalityTerritorialClass.unit.spec.ts new file mode 100644 index 00000000..17f37c2d --- /dev/null +++ b/tests/unit/municipalityTerritorialClass.unit.spec.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest' + +import { municipalityCatalog } from '@/lib/municipalityCatalog' +import { + classifyMunicipalityTerritory, + computeMunicipalityTerritorialClass, + TERRITORIAL_CLASS_ANCHORS, + TERRITORIAL_CLASSES, + territorialClassSortWeight, + type MunicipalityTerritorialClass, + type TerritorialClassInput, +} from '@/utilities/municipalityTerritorialClass' + +/** + * Statewide reference: 100.000 own votes over 5.000.000 valid votes = a 2% + * statewide share, so a município with a 4% local share has LQ 2 (reduto) and + * one with 0,5% has LQ 0,25 (weak half). + */ +const input = (overrides: Partial = {}): TerritorialClassInput => ({ + ownVotes: 200, + validVotes: 10_000, + stateOwnVotes: 100_000, + stateValidVotes: 5_000_000, + fieldHeadroom: 1_000, + medianFieldHeadroom: 2_000, + captureRate: 0.1, + inCoreBlock: false, + ...overrides, +}) + +describe('classifyMunicipalityTerritory', () => { + it('classifies as reduto when the local share is at least strongLq × his own standard', () => { + // 4% local vs 2% statewide = LQ 2 + const result = classifyMunicipalityTerritory(input({ ownVotes: 400, validVotes: 10_000 })) + expect(result.class).toBe('reduto') + expect(result.lq).toBe(TERRITORIAL_CLASS_ANCHORS.strongLq) + expect(result.factors.slice(0, 2).map((factor) => factor.id)).toEqual(['dominance', 'ownShare']) + }) + + it('keeps a tiny reduto as reduto, with ownShare telling it apart from one that carries the account', () => { + const symbolic = classifyMunicipalityTerritory(input({ ownVotes: 60, validVotes: 1_000 })) + expect(symbolic.class).toBe('reduto') + // 60 / 100.000 = 0,06% of his statewide vote — the "reduto simbólico" of P10. + expect(symbolic.ownShare).toBeCloseTo(0.0006, 6) + }) + + it('classifies as manutenção inside the standard band', () => { + // 2% local = LQ 1 + expect(classifyMunicipalityTerritory(input({ ownVotes: 200 })).class).toBe('manutencao') + // Just under the strong cut stays manutenção (boundary is inclusive on reduto) + expect(classifyMunicipalityTerritory(input({ ownVotes: 399 })).class).toBe('manutencao') + // Exactly at the weak cut is still manutenção (boundary is exclusive on marginal) + expect(classifyMunicipalityTerritory(input({ ownVotes: 100 })).lq).toBe( + TERRITORIAL_CLASS_ANCHORS.weakLq, + ) + expect(classifyMunicipalityTerritory(input({ ownVotes: 100 })).class).toBe('manutencao') + }) + + it('classifies as expansão when he is weak but the field still has votes to win', () => { + const result = classifyMunicipalityTerritory( + input({ ownVotes: 50, fieldHeadroom: 9_000, medianFieldHeadroom: 2_000 }), + ) + expect(result.class).toBe('expansao') + expect(result.factors[0]).toEqual({ id: 'field', value: 9_000 }) + }) + + it('classifies as marginal when he is weak and the field is small', () => { + expect( + classifyMunicipalityTerritory( + input({ ownVotes: 50, fieldHeadroom: 100, medianFieldHeadroom: 2_000 }), + ).class, + ).toBe('marginal') + }) + + it('never calls a core-block município marginal, even with a small field', () => { + const result = classifyMunicipalityTerritory( + input({ ownVotes: 50, fieldHeadroom: 100, medianFieldHeadroom: 2_000, inCoreBlock: true }), + ) + expect(result.class).toBe('expansao') + expect(result.inCoreBlock).toBe(true) + }) + + it('drops the capture factor when the field ceiling is unknown', () => { + const result = classifyMunicipalityTerritory(input({ captureRate: null })) + expect(result.factors.map((factor) => factor.id)).not.toContain('capture') + }) + + it('returns sem_base without a local electorate or a statewide denominator', () => { + expect(classifyMunicipalityTerritory(input({ validVotes: 0 })).class).toBe('sem_base') + expect(classifyMunicipalityTerritory(input({ stateOwnVotes: 0 })).class).toBe('sem_base') + expect(classifyMunicipalityTerritory(input({ stateValidVotes: 0 })).class).toBe('sem_base') + expect(classifyMunicipalityTerritory(input({ validVotes: 0 })).lq).toBeNull() + expect(classifyMunicipalityTerritory(input({ validVotes: 0 })).factors).toEqual([]) + }) + + it('classifies a município with zero own votes as weak, not as an error', () => { + const result = classifyMunicipalityTerritory(input({ ownVotes: 0, fieldHeadroom: 9_000 })) + expect(result.class).toBe('expansao') + expect(result.lq).toBe(0) + }) +}) + +describe('computeMunicipalityTerritorialClass over the committed artifact', () => { + const classifications = municipalityCatalog.map((entry) => + computeMunicipalityTerritorialClass(entry.slug), + ) + + /** + * The regression this whole item exists to prevent: the inherited absolute + * thresholds (35/20/10) collapsed the entire state into one class, because a + * federal-deputy candidate's local share is 1–5% everywhere. Relative + * anchors must produce a usable spread over the real 435 municípios. + */ + it('produces a non-degenerate distribution over the 435 catalog slugs', () => { + const counts = new Map() + for (const classification of classifications) { + counts.set(classification.class, (counts.get(classification.class) ?? 0) + 1) + } + + for (const territorialClass of TERRITORIAL_CLASSES) { + if (territorialClass === 'sem_base') continue + expect(counts.get(territorialClass) ?? 0).toBeGreaterThan(0) + } + for (const [, count] of counts) { + expect(count / classifications.length).toBeLessThan(0.7) + } + }) + + it('gives every classified município a "por quê" and a finite LQ', () => { + for (const classification of classifications) { + if (classification.class === 'sem_base') continue + expect(classification.factors.length).toBeGreaterThanOrEqual(2) + expect(Number.isFinite(classification.lq ?? Number.NaN)).toBe(true) + } + }) + + it('keeps the core block at roughly half of his statewide vote', () => { + const coreShare = classifications + .filter((classification) => classification.inCoreBlock) + .reduce((total, classification) => total + classification.ownShare, 0) + expect(coreShare).toBeGreaterThan(0.4) + expect(coreShare).toBeLessThan(0.6) + }) + + it('memoizes: the same slug returns the identical object', () => { + const slug = municipalityCatalog[0].slug + expect(computeMunicipalityTerritorialClass(slug)).toBe( + computeMunicipalityTerritorialClass(slug), + ) + }) +}) + +describe('territorialClassSortWeight', () => { + it('ranks reduto first and leaves sem_base without a weight', () => { + expect(territorialClassSortWeight.reduto).toBeGreaterThan( + territorialClassSortWeight.expansao ?? 0, + ) + expect(territorialClassSortWeight.expansao).toBeGreaterThan( + territorialClassSortWeight.manutencao ?? 0, + ) + expect(territorialClassSortWeight.manutencao).toBeGreaterThan( + territorialClassSortWeight.marginal ?? 0, + ) + expect(territorialClassSortWeight.sem_base).toBeNull() + }) +}) diff --git a/tests/unit/municipalityVoteRank.unit.spec.ts b/tests/unit/municipalityVoteRank.unit.spec.ts index e53bcc51..4648750c 100644 --- a/tests/unit/municipalityVoteRank.unit.spec.ts +++ b/tests/unit/municipalityVoteRank.unit.spec.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest' import { federalBaselineMunicipalitySlugs } from '@/lib/bahiaElectionAggregates' +import { formatVoteSharePercent } from '@/lib/electionFormat' import { compareMunicipalityVotesForSort, computeVoteRankByYear, DEFAULT_VOTE_RANK_YEAR, formatMunicipalityVoteRank, - formatMunicipalityVoteShare, getMunicipalityVoteRank, } from '@/lib/municipalityVoteRank' @@ -58,7 +58,7 @@ describe('municipalityVoteRank', () => { it('formats rank as colocação only (catalog size lives in the header hint)', () => { expect(formatMunicipalityVoteRank(12)).toBe('12º') - expect(formatMunicipalityVoteShare(0.031)).toMatch(/3[,.]1%/) + expect(formatVoteSharePercent(0.031)).toMatch(/3[,.]1%/) }) it('sorts zero votes last in either direction', () => {