Merge branch 'cursor/4ba21740'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-24 18:41:52 -03:00
commit d73a644d72
24 changed files with 993 additions and 147 deletions

View file

@ -7,7 +7,8 @@ alwaysApply: false
## Status
- **E17 entregue em código (2026-07-24):** tabela comparativa dos 27 Territórios de Identidade no Início staff (`/campanha`) — `src/utilities/territoryOverview.ts` (rollup puro, client-safe: `computeTerritoryRollup` + `sortTerritoryRows`) + `src/utilities/loadTerritoryOverview.ts` (loader server-only, `overrideAccess: true` — leitura regional completa para advisor é contexto, não gestão; exposição só agregados TI-level) + `src/components/campaign/TerritoryOverviewTable.tsx` (tabela shadcn densa, ordenação client-side default `% da própria votação desc`, Metropolitano decomposto em Salvador 19 zonas × Demais RMS, linha→`/campanha/municipios?region=<TI>`). Somas/razões apenas (salvaguarda MAUP); sem migration/collection. Unit `territoryOverview.unit.spec.ts` (10 testes); tsc/lint/knip/unit 266/int 345/build verde; Aikido 0 findings. **Débito:** A11 ausente → share calculado localmente no rollup; A11 unifica quando entrar. Plano: [`tabela-ti-inicio.md`](../../../docs/plans/tabela-ti-inicio.md). Primeira fatia de E12 (estende com captura/LQ/cobertura de meta por TI).
- **A11 entregue em código (2026-07-24):** posição em votos (rank denso 435 + % da própria votação estadual) no `MunicipalityBaselineCard` e coluna/ordenação `?sort=votos` na lista (default desc; header=`2022`); helper puro [`municipalityVoteRank.ts`](../../../src/lib/municipalityVoteRank.ts); consome o contrato URL `sort`/`dir` de B15. Plano: [`ranking-votos-municipio.md`](../../../docs/plans/ranking-votos-municipio.md).
- **E17 entregue em código (2026-07-24):** tabela comparativa dos 27 Territórios de Identidade no Início staff (`/campanha`) — `src/utilities/territoryOverview.ts` (rollup puro, client-safe: `computeTerritoryRollup` + `sortTerritoryRows`) + `src/utilities/loadTerritoryOverview.ts` (loader server-only, `overrideAccess: true` — leitura regional completa para advisor é contexto, não gestão; exposição só agregados TI-level) + `src/components/campaign/TerritoryOverviewTable.tsx` (tabela shadcn densa, ordenação client-side default `% da própria votação desc`, Metropolitano decomposto em Salvador 19 zonas × Demais RMS, linha→`/campanha/municipios?region=<TI>`). Somas/razões apenas (salvaguarda MAUP); sem migration/collection. Unit `territoryOverview.unit.spec.ts` (10 testes); tsc/lint/knip/unit 266/int 345/build verde; Aikido 0 findings. Plano: [`tabela-ti-inicio.md`](../../../docs/plans/tabela-ti-inicio.md). Primeira fatia de E12 (estende com captura/LQ/cobertura de meta por TI).
- **B15 entregue (2026-07-24):** ordenar `/campanha/municipios` pelo header da coluna — contrato URL `?sort=`/`?dir=`, clique no header (desktop) + select mobile; A11 consome a key `votos`. Verificação (tsc/lint/test/build/Aikido) passou. `/simplify` concluído no mesmo dia: reduziu os comparadores de ordenação a um helper genérico, unificou os dois ramos `payload.find` nativo/derivado, moveu labels/opções de sort para `municipalityUi.ts`, simplificou o ícone de chevron e removeu o cast `as unknown` do mock de roteador nos testes. Plano: [`ordenacao-colunas-lista-municipios.md`](../../../docs/plans/ordenacao-colunas-lista-municipios.md).
- **Fill-in registrado (2026-07-24):** seletor **Cenário** de `/campanha/municipios` → fileira de `MunicipalityFilters` (provider envolve filtros+resultados; overview sem seletor). Plano: [`cenario-junto-filtros-municipios.md`](../../../docs/plans/cenario-junto-filtros-municipios.md).
- **E4R entregue em código + seed em produção (2026-07-24):** `pnpm db:seed:projecao` (`scripts/import-projecao.mjs` + `src/lib/projectionSheetParse.ts`) importa MAPA GERAL canônico → `expectedVotes` (Bom→otimista, Regular→média, Mínimo→pessimista) + `priority` com **overwrite-always**; Salvador pulado; zero PII. No mesmo dia o grupo duplicado `voteGoals` foi removido do app (ver Decisões). Seed local e **produção Neon** (2026-07-24): arquivo canônico `docs/sheets/Mapa projeção de votos Solla 2026.xlsx` (+ referência `Mapa_projecao_votos_Solla_2026.xlsx` só A×B), **189** municípios atualizados, **50** `priority=alta`, Salvador pulado, re-dry-run pós-apply **delta=0**. Plano: [`import-planilha-projecao.md`](../../../docs/plans/import-planilha-projecao.md).
@ -78,6 +79,7 @@ alwaysApply: false
## Decisões
- Em 2026-07-24, **header da coluna A11 = `2022` (não "Concentração 2022"):** o título longo competia com células curtas (`%` + `12º`); no contexto da mesa o ano basta. Definição da métrica fica no hover do header (`formatMunicipalityConcentrationHint`). **Rejeitado:** manter "Concentração" no rótulo visível.
- Em 2026-07-24, **uma única série por cenário — "Metas" eliminadas:** o grupo `municipality.voteGoals` ("Meta Bom/Regular/Mínimo") duplicava o conceito de votos estimados e foi removido do app (collection, zod, view models, form/card, `utilities/voteGoals.ts`); a série do município é `expectedVotes` (pessimista/média/otimista). Planilha EXPECTATIVA mapeia **Bom→otimista, Regular→média, Mínimo→pessimista**. Migration `20260724_133600_drop_municipality_vote_goals` com backfill metas→estimativas (estimativa existente vence). **Supersede** o "não fundir com `voteGoals`" da decisão A10 abaixo. **Rejeitado:** manter os dois grupos com semânticas distintas (meta de planejamento × estimativa) — a mesa trabalha com um número por cenário.
- Em 2026-07-24, **E4R overwrite-always:** `pnpm db:seed:projecao` sempre sobrescreve `expectedVotes` + `priority` nas linhas casadas (planilha nova sobre DB já populado). **Rejeitado:** empty-only / `--overwrite` opt-in (quebraria o re-seed). Dry-run continua obrigatório antes de produção.
- Em 2026-07-23, **access explícito em toda collection (hardening Fase 0):** o default do Payload ("qualquer autenticado pode CRUD") é tratado como bug; toda collection declara `access` explícito — `users`/`signature`/`subscription`/`consent` só Payload admin (`payloadAdminOnly`), escrita de CMS admin-only com leitura pública preservada. JWTs de `campaignUser` não operam mais essas collections via `/api`. **Rejeitado:** confiar no default do Payload; RBAC só na camada de UI.

View file

@ -0,0 +1,97 @@
---
target: a11-ranking-votos
total_score: 25
p0_count: 0
p1_count: 2
timestamp: 2026-07-24T20-30-52Z
slug: a11-ranking-votos
---
# Critique — A11 posição em votos
**Alvos:** `MunicipalityBaselineCard`, `MunicipalityList`, `MunicipalitySortableHead`, `MunicipalityFilters`
**Data:** 2026-07-24 (pós-simplify)
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| 1 | Visibility of System Status | 3 | Pending/dim ok; sort active mostly chevron — no “ordenado por…” summary |
| 2 | Match System / Real World | 2 | “% da votação” unexplained on list; “Praça” leftovers; seed empty-state |
| 3 | User Control and Freedom | 3 | Limpar is all-or-nothing (sort resets only with full clear) |
| 4 | Consistency and Standards | 2 | Mobile “% da votação” vs desktop “Votos 2022”; card vs table readout order |
| 5 | Error Prevention | 3 | Detail separates dual-% well; list has no equivalent guardrail |
| 6 | Recognition Rather Than Recall | 2 | List % without denominator; definition lives on detail |
| 7 | Flexibility and Efficiency | 3 | Default votos desc is right mesa lens; inactive columns under-afforded |
| 8 | Aesthetic and Minimalist Design | 3 | Restrained desk; quiet secondary lines also hide meaning |
| 9 | Error Recovery | 2 | “—” for missing rank; engineer empty baseline copy |
| 10 | Help and Documentation | 2 | Baseline intro good; list column gives zero definition |
| **Total** | | **25/40** | **Acceptable — vocabulary/consistency is the drag** |
## Anti-Patterns Verdict
**LLM:** Mostly earned product familiarity (flat desk, Inter, tabular-nums) — not AI kitsch. Product strangeness is the failure: three labels for one lens (Votos 2022 / % da votação / sort-by-votes).
**Deterministic scan:** `detect.mjs --json` on the four files → exit 0, `[]` (0 findings). No false positives to triage.
**Visual overlays:** Not available — `/campanha` auth gate + localhost:3000 browser allowlist blocked live injection. No user-visible overlay.
## Overall Impression
Default sort by concentration is the right product move. The biggest opportunity is one shared vocabulary so Mesa CG never wonders whether the loudest number is votes, share, or local % of válidos.
## What's Working
1. Mesa-default `votos` desc with URL-omitted defaults.
2. Baseline dual-metric honesty (intro + “métrica distinta…”).
3. Sort/filter craft: aria-sort, directional aria-labels, min-h-11, feel-the-action pending.
## Priority Issues
### [P1] Label / metric / sort vocabulary collision
- **Why:** Under pressure, header “Votos 2022” vs cell “% da votação” vs mobile Ordenar invites wrong comparisons.
- **Fix:** One shared term (e.g. Concentração 2022) across column, Ordenar, and primary readout; secondary = votos · Nº de 435.
- **Suggested command:** `/impeccable clarify`
### [P1] Dual-% trap still live on list → detail path
- **Why:** List shows unexplained %; detail introduces local % of válidos — disclaimer one click too late.
- **Fix:** Column hint/caption: “% da votação estadual do candidato”; keep local dominance secondary.
- **Suggested command:** `/impeccable clarify`
### [P2] Card vs table VotePositionReadout hierarchy diverge
- **Why:** Same data, different story (table prioritizes share; card is middot soup).
- **Fix:** Same stack both layouts: concentration → votos · rank.
- **Suggested command:** `/impeccable distill`
### [P2] “Praça” + engineer empty state
- **Why:** Remodel is Município; “Rode o seed…” fails closed for staff.
- **Fix:** Município language; staff-safe empty (“Sem baseline TSE neste ambiente”).
- **Suggested command:** `/impeccable harden`
### [P3] Active sort under-announced for sighted scan
- **Why:** Chevron alone is easy to miss in dense headers.
- **Fix:** Active weight + optional “Ordenado por concentração 2022…”.
- **Suggested command:** `/impeccable polish`
## Persona Red Flags
**Alex:** Default sort wins; inactive sortable headers lack dormant affordance.
**Sam:** Muted A11 lines risk contrast; card readout is one long SR sentence; no table caption for default metric.
**Casey:** Mobile Ordenar clearer than desktop; territory select painful; “Abrir Praça” CTA.
**Mesa CG:** Ambiguity between % da votação and % dos válidos produces wrong room narrative in seconds.
## Cognitive load
6/8 checklist failures (moderate→high for A11 under pressure). Territory ~27 options; staff filter band is a wall of selects.
## Minor Observations
- 2022 ring on baseline is good quiet emphasis.
- Missing votePosition → “—” vs silent omit of rank line on baseline — inconsistent emptiness.
- Rank `Nº de 435` is immediately legible domain language.
## Questions to Consider
1. If the first question is concentration, why is the loudest glyph a bare % instead of rank-of-435?
2. Would one word — Concentração — beat another disclaimer sentence?
3. Should local % dos válidos even share the baseline card with % da votação?
4. Is “Votos 2022” protecting familiarity while lying about what the eye reads first?

View file

@ -0,0 +1,69 @@
---
target: a11-ranking-votos
total_score: 29
p0_count: 0
p1_count: 2
timestamp: 2026-07-24T20-48-41Z
slug: a11-ranking-votos
---
# Critique — A11 posição em votos (pós-polish)
**Alvos:** BaselineCard, List, SortableHead, Filters
**Data:** 2026-07-24
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| 1 | Visibility of System Status | 3 | Sort summary ok; dormant chevrons faint |
| 2 | Match System / Real World | 3 | Concentração unified; Praças leftovers in visit/scope |
| 3 | User Control and Freedom | 3 | Limpar nuclear |
| 4 | Consistency and Standards | 3 | Mobile/desktop labels aligned |
| 5 | Error Prevention | 3 | Dual-% taught; cells still noisy |
| 6 | Recognition Rather Than Recall | 3 | Definition in body/cells, not on header |
| 7 | Flexibility and Efficiency | 3 | Default votos desc right |
| 8 | Aesthetic and Minimalist Design | 2 | Per-cell “da votação estadual” + “de 435” |
| 9 | Error Recovery | 3 | Staff-safe empty baseline |
| 10 | Help and Documentation | 3 | Help misplaced (cells vs header) |
| **Total** | | **29/40** | **Good** |
## Anti-Patterns Verdict
**LLM:** Not AI kitsch. Over-teaching densifies the table.
**Detector:** exit 0, `[]`.
**Overlays:** unavailable (auth + :3000 allowlist).
## Overall Impression
Vocabulary polish worked (+4 vs 25). Next jump is density: definitions once on the Concentração header; cells = bare % + colocação.
## What's Working
1. Concentração 2022 wired through labels/Ordenar/default sort
2. Dual-metric honesty on baseline
3. Sort a11y craft
## Priority Issues
### [P1] Per-cell “da votação estadual” (owner)
Cell = bare %. Explanation on Concentração header tooltip. → `/impeccable distill`
### [P1] “de 435” per row (owner)
Rank = `12º` only; catalog size once in header tooltip. → `/impeccable distill`
### [P2] Sort summary copy-heavy
Short status only; definition → header. → `/impeccable quieter` / polish
### [P2] Baseline dual-% fork
Demote local dominance. → `/impeccable layout`
### [P3] Dormant chevrons + Praças leftovers
→ `/impeccable polish` / clarify
## Persona Red Flags
Alex wants denser cells. Sam gets SR soup from repeated suffixes. Mesa CG: definition once on header.
## Questions to Consider
If the column is Concentração, why restate the denominator every row?

View file

@ -0,0 +1,62 @@
---
target: a11-ranking-votos
total_score: 30
p0_count: 0
p1_count: 1
timestamp: 2026-07-24T21-01-02Z
slug: a11-ranking-votos
---
# Critique — A11 (pós density distill)
**Data:** 2026-07-24
**Score:** 30/40 (was 29)
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| 1 | Visibility of System Status | 3 | Sort status solid |
| 2 | Match System / Real World | 3 | Concentração vocabulary ok |
| 3 | User Control and Freedom | 3 | Limpar nuclear |
| 4 | Consistency and Standards | 3 | Help chrome fights distill |
| 5 | Error Prevention | 3 | Bare cells help |
| 6 | Recognition Rather Than Recall | 3 | Definition on ? not header |
| 7 | Flexibility and Efficiency | 3 | Default votos desc |
| 8 | Aesthetic and Minimalist Design | 3 | Cells good; ? residual |
| 9 | Error Recovery | 3 | Empty baseline ok |
| 10 | Help and Documentation | 2 | Wrong chrome: ? vs hover header |
| **Total** | | **30/40** | **Good** |
## Anti-Patterns
Not AI kitsch. Residual product strangeness: orphan help glyph. Detector []. No overlay.
## Overall
Cells landed. Open P1: remove CampaignInfoHint; explanation on header hover/focus.
## What's Working
1. Bare % + colocação cells
2. Concentração 2022 vocabulary + default sort
3. Baseline dual-metric honesty
## Priority Issues
### [P1] Owner — no ? button; hover header (validated)
Remove hint chrome; Tooltip on sortable Concentração head. Mobile: no orphan ?. Baseline: same rule.
→ `/impeccable distill`
### [P2] Limpar nukes sort
→ `/impeccable harden`
### [P3] Dormant chevrons after ? dies
→ `/impeccable polish`
## Personas
Alex: cells win, ? steals focus. Sam: extra tab stop. Mesa CG: definition on header not glyph.
## Questions
Does desktop even need tooltip if caption + tribal knowledge suffice? Touch = no progressive help OK?

View file

@ -0,0 +1,25 @@
# Critique — A11 posição em votos
⚠️ DEGRADED: Assessment A via design subagent; Assessment B (browser/detector) skipped — no logged-in `/campanha` session in this delivery.
**Alvos:** `MunicipalityBaselineCard`, `MunicipalityList`, `MunicipalitySortableHead`, `MunicipalityFilters`
**Data:** 2026-07-24
## Findings (pós-polish 2026-07-24)
- **P0:** nenhum
- **P1 fechados nesta sessão:** dual % separado (dominância local demoted); rank no mobile; default lista = votos desc; célula lidera com % da votação; select mobile com dir; chevron só quando ativo; aria-label descreve próximo clique
- **P2 restante:** B15 amplia headers das demais colunas; copy Praça→Município em R6
## Polish applied in-session
- Align prop explícito no header `votos` (direita)
- Célula: share primário + votos/rank secundários
- 2022 no baseline com ring sutil (não heat map)
- Dominância local em linha própria, explicitamente distinta
## Verificação
- Unit: municipalityVoteRank, municipalityUi sort params, campaignComponents
- Int: municipalityPageData (incl. `sort=votos`) via `TEQO_TEST_DATABASE_URL` :55432 (porta 5432 ocupada por outro compose neste host)
- `tsc --noEmit`, `pnpm lint --max-warnings=0`

View file

@ -61,6 +61,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
**Recently resolved (2026-07-23):** **Engineering-standards hardening** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md); ledger: [`docs/TECH-DEBT.md`](docs/TECH-DEBT.md); safety net: [`docs/TESTING.md`](docs/TESTING.md); standards rule: `.cursor/rules/engineering-standards.mdc` — READ IT before coding). Phases: (0) explicit `access` on every collection — `users`/`signature`/`subscription`/`consent` admin-only, CMS writes admin-only, leader route gates on `demandas*`/`planos*`, `createLeaderSupporter` scope fixed, `candidate` treated as unrestricted staff consistently; (1) tooling gate — knip in CI (`.github/workflows/ci.yml` on the GitHub mirror), `pnpm lint --max-warnings=0`, `as never` banned (typed helpers `hookFilledCreateData` / tests' `stub<T>`), tsconfig include consolidated + `noUnusedLocals`, ~12 dead modules deleted; (2) caching — committed TSE aggregate artifact (`pnpm build:election-aggregates` → `src/lib/electionAggregates/`, loader `bahiaElectionAggregates.ts`), `loadMunicipalityScope` React `cache()` dedup, `unstable_cache` + `election-tse` tag on elections-tab loaders; (3) "Feel the action" gaps closed — task-toggle revalidation bug, shared `CampaignListPendingBoundary` dimming results, map-compare transition, dashboard map + detail tabs stream behind Suspense; (4) state scoping — map hover isolated in `MunicipalityMapSelection`, scenario provider narrowed, `LeaderContactsPanel` server-rendered with a client form island; (5) structure — `campaignAccess` split into `src/utilities/access/*` (re-exported), supporter actions split (`supporterImport.ts`), `runStaffEntityMutation` + `runCampaignRedirectFormAction` dedupe the org/dobradinha twins and 4 formActions ladders, Plaza→Municipality identifier rename finished.
**Recently resolved (2026-07-24):** **A11** Posição em votos do município — rank denso sobre as 435 unidades do catálogo + **% da própria votação** estadual (artefato `bahiaElectionAggregates`); helper `src/lib/municipalityVoteRank.ts`; UI no `MunicipalityBaselineCard` (por ano) e coluna/ordenação `?sort=votos` na lista (`sort`/`dir` mínimo `name`|`votos`; B15 amplia as demais keys). Plano: [`docs/plans/ranking-votos-municipio.md`](docs/plans/ranking-votos-municipio.md).
**Recently resolved (2026-07-24):** **E4R** Import único da planilha de projeção — `pnpm db:seed:projecao` (`scripts/import-projecao.mjs`) lê o xlsx canônico em `docs/sheets/`, faz match via `canonicalizeMunicipalityName` + `municipalityCatalog`, e **sempre sobrescreve** `municipality.expectedVotes` + `priority` (re-seed quando a mesa manda planilha nova); Salvador pulado; `--dry-run` + guard `ALLOW_REMOTE_DB`; parser puro em `src/lib/projectionSheetParse.ts` (Bom→`optimistic`, Regular→`central`, Mínimo→`pessimistic`). **Fase 2 (mesmo dia, decisão do produto — não é mais zero PII):** o seed também importa as colunas de estratégia, com PRIORITÁRIAS sobrepondo MAPA GERAL quando não-vazia — SITUAÇÃO→`politicalTrend` (QUEDA/MANTÉM/AUMENTO→desfavorável/neutra/favorável, com nota de proveniência), DOBRADINHAS→`stateDeputy`+`stateDeputies` (união; célula crua em `dobradinhaNotes`), LIDERANÇAS→`contact` name-only **sem telefone** + `leadership` (dedup estadual por slug do nome; `Contact.phone` virou opcional via migration `20260724_175500_contact_phone_optional`, UI continua exigindo), ASSESSOR RESPONSÁVEL→`campaignUser`+`advisors` (união; Edizio=coordinator, Solla=candidate — `candidate` agora é elegível como assessor em `eligibleCampaignStaffWhere`; novos usuários com email `<slug>@planilha.invalid` + senha aleatória, sem login até um admin trocar), ENCAMINHAMENTOS→`nextSteps`, OBSERVAÇÃO→`strengths`/`risks` (classificação curada por slug). Relações são união (nunca removem vínculos da UI); re-run idempotente; nomes de atores políticos entraram sem trilha de consentimento — regularizar com a assessoria jurídica. No mesmo dia, o grupo duplicado `municipality.voteGoals` ("Meta Bom/Regular/Mínimo") foi **removido** — collection, zod, view models, form/card e utilitário `voteGoals.ts` — via migration `20260724_133600_drop_municipality_vote_goals` (backfill metas→estimativas onde a estimativa estava vazia): `expectedVotes` (pessimista/média/otimista) é a única série por cenário do município. Plano: [`docs/plans/import-planilha-projecao.md`](docs/plans/import-planilha-projecao.md).
**Recently resolved (2026-07-23):** **A10** Cenários de estimativa (pessimista/média/otimista) — `votePledge.estimatedVotes` e `municipality.expectedVotes` viram groups (`pessimistic|central|optimistic`); migration `20260723_124200_add_vote_estimate_scenarios` (backfill scalar→`central`); agregação por cenário em `votePledgeData` com default `central`; mapa 2026 com `values2026ByScenario` + seletor local; lista/overview/dashboard com média + faixa secundária; liderança continua com um `declaredVotes` sem ver estimativas. Plano: [`docs/plans/cenarios-estimativa-votos.md`](docs/plans/cenarios-estimativa-votos.md). Desbloqueia **E8**.

View file

@ -96,7 +96,7 @@ Componentes:
## Dependências
- Nenhuma dura de outro item aberto. Reusa `campaignListUrl.ts`, bundle A9+ (`loadMunicipalityScope` / lista), B9 (células editáveis coexistindo), pending do hardening.
- **Suaves (consumidores):** **A11** adiciona key `votos` + coluna/leitura sobre este contrato (atualizar plano A11); **E9** depois adiciona `deficit` / risco-frescor na fila (pode ser outra superfície, mas o padrão de URL/header nasce aqui).
**Suaves (consumidores):** **A11 ✓** já adicionou keys `name`|`votos` + coluna/leitura sobre este contrato (2026-07-24); **B15** amplia as keys restantes (`region`, `kind`, `trend`, `expectedVotes`, `lastUpdateAt`, `coverage`) e unifica headers. **E9** depois adiciona `deficit` / risco-frescor na fila.
## Não escopo
@ -130,7 +130,7 @@ Componentes:
## Notas de implementação (2026-07-24)
- **Fase 1 (contrato URL):** `MunicipalityListState` ganhou `sort`/`dir`; `municipalityListParamNames` inclui as novas chaves; `parseMunicipalityListParams` e `buildMunicipalityListSearchParams` validam e omitem defaults (`sort=name`, `dir=asc`); `buildMunicipalitySortHref` toggles a direção e reseta `page` para 1. Incluída `votos` na allowlist para desbloquear A11.
- **Fase 2 (loader):** `loadMunicipalityListPageBundle` aplica Payload `sort` para `name`, `region`, `kind`, `lastUpdateAt` e `politicalTrend.status`; para `expectedVotes`, `coverage` e `votos` carrega o filtrado completo, ordena em memória com nulls-last e tie-break por `name`, depois fatia a página. `votos` usa fallback por `name` até A11 implementar o rank real. `overview`/`scopeTotal` permanecem independentes da ordenação da lista.
- **Fase 2 (loader):** `loadMunicipalityListPageBundle` aplica Payload `sort` para `name`, `region`, `kind`, `lastUpdateAt` e `politicalTrend.status`; para `expectedVotes`, `coverage` e `votos` carrega o filtrado completo, ordena em memória com nulls-last e tie-break por `name`, depois fatia a página. **A11 (2026-07-24):** `votos` passa a ordenar pelo rank/share real (`municipalityVoteRank` + `compareMunicipalityVotesForSort`), não mais stub por `name`.
- **Fase 3 (desktop):** `MunicipalitySortableHead` em `MunicipalityList.tsx` substitui os headers estáticos; usa `CampaignTransitionAnchor` para participar do pending compartilhado, expõe `aria-sort` e ícone de direção. A coluna `Assessores` permanece não sortável (editor B9). A prop `state` foi acrescentada a `MunicipalityListProps` e passada pela página.
- **Fase 4 (mobile):** select compacto "Ordenar" em `MunicipalityFilters.tsx`, visível apenas abaixo de `md`, espelhando as mesmas keys/direções; cards seguem a mesma ordem da URL.
- **Fase 5 (verificação):** `tsc --noEmit`, `pnpm lint`, `pnpm test`, `pnpm build` e Aikido passaram; knip não introduziu dead code novo. Ajuste em `tests/unit/campaignComponents.unit.spec.ts` para fornecer `state` e envolver `MunicipalityList` em `AppRouterContext` mock (necessário porque o sort header usa `useRouter`).

View file

@ -1,12 +1,28 @@
# A11 — Posição em votos do município (rank + % da própria votação) e ordenação por votação
Status: rascunho
Status: entregue 2026-07-24
Atualizado em: 2026-07-24
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Demais itens abertos, A11; E9 absorve como coluna/ordenação da fila)
Impeccable: B — encaixes em superfícies existentes (detalhe do município + lista), sem rota nova
Appetite: ~0,5–1 dia eng; sem migration, sem collection, sem server action nova
Responsável: —
Revisões:
- 2026-07-24 (entrega + polish pós-critique): helper `municipalityVoteRank`; baseline + coluna lista; default da lista = `votos` desc (omitido na URL); `?sort=name` para alfabético; share lidera a célula; rank no mobile; % dos válidos demoted. B15 amplia keys restantes.
- 2026-07-24 (auditoria pre-implementação): B15 ainda ausente no código — esta entrega introduz `sort`/`dir` mínimo só para `name`|`votos` (fallback do plano); B15 amplia as demais keys depois. Seção Dados → decisão → apresentação preenchida. Questões em aberto fechadas: lista só 2022; sem delta de rank 2018→2022 na v1.
## Dados → decisão → apresentação
- **Vou apresentar dados?** Sim — superfície neste item (detalhe + lista).
- **Decisões desbloqueadas:**
- Staff: “este município é que fatia/posição da nossa votação?”
- Staff: “abrir a lista na ordem da mesa (maior concentração da própria votação primeiro)”
- **Forma escolhida:** **número + contexto** no detalhe (`12º de 435` · `3,1% da votação`); **tabela** na lista (coluna Votos 2022 + %, ordenável). **Rejeitado:** leaderboard gamificado; `% dos válidos` locais como âncora; chart.
- **Profile:** numérico absoluto (votos) + share relativo da própria votação estadual; granularidade = município operacional (435); ano lista = 2022.
- **Anti-goals:** sem % estadual absoluto como KPI; sem % do eleitorado local; sem medalhas/delta de rank na v1.
Self-check dados: 5/5.
## Design (Impeccable)
Âncoras: `PRODUCT.md` (princípio 6 — métricas relativas e locais) / `DESIGN.md` (register `product`) · `MunicipalityBaselineCard` e `MunicipalityList` existentes.
@ -26,7 +42,7 @@ A leitura "posição em votos absolutos + % da própria votação" é a **concen
- **Detalhe do município:** no `MunicipalityBaselineCard` (ou card irmão compacto), por ano da série (default 2022): posição em votos absolutos (rank denso entre as 435 unidades do catálogo, ex.: "12º de 435") e **% da própria votação estadual** (votos no município ÷ Σ votos do candidato no ano, ex.: "3,1% da votação").
- **Lista de municípios:** chave de ordenação nova `?sort=votos` (ano default 2022) no contrato URL de **B15** ([ordenacao-colunas-lista-municipios.md](ordenacao-colunas-lista-municipios.md) — `sort`/`dir` + header clicável); preservar filtros/paginação; coluna/linha secundária com votos 2022 + % da própria votação.
- **Helper puro** `src/utilities/municipalityVoteRank.ts`: computa uma vez por request (rank map + shares por ano sobre `federalBaselineMunicipalitySlugs()`), testável em unit; consumido pelo detalhe, pela lista e depois por E9/E17.
- **Helper puro** `src/lib/municipalityVoteRank.ts`: cache de módulo sobre o artefato imutável (rank map + shares por ano sobre `federalBaselineMunicipalitySlugs()`), testável em unit; consumido pelo detalhe, pela lista e depois por E9/E17.
- Access: superfícies staff (coordinator/advisor/candidate); leader segue em lockdown (sem páginas de município) — nada muda.
## Decisões travadas
@ -49,12 +65,12 @@ flowchart LR
Artifact["bahiaElectionAggregates.ts<br/>(votesByYear por slug)"]
Rank["municipalityVoteRank.ts<br/>(rank denso + share por ano)"]
Card["MunicipalityBaselineCard<br/>(posição + % da votação)"]
List["MunicipalityList + sort=votos<br/>(contrato B15)"]
List["MunicipalityList + sort=votos<br/>(contrato sort mínimo)"]
Artifact --> Rank --> Card
Rank --> List
```
Componentes tocados: `src/utilities/municipalityVoteRank.ts` (novo, puro + unit tests), `municipalityUi.ts` (nova key `votos` no allowlist B15), `MunicipalityList.tsx` (header `votos` + linha com votos/%), `MunicipalityBaselineCard.tsx` (posição/share por ano), `municipalityPageData.ts` (apply da key `votos` sobre o filtrado — mesmo path in-memory do B15). **Não** reinventar select "Ordenar por" no desktop se B15 já entregou header + select mobile.
Componentes tocados: `src/lib/municipalityVoteRank.ts` (novo, puro + unit tests), `municipalityUi.ts` (keys `name`|`votos` no allowlist), `MunicipalityList.tsx` / `MunicipalitySortableHead` (header `votos` + linha com votos/%), `MunicipalityBaselineCard.tsx` (posição/share por ano), `municipalityPageData.ts` (apply da key `votos` sobre o filtrado). **B15** amplia as demais keys de coluna depois.
## Dependências
@ -73,6 +89,15 @@ Componentes tocados: `src/utilities/municipalityVoteRank.ts` (novo, puro + unit
- **Seletor de ano na ordenação da lista.** Gatilho: pedido da mesa por leitura de série na lista.
- **Coluna de rank na fila do E9.** Entra junto com E9 (a fila referencia este helper).
- **Hidratação única do sort derivado `votos` (evitar find filtrado + `loadMunicipalityScope` redundante).** Gatilho: lista lenta no recorte amplo ou 3º path derivado no loader; B15 já documenta full-scan de `expectedVotes`/`coverage` — tratar junto.
## Já resolvido no simplify/critique (não reabrir)
- Hover no header `2022` (sem `?` / CampaignInfoHint) · Limpar preserva sort · chevrons ativos/dormant · label curto `2022` (não "Concentração 2022").
## Explicitamente fora
- Extrair `nextMunicipalitySortDir` / fundir com `CampaignInfoHint` (Popover ≠ hover) · markup único mobile/desktop · rank em SQL (artefato em memória basta).
## Referências

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-24 (B15 ✓ ordenação por coluna na lista; fill-in ícone+tooltip de prioridade na lista — [plano](plans/icone-prioridade-lista-municipios.md); fill-in Cenário junto aos filtros — [plano](plans/cenario-junto-filtros-municipios.md); E4R ✓; E17 ✓; B14; janela 1: smoke, A11, R6, E8)
Atualizado em: 2026-07-24 (A11 ✓ posição em votos + sort=votos; B15 ✓ ordenação por coluna na lista; fill-in ícone+tooltip de prioridade na lista — [plano](plans/icone-prioridade-lista-municipios.md); fill-in Cenário junto aos filtros — [plano](plans/cenario-junto-filtros-municipios.md); E4R ✓; E17 ✓; B14; janela 1: smoke, R6, E8)
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)
@ -70,8 +70,8 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
### Demais itens abertos
- ~~**E4R** import único da planilha de projeção~~ — **entregue 2026-07-24** (`pnpm db:seed:projecao`; overwrite-always; [plano](plans/import-planilha-projecao.md))
- **A11** posição em votos do município (rank absoluto + % da própria votação) + ordenação da lista por votação · a lente de prioridade atual da mesa (sessão 2026-07-23) · ~0,5–1d · Janela 1–2 · reusa contrato `sort`/`dir` de **B15**; E9 absorve como coluna/ordenação da fila · [plano](plans/ranking-votos-municipio.md)
- ~~**E17** tabela comparativa dos Territórios de Identidade no Início (pedido do candidato) · somas/razões apenas, Metropolitano decomposto (salvaguardas MAUP) · ~1d · Janela 1–2 · primeira fatia de E12 · [plano](plans/tabela-ti-inicio.md)~~ — **entregue 2026-07-24** (`territoryOverview.ts` + `loadTerritoryOverview.ts` + `TerritoryOverviewTable.tsx`; rollup puro client-safe, loader `overrideAccess: true`, Metropolitano decomposto, ordenação client-side; sem migration; A11 ausente → share local)
- ~~**A11** posição em votos do município (rank absoluto + % da própria votação) + ordenação da lista por votação~~ — **entregue 2026-07-24** (helper `municipalityVoteRank`; baseline + coluna lista; default `?sort=votos` desc; header=`2022`; consome contrato B15) · [plano](plans/ranking-votos-municipio.md)
- ~~**E17** tabela comparativa dos Territórios de Identidade no Início (pedido do candidato) · somas/razões apenas, Metropolitano decomposto (salvaguardas MAUP) · ~1d · Janela 1–2 · primeira fatia de E12 · [plano](plans/tabela-ti-inicio.md)~~ — **entregue 2026-07-24** (`territoryOverview.ts` + `loadTerritoryOverview.ts` + `TerritoryOverviewTable.tsx`; rollup puro client-safe, loader `overrideAccess: true`, Metropolitano decomposto, ordenação client-side; sem migration)
- **A6** dobradinha 2026 automática quando o TSE publicar candidaturas · gatilho externo: pós-15/08 · camada de insight sobre o registro operacional `stateDeputy` (M4) · alimenta **E13** · [plano](plans/insight-dobradinha-2026.md)
- **B5 F2–F3** cache CLI compartilhado + factory mun/TI (scripts continuam) · [plano](plans/escala-dry-pos-b2.md)
- **B8 F2** polígonos dos Municípios-zona de Salvador (ZE 1–19; Camaçari saiu do escopo na M1 — município inteiro): F1 catálogo zona→bairros entregue (hoje Salvador-only); falta prep do catálogo (~½ dia) + dissolve IBGE/malha → TopoJSON no mapa · Janela 3 · cortável · [plano](plans/poligonos-pracas-zona.md)
@ -119,7 +119,7 @@ flowchart TD
B14n["B14 Município mais próximo"]
B15n["B15 Ordenar lista<br/>por coluna"]
E4R["E4R ✓ Import planilha<br/>(seed estratégia)"]
A11n["A11 Posição em votos"]
A11n["A11 ✓ Posição em votos"]
E17n["E17 ✓ Tabela TI no Início"]
subgraph Intel["Inteligência de campanha"]
@ -160,11 +160,11 @@ flowchart TD
B8F2 -.ZE Salvador.-> B14n
```
Paralelizáveis agora: **A11** (semana de onboarding; E4R ✓ seedou o quadro; A11 soft-dep B15 ✓), **E8** (A10 ✓ e remodelagem em produção), **C12** (paralelo a E8), **E16** (compõe o existente), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), fill-ins (ícone de prioridade na lista, Cenário junto aos filtros, O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12) não absorver os deltas do ZAP (não compete com E8/E9).
Paralelizáveis agora: **E8** (A10 ✓ e remodelagem em produção), **C12** (paralelo a E8), **E16** (compõe o existente), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), fill-ins (ícone de prioridade na lista, Cenário junto aos filtros, O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12) não absorver os deltas do ZAP (não compete com E8/E9).
### Sequência por janela (só pendentes)
**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) entregue 2026-07-24; **A11** na semana de onboarding; gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E8** pode começar em paralelo; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa).
**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) e **A11 ✓** (posição em votos + default `sort=votos`) entregues 2026-07-24; gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E8** pode começar em paralelo; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa).
**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; **E8** conta da cadeira → **E9** fila de alocação, com **C12** registro-fundação em paralelo (migrations cedo, longe do congelamento); **E16** dossiê do município (pedido O6 — compõe o existente, melhora com E8); D2 se sobrar folga.
@ -176,7 +176,7 @@ Paralelizáveis agora: **A11** (semana de onboarding; E4R ✓ seedou o quadro; A
**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); C2 dados reais; assimetria declarado×estimado (relação de campo); **E8**+**E9**+**C12** (a conta da cadeira, a fila e o registro ex-ante são o mínimo de "inteligência, não planilha" — e C12 é irrecuperável se não registrar durante a campanha).
**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter fila E9 sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; **B14** município mais próximo (lista/busca e Recentes continuam); ~~**B15** sort por coluna~~ (entregue 2026-07-24); **A11**/**E17** (baratos — cortar só em último caso); **E16** dossiê (último dos extras — pedido explícito de campo); débitos/fill-ins.
**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter fila E9 sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; **B14** município mais próximo (lista/busca e Recentes continuam); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); **E16** dossiê (último dos extras — pedido explícito de campo); débitos/fill-ins.
## Bloqueadores atuais

View file

@ -238,7 +238,7 @@ const ElectionsTab = async ({
return (
<div className="flex flex-col gap-6">
<MunicipalityBaselineCard baseline={baseline} />
<MunicipalityBaselineCard baseline={baseline} municipalitySlug={slug} />
<section
aria-labelledby="municipality-comparison-title"
className="flex flex-col gap-4 rounded-xl border p-4"

View file

@ -3,6 +3,7 @@
import { useRouter } from 'next/navigation'
import {
createContext,
forwardRef,
useContext,
useTransition,
type ComponentPropsWithoutRef,
@ -40,16 +41,18 @@ export const CampaignListPendingBoundary = ({ children }: { children: ReactNode
export const useCampaignListPending = (): CampaignListPendingValue | null =>
useContext(CampaignListPendingContext)
/**
* Anchor that navigates through the shared list transition (falling back to a
* local one), so the results region dims instead of a dead full-page load.
* Takes only serializable props — safe to render from server components.
*/
export const CampaignTransitionAnchor = ({
href,
children,
...anchorProps
}: Omit<ComponentPropsWithoutRef<'a'>, 'href' | 'onClick'> & { href: string }) => {
/** Dims while a shared list navigation is pending; safe to render from RSC. */
export const CampaignTransitionAnchor = forwardRef<
HTMLAnchorElement,
Omit<ComponentPropsWithoutRef<'a'>, 'href' | 'onClick'> & {
href: string
replace?: boolean
scroll?: boolean
}
>(function CampaignTransitionAnchor(
{ href, children, replace = false, scroll = true, ...anchorProps },
ref,
) {
const router = useRouter()
const shared = useContext(CampaignListPendingContext)
const [, startLocalTransition] = useTransition()
@ -57,6 +60,7 @@ export const CampaignTransitionAnchor = ({
return (
<a
ref={ref}
href={href}
{...anchorProps}
onClick={(event) => {
@ -72,14 +76,15 @@ export const CampaignTransitionAnchor = ({
}
event.preventDefault()
startTransition(() => {
router.push(href)
if (replace) router.replace(href, { scroll })
else router.push(href, { scroll })
})
}}
>
{children}
</a>
)
}
})
/** Wraps the server-rendered results; dims while a shared navigation is pending. */
export const CampaignListResults = ({

View file

@ -1,12 +1,24 @@
import { computeVoteTrend } from '@/lib/electionInsights'
import { MunicipalityHoverTooltip } from '@/components/campaign/MunicipalityHoverTooltip'
import { computeVoteTrend, formatElectionNumber } from '@/lib/electionInsights'
import {
DEFAULT_VOTE_RANK_YEAR,
formatMunicipalityVoteRank,
formatMunicipalityVoteShare,
getMunicipalityVoteRank,
} from '@/lib/municipalityVoteRank'
import { formatMunicipalityConcentrationHint } from '@/utilities/municipalityUi'
import type { MunicipalityElectoralBaseline } from '@/utilities/municipalityElectoralBaseline'
const voteFormatter = new Intl.NumberFormat('pt-BR')
const seriesVotes = (baseline: MunicipalityElectoralBaseline, year: number): number =>
baseline.series.find((point) => point.year === year)?.votes ?? 0
export const MunicipalityBaselineCard = ({ baseline }: { baseline: MunicipalityElectoralBaseline | null }) => {
export const MunicipalityBaselineCard = ({
baseline,
municipalitySlug,
}: {
baseline: MunicipalityElectoralBaseline | null
municipalitySlug: string
}) => {
if (!baseline) {
return (
<section
@ -17,8 +29,8 @@ export const MunicipalityBaselineCard = ({ baseline }: { baseline: MunicipalityE
Votação nas eleições anteriores
</h2>
<p className="text-sm text-muted-foreground">
Sem dados TSE importados para esta Praça. Rode o seed eleitoral no ambiente para habilitar
o baseline.
Sem baseline TSE neste ambiente. A concentração e o histórico de votos ficam disponíveis
quando os resultados forem importados.
</p>
</section>
)
@ -26,12 +38,13 @@ export const MunicipalityBaselineCard = ({ baseline }: { baseline: MunicipalityE
const latest = baseline.series[baseline.series.length - 1]
// "Evolução de votos" — série numérica derivada. "Tendência política" é outro
// conceito (conjuntura, registrada manualmente pela coordenação na Praça).
// conceito (conjuntura, registrada manualmente pela coordenação no município).
const evolution = computeVoteTrend({
y2014: seriesVotes(baseline, 2014),
y2018: seriesVotes(baseline, 2018),
y2022: seriesVotes(baseline, 2022),
y2022: seriesVotes(baseline, DEFAULT_VOTE_RANK_YEAR),
})
const concentrationHint = formatMunicipalityConcentrationHint()
return (
<section
@ -39,23 +52,46 @@ export const MunicipalityBaselineCard = ({ baseline }: { baseline: MunicipalityE
className="flex flex-col gap-4 rounded-xl border p-4"
>
<div className="flex flex-col gap-1">
<h2 id="municipality-baseline-title" className="text-base font-medium">
Votação de {baseline.candidateName} ({baseline.candidateParty})
</h2>
<MunicipalityHoverTooltip content={concentrationHint}>
<h2 id="municipality-baseline-title" className="w-fit text-base font-medium">
Votação de {baseline.candidateName} ({baseline.candidateParty})
</h2>
</MunicipalityHoverTooltip>
<p className="text-sm text-muted-foreground">
Votos nominais para deputado federal (1º turno) na geografia desta Praça — fonte TSE.
Votos nominais para deputado federal (1º turno) na geografia deste município — fonte TSE.
</p>
</div>
<dl className="grid gap-3 sm:grid-cols-3">
{baseline.series.map((point) => (
<div key={point.year} className="rounded-lg bg-muted/40 px-3 py-2">
<dt className="text-xs font-medium text-muted-foreground">Eleição {point.year}</dt>
<dd className="text-lg font-medium tabular-nums">
{voteFormatter.format(point.votes)}
</dd>
</div>
))}
{baseline.series.map((point) => {
const position = getMunicipalityVoteRank(municipalitySlug, point.year)
return (
<div
key={point.year}
className={
point.year === DEFAULT_VOTE_RANK_YEAR
? 'rounded-lg bg-muted/60 px-3 py-2 ring-1 ring-border'
: 'rounded-lg bg-muted/40 px-3 py-2'
}
>
<dt className="text-xs font-medium text-muted-foreground">Eleição {point.year}</dt>
<dd className="text-lg font-medium tabular-nums">
{formatElectionNumber(point.votes)}
</dd>
{position ? (
<dd
className="mt-1 flex flex-col gap-0.5 text-xs tabular-nums text-muted-foreground"
aria-label={`${formatMunicipalityVoteShare(position.share)} da votação estadual, ${formatMunicipalityVoteRank(position.rank)} de ${formatElectionNumber(position.totalUnits)}`}
>
<span>{formatMunicipalityVoteShare(position.share)}</span>
<span>{formatMunicipalityVoteRank(position.rank)}</span>
</dd>
) : (
<dd className="mt-1 text-xs text-muted-foreground">—</dd>
)}
</div>
)
})}
</dl>
{evolution.status !== 'noBaseline' ? (
@ -63,27 +99,34 @@ export const MunicipalityBaselineCard = ({ baseline }: { baseline: MunicipalityE
) : null}
{baseline.tally2022 ? (
<p className="text-sm text-muted-foreground">
2022: {voteFormatter.format(baseline.tally2022.aptos)} aptos ·{' '}
{voteFormatter.format(baseline.tally2022.comparecimento)} compareceram ·{' '}
{voteFormatter.format(baseline.tally2022.votosValidos)} votos válidos
{latest && baseline.tally2022.votosValidos > 0
? ` · ${((latest.votes / baseline.tally2022.votosValidos) * 100).toLocaleString('pt-BR', { maximumFractionDigits: 1 })}% dos válidos`
: ''}
</p>
<div className="flex flex-col gap-1 border-t border-border/60 pt-3 text-xs text-muted-foreground">
<p className="font-medium text-muted-foreground">Comparativos locais</p>
<p>
2022: {formatElectionNumber(baseline.tally2022.aptos)} aptos ·{' '}
{formatElectionNumber(baseline.tally2022.comparecimento)} compareceram ·{' '}
{formatElectionNumber(baseline.tally2022.votosValidos)} votos válidos
</p>
{latest && baseline.tally2022.votosValidos > 0 ? (
<p>
Dominância local:{' '}
{formatMunicipalityVoteShare(latest.votes / baseline.tally2022.votosValidos)} dos
válidos neste município (distinto da concentração acima).
</p>
) : null}
</div>
) : null}
{baseline.ticket2022.president != null || baseline.ticket2022.governor != null ? (
<p className="text-sm text-muted-foreground">
Chapa 2022:{' '}
{baseline.ticket2022.president != null
? `Lula ${voteFormatter.format(baseline.ticket2022.president)} votos`
? `Lula ${formatElectionNumber(baseline.ticket2022.president)} votos`
: null}
{baseline.ticket2022.president != null && baseline.ticket2022.governor != null
? ' · '
: null}
{baseline.ticket2022.governor != null
? `Jerônimo ${voteFormatter.format(baseline.ticket2022.governor)} votos`
? `Jerônimo ${formatElectionNumber(baseline.ticket2022.governor)} votos`
: null}
</p>
) : null}

View file

@ -13,13 +13,12 @@ import { normalizedText } from '@/utilities/campaignListUrl'
import {
buildMunicipalityFiltersKey,
buildMunicipalityListHref,
DEFAULT_MUNICIPALITY_LIST_SORT_DIR,
DEFAULT_MUNICIPALITY_LIST_SORT_KEY,
municipalityKindLabels,
municipalityListCoverageLabels,
municipalityListSortOptions,
parseMunicipalitySortValue,
politicalTrendLabels,
resolveMunicipalityListSort,
serializeMunicipalitySortValue,
shouldUpdateMunicipalitySearchUrl,
type MunicipalityListState,
@ -41,6 +40,7 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
const isPending = sharedPending?.isPending ?? isLocalPending
const startTransition = sharedPending?.startTransition ?? startLocalTransition
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state)
useEffect(
() => () => {
@ -109,7 +109,7 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
<div className="flex flex-col gap-3 md:flex-row md:items-end">
<CampaignSearchInput
id="municipality-search"
label="Buscar Praça"
label="Buscar município"
placeholder="Buscar por município ou zona…"
value={search}
onChange={(event) => {
@ -231,10 +231,7 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
<FieldLabel htmlFor="municipality-sort">Ordenar</FieldLabel>
<NativeSelect
id="municipality-sort"
value={serializeMunicipalitySortValue(
state.sort ?? DEFAULT_MUNICIPALITY_LIST_SORT_KEY,
state.dir ?? DEFAULT_MUNICIPALITY_LIST_SORT_DIR,
)}
value={serializeMunicipalitySortValue(activeSort, activeDir)}
onChange={(event) => {
const parsed = parseMunicipalitySortValue(event.target.value)
if (parsed) commitNavigation({ sort: parsed.key, dir: parsed.dir })
@ -261,7 +258,10 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
clearDebounce()
setSearch('')
startTransition(() => {
router.replace('/campanha/municipios', { scroll: false })
router.replace(
buildMunicipalityListHref({ page: 1, sort: state.sort, dir: state.dir }, 1),
{ scroll: false },
)
})
}}
>

View file

@ -0,0 +1,27 @@
'use client'
import type { ReactElement, ReactNode } from 'react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
/** Hover/focus explanation with no extra chrome — wraps an existing control or heading. */
export const MunicipalityHoverTooltip = ({
content,
children,
side = 'bottom',
align = 'center',
}: {
content: ReactNode
children: ReactElement
side?: 'top' | 'right' | 'bottom' | 'left'
align?: 'start' | 'center' | 'end'
}) => (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side={side} align={align} className="max-w-xs text-left font-normal">
{content}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)

View file

@ -1,33 +1,36 @@
import { ChevronDownIcon, CircleAlertIcon, CircleCheckIcon } from 'lucide-react'
import { CircleAlertIcon, CircleCheckIcon } from 'lucide-react'
import Link from 'next/link'
import type { ReactNode } from 'react'
import { CampaignTransitionAnchor } from '@/components/campaign/CampaignListPending'
import { MunicipalityAdvisorAvatarStack } from '@/components/campaign/MunicipalityAdvisorAvatarStack'
import { MunicipalityListAdvisorsControl } from '@/components/campaign/MunicipalityListAdvisorsControl'
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/MunicipalityListExpectedVotesControl'
import { MunicipalityListTrendControl } from '@/components/campaign/MunicipalityListTrendControl'
import { MunicipalitySortableHead } from '@/components/campaign/MunicipalitySortableHead'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/Table'
import { formatElectionNumber } from '@/lib/electionInsights'
import {
formatMunicipalityVoteRank,
formatMunicipalityVoteShare,
} from '@/lib/municipalityVoteRank'
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import {
buildMunicipalitySortHref,
DEFAULT_MUNICIPALITY_LIST_SORT_DIR,
DEFAULT_MUNICIPALITY_LIST_SORT_KEY,
formatMunicipalityConcentrationHint,
formatMunicipalityGeographyLabel,
formatMunicipalityListSortSummary,
municipalityKindLabels,
municipalityListSortLabels,
municipalityPriorityLabels,
type MunicipalityListSortKey,
resolveMunicipalityListSort,
type MunicipalityListState,
} from '@/utilities/municipalityUi'
import type {
@ -55,46 +58,29 @@ export type MunicipalityListProps = {
state: MunicipalityListState
}
type MunicipalitySortableHeadProps = {
state: MunicipalityListState
sortKey: MunicipalityListSortKey
children: ReactNode
className?: string
align?: 'left' | 'center'
}
const MunicipalitySortableHead = ({
state,
sortKey,
children,
className,
align = 'left',
}: MunicipalitySortableHeadProps) => {
const active = (state.sort ?? DEFAULT_MUNICIPALITY_LIST_SORT_KEY) === sortKey
const dir = active ? state.dir ?? DEFAULT_MUNICIPALITY_LIST_SORT_DIR : undefined
const isAscending = dir === 'asc'
const VotePositionReadout = ({
position,
layout,
}: {
position: NonNullable<MunicipalityListViewModel['votePosition2022']>
layout: 'table' | 'card'
}) => {
const share = formatMunicipalityVoteShare(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)}`
return (
<TableHead
className={cn(align === 'center' && 'text-center', className)}
aria-sort={active ? (isAscending ? 'ascending' : 'descending') : 'none'}
<div
className={cn(
'flex flex-col gap-0.5 tabular-nums',
layout === 'table' ? 'items-end text-right' : 'text-sm',
)}
aria-label={ariaLabel}
>
<CampaignTransitionAnchor
href={buildMunicipalitySortHref(state, sortKey)}
className="inline-flex items-center gap-1"
aria-label={`Ordenar por ${municipalityListSortLabels[sortKey]}`}
>
{children}
<ChevronDownIcon
aria-hidden="true"
className={cn(
'h-4 w-4 transition-transform',
active ? 'text-foreground' : 'text-muted-foreground/50',
active && isAscending && '-rotate-180',
)}
/>
</CampaignTransitionAnchor>
</TableHead>
<span className={cn('font-medium', layout === 'card' && 'text-foreground')}>{share}</span>
<span className="text-xs text-muted-foreground">{metaLine}</span>
</div>
)
}
@ -121,11 +107,21 @@ export const MunicipalityList = ({
trendFormAction,
advisorsFormAction,
state,
}: MunicipalityListProps) => (
<>
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
}: MunicipalityListProps) => {
const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state)
const sortSummary = formatMunicipalityListSortSummary(activeSort, activeDir)
const concentrationHint = formatMunicipalityConcentrationHint()
return (
<>
<p className="text-sm text-muted-foreground" aria-live="polite">
{sortSummary}
</p>
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
{municipalities.map((municipality) => {
const names = advisorNames(municipality, advisorNamesById)
const position = municipality.votePosition2022
return (
<article key={municipality.id} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-start justify-between gap-2">
@ -134,6 +130,7 @@ export const MunicipalityList = ({
<p className="text-sm text-muted-foreground">
{formatMunicipalityGeographyLabel(municipality)}
</p>
{position ? <VotePositionReadout position={position} layout="card" /> : null}
</div>
{municipality.priority === 'alta' && isStaffView ? (
<Badge variant="destructive">{municipalityPriorityLabels.alta}</Badge>
@ -185,7 +182,7 @@ export const MunicipalityList = ({
</dl>
) : null}
<Button asChild variant="outline" className="min-h-11 w-full">
<Link href={`/campanha/municipios/${municipality.slug}`}>Abrir Praça</Link>
<Link href={`/campanha/municipios/${municipality.slug}`}>Abrir município</Link>
</Button>
</article>
)
@ -194,10 +191,13 @@ export const MunicipalityList = ({
<div data-view="desktop-table" className="hidden overflow-hidden rounded-xl border md:block">
<Table>
<TableCaption className="sr-only">
{sortSummary}. Coluna 2022: {concentrationHint}
</TableCaption>
<TableHeader>
<TableRow>
<MunicipalitySortableHead state={state} sortKey="name">
Praça
Município
</MunicipalitySortableHead>
<MunicipalitySortableHead state={state} sortKey="region">
Território de identidade
@ -205,6 +205,12 @@ export const MunicipalityList = ({
<MunicipalitySortableHead state={state} sortKey="kind">
Tipo
</MunicipalitySortableHead>
<MunicipalitySortableHead
state={state}
sortKey="votos"
align="right"
tooltip={concentrationHint}
/>
{isStaffView ? (
<>
<TableHead>Assessores</TableHead>
@ -251,6 +257,16 @@ export const MunicipalityList = ({
{municipality.region}
</TableCell>
<TableCell>{municipalityKindLabels[municipality.kind]}</TableCell>
<TableCell className="text-right">
{municipality.votePosition2022 ? (
<VotePositionReadout
position={municipality.votePosition2022}
layout="table"
/>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
{isStaffView ? (
<>
<TableCell>
@ -318,3 +334,4 @@ export const MunicipalityList = ({
</div>
</>
)
}

View file

@ -0,0 +1,105 @@
'use client'
import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { CampaignTransitionAnchor } from '@/components/campaign/CampaignListPending'
import { MunicipalityHoverTooltip } from '@/components/campaign/MunicipalityHoverTooltip'
import { TableHead } from '@/components/ui/Table'
import { cn } from '@/lib/utils'
import {
buildMunicipalitySortHref,
defaultMunicipalityListSortDir,
municipalityListSortLabels,
resolveMunicipalityListSort,
type MunicipalityListSortKey,
type MunicipalityListState,
} from '@/utilities/municipalityUi'
type MunicipalitySortableHeadProps = {
state: MunicipalityListState
sortKey: MunicipalityListSortKey
children?: ReactNode
tooltip?: ReactNode
className?: string
align?: 'left' | 'center' | 'right'
}
export const MunicipalitySortableHead = ({
state,
sortKey,
children,
tooltip,
className,
align = 'left',
}: MunicipalitySortableHeadProps) => {
const href = buildMunicipalitySortHref(state, sortKey)
const { sort: activeSort, dir } = resolveMunicipalityListSort(state)
const active = activeSort === sortKey
const label = children ?? municipalityListSortLabels[sortKey]
const ariaSort = active ? (dir === 'asc' ? 'ascending' : 'descending') : 'none'
const nextDir = active
? dir === 'asc'
? 'desc'
: 'asc'
: defaultMunicipalityListSortDir(sortKey)
const nextDirLabel = nextDir === 'asc' ? 'crescente' : 'decrescente'
const sortControl = (
<CampaignTransitionAnchor
href={href}
replace
scroll={false}
className={cn(
'group inline-flex min-h-11 items-center gap-1 rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
active
? 'font-medium text-foreground hover:text-primary'
: 'font-normal text-muted-foreground hover:text-foreground',
align === 'right' && 'w-full justify-end',
align === 'center' && 'w-full justify-center',
)}
aria-label={
active
? `Ordenar por ${municipalityListSortLabels[sortKey]}, inverter para ${nextDirLabel}`
: `Ordenar por ${municipalityListSortLabels[sortKey]} (${nextDirLabel})`
}
>
<span>{label}</span>
{active ? (
dir === 'asc' ? (
<ChevronUpIcon className="size-3.5 shrink-0" aria-hidden="true" />
) : (
<ChevronDownIcon className="size-3.5 shrink-0" aria-hidden="true" />
)
) : (
<ChevronsUpDownIcon
className="size-3.5 shrink-0 opacity-60 group-hover:opacity-100 group-focus-visible:opacity-100"
aria-hidden="true"
/>
)}
</CampaignTransitionAnchor>
)
return (
<TableHead
aria-sort={ariaSort}
className={cn(
align === 'right' && 'text-right',
align === 'center' && 'text-center',
align === 'left' && 'text-left',
className,
)}
>
{tooltip ? (
<MunicipalityHoverTooltip
content={tooltip}
align={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
>
{sortControl}
</MunicipalityHoverTooltip>
) : (
sortControl
)}
</TableHead>
)
}

View file

@ -0,0 +1,95 @@
import {
federalBaselineMunicipalitySlugs,
getMunicipalityFederalBaseline,
} from '@/lib/bahiaElectionAggregates'
import { formatElectionNumber } from '@/lib/electionInsights'
/** Year the mesa uses as the current priority lens (A11 list sort + default readout). */
export const DEFAULT_VOTE_RANK_YEAR = 2022
export type MunicipalityVoteRankEntry = {
rank: number
votes: number
/** Fraction of the candidate's statewide (artifact) total for the year — 0..1. */
share: number
totalUnits: number
}
const rankByYearCache = new Map<number, ReadonlyMap<string, MunicipalityVoteRankEntry>>()
/**
* Dense rank + share of own statewide vote for every catalog municipality slug
* in the committed federal baseline artifact. Ties share a rank; the next
* distinct total gets the next consecutive rank (no gaps).
*
* Cached at module scope — the artifact is immutable for the process lifetime.
*/
export const computeVoteRankByYear = (
year: number,
): ReadonlyMap<string, MunicipalityVoteRankEntry> => {
const cached = rankByYearCache.get(year)
if (cached) return cached
const yearKey = String(year)
const slugs = federalBaselineMunicipalitySlugs()
const rows = slugs.map((slug) => ({
slug,
votes: getMunicipalityFederalBaseline(slug).votesByYear[yearKey] ?? 0,
}))
let candidateTotal = 0
for (const row of rows) candidateTotal += row.votes
rows.sort((a, b) => {
if (b.votes !== a.votes) return b.votes - a.votes
return a.slug.localeCompare(b.slug)
})
const totalUnits = rows.length
const bySlug = new Map<string, MunicipalityVoteRankEntry>()
let denseRank = 0
let previousVotes: number | undefined
for (const row of rows) {
if (row.votes !== previousVotes) {
denseRank += 1
previousVotes = row.votes
}
bySlug.set(row.slug, {
rank: denseRank,
votes: row.votes,
share: candidateTotal > 0 ? row.votes / candidateTotal : 0,
totalUnits,
})
}
rankByYearCache.set(year, bySlug)
return bySlug
}
export const getMunicipalityVoteRank = (
slug: string,
year: number = DEFAULT_VOTE_RANK_YEAR,
): MunicipalityVoteRankEntry | null => computeVoteRankByYear(year).get(slug) ?? null
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,
bVotes: number,
dir: 'asc' | 'desc',
): number => {
const aZero = aVotes === 0
const bZero = bVotes === 0
if (aZero !== bZero) return aZero ? 1 : -1
const cmp = aVotes - bVotes
return dir === 'asc' ? cmp : -cmp
}

View file

@ -1,15 +1,20 @@
import type { Payload } from 'payload'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import {
compareMunicipalityVotesForSort,
computeVoteRankByYear,
DEFAULT_VOTE_RANK_YEAR,
type MunicipalityVoteRankEntry,
} from '@/lib/municipalityVoteRank'
import type { CampaignUser, Municipality } from '@/payload-types'
import { isCampaignLeader, isCampaignStaff } from '@/utilities/campaignAccess'
import { loadMunicipalityScope } from '@/utilities/campaignMunicipalityScope'
import {
buildMunicipalityListWhere,
DEFAULT_MUNICIPALITY_LIST_SORT_DIR,
DEFAULT_MUNICIPALITY_LIST_SORT_KEY,
municipalityPageSize,
parseMunicipalityListParams,
resolveMunicipalityListSort,
type MunicipalityListSearchParams,
type MunicipalityListSortDirection,
type MunicipalityListSortKey,
@ -59,7 +64,6 @@ const payloadSortFieldByKey: Partial<Record<MunicipalityListSortKey, string>> =
kind: 'kind',
lastUpdateAt: 'lastUpdateAt',
trend: 'politicalTrend.status',
votos: 'name',
}
const municipalityNameCompare = (left: Municipality, right: Municipality): number =>
@ -88,12 +92,23 @@ const applyDerivedMunicipalitySort = (
docs: Municipality[],
sortKey: MunicipalityListSortKey,
dir: MunicipalityListSortDirection,
ranks: ReadonlyMap<string, MunicipalityVoteRankEntry>,
): Municipality[] => {
switch (sortKey) {
case 'expectedVotes':
return sortByNullableValue(docs, dir, (municipality) => municipality.expectedVotes?.central)
case 'coverage':
return sortByNullableValue(docs, dir, (municipality) => municipality.advisors?.length)
case 'votos':
return [...docs].sort((left, right) => {
const byVotes = compareMunicipalityVotesForSort(
ranks.get(left.slug)?.votes ?? 0,
ranks.get(right.slug)?.votes ?? 0,
dir,
)
if (byVotes !== 0) return byVotes
return municipalityNameCompare(left, right)
})
default:
return docs
}
@ -107,8 +122,7 @@ export const loadMunicipalityListPageBundle = async (
const state = parseMunicipalityListParams(searchParams)
const where = buildMunicipalityListWhere(state)
const isStaff = isCampaignStaff(user)
const sortKey = state.sort ?? DEFAULT_MUNICIPALITY_LIST_SORT_KEY
const sortDir = state.dir ?? DEFAULT_MUNICIPALITY_LIST_SORT_DIR
const { sort: sortKey, dir: sortDir } = resolveMunicipalityListSort(state)
if (isCampaignLeader(user)) {
return {
@ -120,6 +134,7 @@ export const loadMunicipalityListPageBundle = async (
}
}
const ranks = computeVoteRankByYear(DEFAULT_VOTE_RANK_YEAR)
const nativeSortField = payloadSortFieldByKey[sortKey]
const isNative = nativeSortField !== undefined
@ -181,7 +196,12 @@ export const loadMunicipalityListPageBundle = async (
totalDocs = listResult.totalDocs
totalPages = listResult.totalPages
} else {
const allDocs = applyDerivedMunicipalitySort(listResult.docs as Municipality[], sortKey, sortDir)
const allDocs = applyDerivedMunicipalitySort(
listResult.docs as Municipality[],
sortKey,
sortDir,
ranks,
)
totalDocs = allDocs.length
totalPages = Math.max(1, Math.ceil(totalDocs / municipalityPageSize))
const start = (state.page - 1) * municipalityPageSize
@ -193,6 +213,7 @@ export const loadMunicipalityListPageBundle = async (
toMunicipalityListViewModel(
municipality,
isStaff ? pledgeAggregates.get(municipality.id) : undefined,
ranks.get(municipality.slug) ?? null,
),
),
totalDocs,

View file

@ -1,6 +1,8 @@
import type { Where } from 'payload'
import { bahiaIdentityTerritories, type BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import { formatElectionNumber } from '@/lib/electionInsights'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import type { CampaignUser, Municipality } from '@/payload-types'
import {
buildListHref,
@ -50,18 +52,20 @@ export type MunicipalityListSortKey =
export type MunicipalityListSortDirection = 'asc' | 'desc'
export const DEFAULT_MUNICIPALITY_LIST_SORT_KEY: MunicipalityListSortKey = 'name'
export const DEFAULT_MUNICIPALITY_LIST_SORT_DIR: MunicipalityListSortDirection = 'asc'
/** Mesa lens: list opens by 2022 vote concentration (desc). */
export const DEFAULT_MUNICIPALITY_LIST_SORT_KEY: MunicipalityListSortKey = 'votos'
export const DEFAULT_MUNICIPALITY_LIST_SORT_DIR: MunicipalityListSortDirection = 'desc'
export const municipalityListSortLabels: Record<MunicipalityListSortKey, string> = {
name: 'Praça',
name: 'Município',
region: 'Território de identidade',
kind: 'Tipo',
trend: 'Tendência',
expectedVotes: 'Votos estimados',
lastUpdateAt: 'Última atualização',
coverage: 'Cobertura',
votos: 'Votos',
/** Short header — definition lives on hover (`formatMunicipalityConcentrationHint`). */
votos: '2022',
}
export type MunicipalityListState = {
@ -108,6 +112,43 @@ const municipalityListSortKeySet = new Set<MunicipalityListSortKey>([
const municipalityListSortDirSet = new Set<MunicipalityListSortDirection>(['asc', 'desc'])
const sortKeysWithDescDefault: MunicipalityListSortKey[] = [
'expectedVotes',
'lastUpdateAt',
'votos',
]
export const defaultMunicipalityListSortDir = (
key: MunicipalityListSortKey,
): MunicipalityListSortDirection => (sortKeysWithDescDefault.includes(key) ? 'desc' : 'asc')
export const resolveMunicipalityListSort = (
state: MunicipalityListState,
): { sort: MunicipalityListSortKey; dir: MunicipalityListSortDirection } => {
const sort = state.sort ?? DEFAULT_MUNICIPALITY_LIST_SORT_KEY
const dir = state.dir ?? defaultMunicipalityListSortDir(sort)
return { sort, dir }
}
export const formatMunicipalityConcentrationHint = (
totalUnits: number = municipalityCatalog.length,
): string =>
`Percentual da votação estadual do candidato neste município — não o % dos válidos locais. Colocação: posição no catálogo de ${formatElectionNumber(totalUnits)} unidades.`
export const formatMunicipalityListSortSummary = (
sort: MunicipalityListSortKey,
dir: MunicipalityListSortDirection,
): string => {
if (sort === 'votos') {
return dir === 'desc' ? 'Ordenado por 2022 ↓' : 'Ordenado por 2022 ↑'
}
if (sort === 'name') {
return dir === 'asc' ? 'Ordenado por nome (A–Z)' : 'Ordenado por nome (Z–A)'
}
const label = municipalityListSortLabels[sort]
return dir === 'desc' ? `Ordenado por ${label} ↓` : `Ordenado por ${label} ↑`
}
const canonicalTerritoryBySearchValue = new Map(
bahiaIdentityTerritories.map((territory) => [normalizeSearchPhrase(territory), territory]),
)
@ -190,6 +231,11 @@ export const buildMunicipalityListSearchParams = (
dir: state.dir,
})
const params = new URLSearchParams()
const resolvedSort = canonicalState.sort ?? DEFAULT_MUNICIPALITY_LIST_SORT_KEY
const resolvedDir = canonicalState.dir ?? defaultMunicipalityListSortDir(resolvedSort)
const isListDefault =
resolvedSort === DEFAULT_MUNICIPALITY_LIST_SORT_KEY &&
resolvedDir === defaultMunicipalityListSortDir(DEFAULT_MUNICIPALITY_LIST_SORT_KEY)
if (canonicalState.q) params.set('q', canonicalState.q)
if (canonicalState.region) params.set('region', canonicalState.region)
@ -198,11 +244,13 @@ export const buildMunicipalityListSearchParams = (
if (canonicalState.priority) params.set('priority', canonicalState.priority)
if (canonicalState.trend) params.set('trend', canonicalState.trend)
if (canonicalState.compare) params.set('compare', String(canonicalState.compare))
if (canonicalState.sort && canonicalState.sort !== DEFAULT_MUNICIPALITY_LIST_SORT_KEY) {
params.set('sort', canonicalState.sort)
}
if (canonicalState.dir && canonicalState.dir !== DEFAULT_MUNICIPALITY_LIST_SORT_DIR) {
params.set('dir', canonicalState.dir)
// Omit the mesa default pair (votos+desc). Keep `sort` whenever the pair is
// non-default so `dir` is never orphaned (e.g. votos+asc → sort=votos&dir=asc).
if (!isListDefault) {
params.set('sort', resolvedSort)
if (resolvedDir !== defaultMunicipalityListSortDir(resolvedSort)) {
params.set('dir', resolvedDir)
}
}
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))
@ -215,24 +263,18 @@ export const buildMunicipalityFiltersKey = (state: MunicipalityListState): strin
export const buildMunicipalityListHref = (state: MunicipalityListState, page: number): string =>
buildListHref(state, buildMunicipalityListSearchParams, '/campanha/municipios', page)
const sortKeysWithDescDefault: MunicipalityListSortKey[] = [
'expectedVotes',
'lastUpdateAt',
'votos',
]
export const buildMunicipalitySortHref = (
state: MunicipalityListState,
nextKey: MunicipalityListSortKey,
): string => {
const currentSort = state.sort ?? DEFAULT_MUNICIPALITY_LIST_SORT_KEY
const currentDir = state.dir ?? DEFAULT_MUNICIPALITY_LIST_SORT_DIR
const currentDir = state.dir ?? defaultMunicipalityListSortDir(currentSort)
let dir: MunicipalityListSortDirection
if (nextKey === currentSort) {
dir = currentDir === 'asc' ? 'desc' : 'asc'
} else {
dir = sortKeysWithDescDefault.includes(nextKey) ? 'desc' : 'asc'
dir = defaultMunicipalityListSortDir(nextKey)
}
const updatedState: MunicipalityListState = {
@ -321,7 +363,7 @@ export const buildMunicipalityListVisitLabel = (state: MunicipalityListState): s
if (!parts.length) return null
const label = `Praças · ${parts.join(' · ')}`
const label = `Municípios · ${parts.join(' · ')}`
if (label.length <= MAX_MUNICIPALITY_LIST_VISIT_LABEL_LENGTH) return label
return `${label.slice(0, MAX_MUNICIPALITY_LIST_VISIT_LABEL_LENGTH - 1)}…`
}
@ -331,12 +373,12 @@ export const buildMunicipalityListVisitHref = (state: MunicipalityListState): st
export const getCampaignScopeLabel = (role: CampaignUser['role'], municipalityCount: number): string => {
if (role === 'advisor') {
return `${municipalityCount} ${municipalityCount === 1 ? 'Praça sob sua assessoria' : 'Praças sob sua assessoria'}`
return `${municipalityCount} ${municipalityCount === 1 ? 'município sob sua assessoria' : 'municípios sob sua assessoria'}`
}
if (role === 'leader') {
return `${municipalityCount} ${municipalityCount === 1 ? 'Praça em que você atua' : 'Praças em que você atua'}`
return `${municipalityCount} ${municipalityCount === 1 ? 'município em que você atua' : 'municípios em que você atua'}`
}
return `${municipalityCount} ${municipalityCount === 1 ? 'Praça' : 'Praças'}`
return `${municipalityCount} ${municipalityCount === 1 ? 'município' : 'municípios'}`
}
/** Short human description of a municipality's geography, e.g. "Chapada Diamantina · ZE 105". */

View file

@ -1,5 +1,6 @@
import type { Payload } from 'payload'
import type { MunicipalityVoteRankEntry } from '@/lib/municipalityVoteRank'
import type { CampaignUser, Municipality } from '@/payload-types'
import { eligibleCampaignStaffWhere } from '@/utilities/campaignAccess'
import type { PoliticalTrendStatus } from '@/utilities/municipalityUi'
@ -53,11 +54,13 @@ export type MunicipalityListViewModel = {
politicalTrendStatus: PoliticalTrendStatus | null
politicalTrendNote: string | null
pledges: MunicipalityPledgeAggregate
votePosition2022: MunicipalityVoteRankEntry | null
}
export const toMunicipalityListViewModel = (
municipality: Municipality,
pledges?: MunicipalityPledgeAggregate,
pledges: MunicipalityPledgeAggregate | undefined,
votePosition2022: MunicipalityListViewModel['votePosition2022'],
): MunicipalityListViewModel => ({
id: municipality.id,
name: municipality.name,
@ -74,6 +77,7 @@ export const toMunicipalityListViewModel = (
politicalTrendStatus: municipality.politicalTrend?.status ?? null,
politicalTrendNote: municipality.politicalTrend?.note ?? null,
pledges: pledges ?? createEmptyMunicipalityPledgeAggregate(),
votePosition2022,
})
export type MunicipalityAdvisorSummary = {

View file

@ -78,6 +78,52 @@ describe('loadMunicipalityListPageBundle', () => {
expect(bundle.overview).not.toBeNull()
expect(bundle.overview!.staffVoteTotalByScenario.central).toBeGreaterThanOrEqual(1_500)
const row = bundle.municipalities.find((item) => item.slug === municipality.slug)
expect(row?.votePosition2022).not.toBeNull()
expect(row!.votePosition2022!.totalUnits).toBe(435)
expect(row!.votePosition2022!.rank).toBeGreaterThanOrEqual(1)
})
it('orders the filtered set by 2022 votes when sort=votos', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const first = await fixtures.getMunicipality()
const second = await fixtures.getMunicipality()
fixtures.touchMunicipality(first.id)
fixtures.touchMunicipality(second.id)
const marker = `a11-votos-${Date.now()}`
await payload.update({
collection: 'municipality',
id: first.id,
data: { name: `${marker}-alpha` },
depth: 0,
overrideAccess: true,
})
await payload.update({
collection: 'municipality',
id: second.id,
data: { name: `${marker}-beta` },
depth: 0,
overrideAccess: true,
})
const bundle = await loadMunicipalityListPageBundle(payload, coordinator, {
q: marker,
sort: 'votos',
})
expect(bundle.municipalities.length).toBeGreaterThanOrEqual(2)
const votes = bundle.municipalities.map((row) => row.votePosition2022?.votes ?? 0)
const nonZero = votes.filter((value) => value > 0)
for (let index = 1; index < nonZero.length; index += 1) {
expect(nonZero[index - 1]!).toBeGreaterThanOrEqual(nonZero[index]!)
}
// Zeros (if any) must trail the non-zero block.
const firstZero = votes.findIndex((value) => value === 0)
if (firstZero >= 0) {
expect(votes.slice(firstZero).every((value) => value === 0)).toBe(true)
}
})
it('keeps advisor access and applies URL filters on top', async () => {

View file

@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { AppRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime'
@ -19,6 +19,13 @@ import type { MunicipalityAdvisorSummary, MunicipalityListViewModel } from '@/ut
import { toVoteEstimateScenarioViewModel } from '@/utilities/voteEstimate'
import { createEmptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeData'
vi.mock('next/navigation', () => ({
useRouter: () => ({
replace: vi.fn(),
push: vi.fn(),
}),
}))
const noopListFormAction = async (
_state: CampaignFormActionState,
_formData: FormData,
@ -78,10 +85,10 @@ describe('campaign visual foundation', () => {
it('renders campaign scope with a visible, accessible label', () => {
const html = renderToStaticMarkup(
createElement(CampaignScopeBadge, null, '3 Praças sob sua assessoria'),
createElement(CampaignScopeBadge, null, '3 municípios sob sua assessoria'),
)
expect(html).toContain('3 Praças sob sua assessoria')
expect(html).toContain('3 municípios sob sua assessoria')
expect(html).toContain('data-scope="campaign"')
})
@ -158,6 +165,12 @@ describe('campaign visual foundation', () => {
pledgeCount: 2,
missingEstimateCount: 1,
},
votePosition2022: {
votes: 4200,
rank: 12,
share: 0.031,
totalUnits: 435,
},
},
{
id: 2,
@ -175,6 +188,7 @@ describe('campaign visual foundation', () => {
politicalTrendStatus: null,
politicalTrendNote: null,
pledges: createEmptyMunicipalityPledgeAggregate(),
votePosition2022: null,
},
]
@ -223,6 +237,7 @@ describe('campaign visual foundation', () => {
pledgeCount: 1,
missingEstimateCount: 1,
},
votePosition2022: null,
},
],
advisorNamesById: new Map<number, MunicipalityAdvisorSummary>(),
@ -256,6 +271,7 @@ describe('campaign visual foundation', () => {
politicalTrendStatus: null,
politicalTrendNote: null,
pledges: createEmptyMunicipalityPledgeAggregate(),
votePosition2022: null,
},
],
advisorNamesById: new Map<number, MunicipalityAdvisorSummary>(),

View file

@ -1,6 +1,16 @@
import { describe, expect, it } from 'vitest'
import { shouldUpdateMunicipalitySearchUrl } from '@/utilities/municipalityUi'
import {
buildMunicipalityListHref,
buildMunicipalitySortHref,
formatMunicipalityConcentrationHint,
formatMunicipalityListSortSummary,
parseMunicipalityListParams,
parseMunicipalitySortValue,
resolveMunicipalityListSort,
serializeMunicipalitySortValue,
shouldUpdateMunicipalitySearchUrl,
} from '@/utilities/municipalityUi'
describe('shouldUpdateMunicipalitySearchUrl', () => {
it('returns false when canonical q matches the current URL q', () => {
@ -14,3 +24,66 @@ describe('shouldUpdateMunicipalitySearchUrl', () => {
expect(shouldUpdateMunicipalitySearchUrl('', 'salvador')).toBe(true)
})
})
describe('municipality list sort params (A11 + B15)', () => {
it('omits default votos/desc from the URL (mesa order)', () => {
const state = parseMunicipalityListParams({ sort: 'votos', dir: 'desc' })
expect(state).toMatchObject({ sort: 'votos', dir: 'desc' })
expect(resolveMunicipalityListSort(state)).toEqual({ sort: 'votos', dir: 'desc' })
expect(buildMunicipalityListHref(state, 1)).toBe('/campanha/municipios')
})
it('treats an empty query as votos desc', () => {
const state = parseMunicipalityListParams({})
expect(resolveMunicipalityListSort(state)).toEqual({ sort: 'votos', dir: 'desc' })
expect(buildMunicipalityListHref(state, 1)).toBe('/campanha/municipios')
})
it('keeps sort=name when sorting alphabetically (dir=asc is the name default)', () => {
const state = parseMunicipalityListParams({ sort: 'name', dir: 'asc' })
expect(state.sort).toBe('name')
expect(state.dir).toBe('asc')
expect(buildMunicipalityListHref(state, 1)).toBe('/campanha/municipios?sort=name')
})
it('keeps sort=votos&dir=asc when ascending', () => {
const state = parseMunicipalityListParams({ sort: 'votos', dir: 'asc' })
expect(state).toMatchObject({ sort: 'votos', dir: 'asc' })
expect(buildMunicipalityListHref(state, 1)).toBe('/campanha/municipios?sort=votos&dir=asc')
})
it('toggles votos from default desc to asc', () => {
const base = parseMunicipalityListParams({})
const href = buildMunicipalitySortHref(base, 'votos')
expect(href).toBe('/campanha/municipios?sort=votos&dir=asc')
})
it('switches from name to votos with default desc (omitted URL)', () => {
const base = parseMunicipalityListParams({ sort: 'name' })
expect(buildMunicipalitySortHref(base, 'votos')).toBe('/campanha/municipios')
})
it('parses mobile Ordenar values with | and rejects junk', () => {
expect(parseMunicipalitySortValue('votos|desc')).toEqual({ key: 'votos', dir: 'desc' })
expect(parseMunicipalitySortValue('name|asc')).toEqual({ key: 'name', dir: 'asc' })
expect(parseMunicipalitySortValue('priority|asc')).toBeNull()
expect(parseMunicipalitySortValue('votos')).toBeNull()
expect(serializeMunicipalitySortValue('votos', 'desc')).toBe('votos|desc')
})
it('summarizes the active sort in plain language', () => {
expect(formatMunicipalityListSortSummary('votos', 'desc')).toBe('Ordenado por 2022 ↓')
expect(formatMunicipalityListSortSummary('votos', 'asc')).toBe('Ordenado por 2022 ↑')
expect(formatMunicipalityListSortSummary('name', 'asc')).toBe('Ordenado por nome (A–Z)')
expect(formatMunicipalityListSortSummary('region', 'asc')).toBe(
'Ordenado por Território de identidade ↑',
)
})
it('formats the concentração header hint once for tooltip/caption', () => {
expect(formatMunicipalityConcentrationHint()).toMatch(/votação estadual/)
expect(formatMunicipalityConcentrationHint()).toMatch(/435/)
expect(formatMunicipalityConcentrationHint()).not.toMatch(/^%/)
expect(formatMunicipalityConcentrationHint(12)).toMatch(/12/)
})
})

View file

@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import { federalBaselineMunicipalitySlugs } from '@/lib/bahiaElectionAggregates'
import {
compareMunicipalityVotesForSort,
computeVoteRankByYear,
DEFAULT_VOTE_RANK_YEAR,
formatMunicipalityVoteRank,
formatMunicipalityVoteShare,
getMunicipalityVoteRank,
} from '@/lib/municipalityVoteRank'
describe('municipalityVoteRank', () => {
it('ranks every catalog slug for the default year', () => {
const bySlug = computeVoteRankByYear(DEFAULT_VOTE_RANK_YEAR)
const slugs = federalBaselineMunicipalitySlugs()
expect(bySlug.size).toBe(slugs.length)
expect(bySlug.size).toBe(435)
let shareSum = 0
for (const slug of slugs) {
const entry = bySlug.get(slug)
expect(entry).toBeDefined()
expect(entry!.totalUnits).toBe(435)
expect(entry!.rank).toBeGreaterThanOrEqual(1)
expect(entry!.rank).toBeLessThanOrEqual(435)
expect(entry!.votes).toBeGreaterThanOrEqual(0)
expect(entry!.share).toBeGreaterThanOrEqual(0)
shareSum += entry!.share
}
expect(shareSum).toBeCloseTo(1, 10)
})
it('uses dense ranks when votes tie', () => {
const bySlug = computeVoteRankByYear(DEFAULT_VOTE_RANK_YEAR)
const byVotes = new Map<number, number[]>()
for (const entry of bySlug.values()) {
const ranks = byVotes.get(entry.votes) ?? []
ranks.push(entry.rank)
byVotes.set(entry.votes, ranks)
}
for (const ranks of byVotes.values()) {
expect(new Set(ranks).size).toBe(1)
}
const sortedUniqueVotes = [...byVotes.keys()].sort((a, b) => b - a)
let expectedDense = 1
for (const votes of sortedUniqueVotes) {
const rank = byVotes.get(votes)![0]!
expect(rank).toBe(expectedDense)
expectedDense += 1
}
})
it('returns null for unknown slugs', () => {
expect(getMunicipalityVoteRank('nao-existe')).toBeNull()
})
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%/)
})
it('sorts zero votes last in either direction', () => {
expect(compareMunicipalityVotesForSort(0, 100, 'desc')).toBeGreaterThan(0)
expect(compareMunicipalityVotesForSort(0, 100, 'asc')).toBeGreaterThan(0)
expect(compareMunicipalityVotesForSort(200, 100, 'desc')).toBeLessThan(0)
expect(compareMunicipalityVotesForSort(50, 100, 'asc')).toBeLessThan(0)
})
})