diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 70f0a4cd..31cf8fc4 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -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=`). 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=`). 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. diff --git a/.impeccable/critique/2026-07-24T20-30-52Z__a11-ranking-votos.md b/.impeccable/critique/2026-07-24T20-30-52Z__a11-ranking-votos.md new file mode 100644 index 00000000..fc6c1a12 --- /dev/null +++ b/.impeccable/critique/2026-07-24T20-30-52Z__a11-ranking-votos.md @@ -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? diff --git a/.impeccable/critique/2026-07-24T20-48-41Z__a11-ranking-votos.md b/.impeccable/critique/2026-07-24T20-48-41Z__a11-ranking-votos.md new file mode 100644 index 00000000..a5a2b01a --- /dev/null +++ b/.impeccable/critique/2026-07-24T20-48-41Z__a11-ranking-votos.md @@ -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? diff --git a/.impeccable/critique/2026-07-24T21-01-02Z__a11-ranking-votos.md b/.impeccable/critique/2026-07-24T21-01-02Z__a11-ranking-votos.md new file mode 100644 index 00000000..823368af --- /dev/null +++ b/.impeccable/critique/2026-07-24T21-01-02Z__a11-ranking-votos.md @@ -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? diff --git a/.impeccable/critique/2026-07-24__a11-ranking-votos.md b/.impeccable/critique/2026-07-24__a11-ranking-votos.md new file mode 100644 index 00000000..6df9eff1 --- /dev/null +++ b/.impeccable/critique/2026-07-24__a11-ranking-votos.md @@ -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` diff --git a/AGENTS.md b/AGENTS.md index 27a26eb9..6eb156c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`), 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 `@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**. diff --git a/docs/plans/ordenacao-colunas-lista-municipios.md b/docs/plans/ordenacao-colunas-lista-municipios.md index 06f91fe1..18b8fae6 100644 --- a/docs/plans/ordenacao-colunas-lista-municipios.md +++ b/docs/plans/ordenacao-colunas-lista-municipios.md @@ -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`). diff --git a/docs/plans/ranking-votos-municipio.md b/docs/plans/ranking-votos-municipio.md index 5885fee5..986457b9 100644 --- a/docs/plans/ranking-votos-municipio.md +++ b/docs/plans/ranking-votos-municipio.md @@ -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
(votesByYear por slug)"] Rank["municipalityVoteRank.ts
(rank denso + share por ano)"] Card["MunicipalityBaselineCard
(posição + % da votação)"] - List["MunicipalityList + sort=votos
(contrato B15)"] + List["MunicipalityList + sort=votos
(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 diff --git a/docs/roadmap.md b/docs/roadmap.md index f4f74b23..5a32034c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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
por coluna"] E4R["E4R ✓ Import planilha
(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 diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx index 1049b98e..ed8ad070 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx @@ -238,7 +238,7 @@ const ElectionsTab = async ({ return (
- +
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, 'href' | 'onClick'> & { href: string }) => { +/** Dims while a shared list navigation is pending; safe to render from RSC. */ +export const CampaignTransitionAnchor = forwardRef< + HTMLAnchorElement, + Omit, '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 ( { @@ -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} ) -} +}) /** Wraps the server-rendered results; dims while a shared navigation is pending. */ export const CampaignListResults = ({ diff --git a/src/components/campaign/MunicipalityBaselineCard.tsx b/src/components/campaign/MunicipalityBaselineCard.tsx index bc6c378f..5492b305 100644 --- a/src/components/campaign/MunicipalityBaselineCard.tsx +++ b/src/components/campaign/MunicipalityBaselineCard.tsx @@ -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 (

- 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.

) @@ -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 (
-

- Votação de {baseline.candidateName} ({baseline.candidateParty}) -

+ +

+ Votação de {baseline.candidateName} ({baseline.candidateParty}) +

+

- 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.

- {baseline.series.map((point) => ( -
-
Eleição {point.year}
-
- {voteFormatter.format(point.votes)} -
-
- ))} + {baseline.series.map((point) => { + const position = getMunicipalityVoteRank(municipalitySlug, point.year) + return ( +
+
Eleição {point.year}
+
+ {formatElectionNumber(point.votes)} +
+ {position ? ( +
+ {formatMunicipalityVoteShare(position.share)} + {formatMunicipalityVoteRank(position.rank)} +
+ ) : ( +
—
+ )} +
+ ) + })}
{evolution.status !== 'noBaseline' ? ( @@ -63,27 +99,34 @@ export const MunicipalityBaselineCard = ({ baseline }: { baseline: MunicipalityE ) : null} {baseline.tally2022 ? ( -

- 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` - : ''} -

+
+

Comparativos locais

+

+ 2022: {formatElectionNumber(baseline.tally2022.aptos)} aptos ·{' '} + {formatElectionNumber(baseline.tally2022.comparecimento)} compareceram ·{' '} + {formatElectionNumber(baseline.tally2022.votosValidos)} votos válidos +

+ {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). +

+ ) : null} +
) : null} {baseline.ticket2022.president != null || baseline.ticket2022.governor != null ? (

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}

) : null} diff --git a/src/components/campaign/MunicipalityFilters.tsx b/src/components/campaign/MunicipalityFilters.tsx index ccecf2c2..30154101 100644 --- a/src/components/campaign/MunicipalityFilters.tsx +++ b/src/components/campaign/MunicipalityFilters.tsx @@ -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 | null>(null) + const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state) useEffect( () => () => { @@ -109,7 +109,7 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
{ @@ -231,10 +231,7 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil Ordenar { 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 }, + ) }) }} > diff --git a/src/components/campaign/MunicipalityHoverTooltip.tsx b/src/components/campaign/MunicipalityHoverTooltip.tsx new file mode 100644 index 00000000..32505803 --- /dev/null +++ b/src/components/campaign/MunicipalityHoverTooltip.tsx @@ -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' +}) => ( + + + {children} + + {content} + + + +) diff --git a/src/components/campaign/MunicipalityList.tsx b/src/components/campaign/MunicipalityList.tsx index a9a9825d..b7efc805 100644 --- a/src/components/campaign/MunicipalityList.tsx +++ b/src/components/campaign/MunicipalityList.tsx @@ -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 + 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 ( - - - {children} - - + {share} + {metaLine} +
) } @@ -121,11 +107,21 @@ export const MunicipalityList = ({ trendFormAction, advisorsFormAction, state, -}: MunicipalityListProps) => ( - <> -
+}: MunicipalityListProps) => { + const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state) + const sortSummary = formatMunicipalityListSortSummary(activeSort, activeDir) + const concentrationHint = formatMunicipalityConcentrationHint() + + return ( + <> +

+ {sortSummary} +

+ +
{municipalities.map((municipality) => { const names = advisorNames(municipality, advisorNamesById) + const position = municipality.votePosition2022 return (
@@ -134,6 +130,7 @@ export const MunicipalityList = ({

{formatMunicipalityGeographyLabel(municipality)}

+ {position ? : null}
{municipality.priority === 'alta' && isStaffView ? ( {municipalityPriorityLabels.alta} @@ -185,7 +182,7 @@ export const MunicipalityList = ({ ) : null}
) @@ -194,10 +191,13 @@ export const MunicipalityList = ({
+ + {sortSummary}. Coluna 2022: {concentrationHint} + - Praça + Município Território de identidade @@ -205,6 +205,12 @@ export const MunicipalityList = ({ Tipo + {isStaffView ? ( <> Assessores @@ -251,6 +257,16 @@ export const MunicipalityList = ({ {municipality.region} {municipalityKindLabels[municipality.kind]} + + {municipality.votePosition2022 ? ( + + ) : ( + — + )} + {isStaffView ? ( <> @@ -318,3 +334,4 @@ export const MunicipalityList = ({ ) +} diff --git a/src/components/campaign/MunicipalitySortableHead.tsx b/src/components/campaign/MunicipalitySortableHead.tsx new file mode 100644 index 00000000..a6d9075f --- /dev/null +++ b/src/components/campaign/MunicipalitySortableHead.tsx @@ -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 = ( + + {label} + {active ? ( + dir === 'asc' ? ( + + ) + + return ( + + {tooltip ? ( + + {sortControl} + + ) : ( + sortControl + )} + + ) +} diff --git a/src/lib/municipalityVoteRank.ts b/src/lib/municipalityVoteRank.ts new file mode 100644 index 00000000..7652c127 --- /dev/null +++ b/src/lib/municipalityVoteRank.ts @@ -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>() + +/** + * 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 => { + 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() + 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 +} diff --git a/src/utilities/municipalityPageData.ts b/src/utilities/municipalityPageData.ts index e6f6b531..d1ab4f95 100644 --- a/src/utilities/municipalityPageData.ts +++ b/src/utilities/municipalityPageData.ts @@ -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> = 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, ): 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, diff --git a/src/utilities/municipalityUi.ts b/src/utilities/municipalityUi.ts index 8ac58de4..02f65e32 100644 --- a/src/utilities/municipalityUi.ts +++ b/src/utilities/municipalityUi.ts @@ -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 = { - 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([ const municipalityListSortDirSet = new Set(['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". */ diff --git a/src/utilities/municipalityViewModels.ts b/src/utilities/municipalityViewModels.ts index ebfbda0a..cc1e28eb 100644 --- a/src/utilities/municipalityViewModels.ts +++ b/src/utilities/municipalityViewModels.ts @@ -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 = { diff --git a/tests/int/municipalityPageData.int.spec.ts b/tests/int/municipalityPageData.int.spec.ts index b5fb5080..e832123a 100644 --- a/tests/int/municipalityPageData.int.spec.ts +++ b/tests/int/municipalityPageData.int.spec.ts @@ -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 () => { diff --git a/tests/unit/campaignComponents.unit.spec.ts b/tests/unit/campaignComponents.unit.spec.ts index c92037c3..19025d72 100644 --- a/tests/unit/campaignComponents.unit.spec.ts +++ b/tests/unit/campaignComponents.unit.spec.ts @@ -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(), @@ -256,6 +271,7 @@ describe('campaign visual foundation', () => { politicalTrendStatus: null, politicalTrendNote: null, pledges: createEmptyMunicipalityPledgeAggregate(), + votePosition2022: null, }, ], advisorNamesById: new Map(), diff --git a/tests/unit/municipalityUi.unit.spec.ts b/tests/unit/municipalityUi.unit.spec.ts index 6411f30e..8f578b47 100644 --- a/tests/unit/municipalityUi.unit.spec.ts +++ b/tests/unit/municipalityUi.unit.spec.ts @@ -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/) + }) +}) diff --git a/tests/unit/municipalityVoteRank.unit.spec.ts b/tests/unit/municipalityVoteRank.unit.spec.ts new file mode 100644 index 00000000..e53bcc51 --- /dev/null +++ b/tests/unit/municipalityVoteRank.unit.spec.ts @@ -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() + 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) + }) +})