mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Merge branch 'cursor/4ba21740'
This commit is contained in:
commit
d73a644d72
24 changed files with 993 additions and 147 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
@ -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?
|
||||
|
|
@ -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?
|
||||
25
.impeccable/critique/2026-07-24__a11-ranking-votos.md
Normal file
25
.impeccable/critique/2026-07-24__a11-ranking-votos.md
Normal 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`
|
||||
|
|
@ -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**.
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
})
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
27
src/components/campaign/MunicipalityHoverTooltip.tsx
Normal file
27
src/components/campaign/MunicipalityHoverTooltip.tsx
Normal 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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
105
src/components/campaign/MunicipalitySortableHead.tsx
Normal file
105
src/components/campaign/MunicipalitySortableHead.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
95
src/lib/municipalityVoteRank.ts
Normal file
95
src/lib/municipalityVoteRank.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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". */
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>(),
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
70
tests/unit/municipalityVoteRank.unit.spec.ts
Normal file
70
tests/unit/municipalityVoteRank.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue