feat(B202): totais do recorte no rodapé da lista de municípios (Closes #1005)

This commit is contained in:
Francisco Solla 2026-09-15 07:14:46 -03:00
parent e579406b1f
commit 0c0725835b
11 changed files with 691 additions and 0 deletions

View file

@ -0,0 +1 @@
**B202 — Totais do recorte na lista de municípios (votos 2022 + expectativas 2026) (2026-09-15):** o rodapé compartilhado de `/campanha/municipios` ganhou uma linha compacta com os totais do recorte filtrado **inteiro** (todas as páginas, nunca só as 25 visíveis): votos Solla 2022 + expectativa 2026 nos três cenários ("Pess./Média/Otim.", o ativo em destaque, via estado client compartilhado com a coluna) + a contagem honesta "N de M com expectativa". A soma é **server-side, sem consulta nova**: o loader já carrega o conjunto completo do recorte para o staff (`staffScope.municipalities` na trilha paginada, `allDocs` na load-all) e a matemática pura vive em `src/lib/municipalitySliceTotals.ts` (mesma fonte por linha do `votePosition2022`; Salvador conta **uma vez** — cidade substitui as 19 ZE nos votos e as ZE entram nas expectativas, que a cidade não tem; assessor soma exatamente o que as linhas dele enxergam; Liderança segue bloqueada e nunca recebe o campo). Cenário sem nenhum preenchimento vira "—" (nunca "0") e a frase secundária é omitida; recorte vazio não renderiza a linha (nenhum total inventado). O rodapé só mudou com um prop opcional `totals?: ReactNode` — as demais listas ficam idênticas — e o anti-goal do B129 segue respeitado (nenhum bloco acima da lista, nenhuma linha dentro da `table`, guardado por e2e). Sem migration, sem Consent, sem PII. Achados do simplify aplicados: identificadores em inglês (`slice`, não `recorte`, que fica só na copy), `aria-label` em `<p>` trocado por um `sr-only` com a leitura completa dos rótulos ("não informado"), oráculos de teste desacoplados do accessor de produção e contagens derivadas do catálogo; a regra "cidade folda votos mas não expectativas" foi pega na conferência visual (o fold pulava o corpo da linha das ZE) e pinada no unit. Verificação: `pnpm gate:fast` verde (312 arquivos / 3072 unit), int de `municipalityPageData` 30/30, e2e B202 2/2 (recorte de 1 município com somas exatas e guardrail B129; lista sem filtro somando 18 páginas até o `ownVotes` estadual) + specs afetados do manifesto (`campaignZoneMap`, `campaignNearestMunicipality`, `campaignSavedFilters` verdes; as 2 falhas do `campaignColumnPicker`/B197 são pré-existentes, reproduzidas sem este diff).

View file

@ -0,0 +1,113 @@
# Impl: Totais do recorte na lista de municípios (votos 2022 + expectativas 2026)
Status: aprovado (gate humano 2026-09-15)
Atualizado em: 2026-09-15
Issue: #1005
Intenção: docs/plans/totais-recorte-lista-municipios.md
Appetite restante: herdado — ~0,5–1 dia eng, sem corte (a abordagem não adiciona consulta nova)
## Leitura da intenção
- **Outcome:** no rodapé de `/campanha/municipios`, uma linha compacta soma o recorte filtrado **inteiro** (não a página visível): votos Solla 2022 + expectativa 2026 nos três cenários, mais "N de M com expectativa" — com o mesmo escopo de acesso das linhas e sem reabrir o bloco de overview que o B129 removeu.
- **O que NÃO negociar:** somas de todas as páginas; mesma fonte por linha (`computeVoteRankByYear`); Salvador conta uma vez (cidade **ou** 19 ZE); expectativas somam as ZE e a cidade nunca entra; assessor soma só o que enxerga; Liderança segue bloqueada; sem pledges, médias, percentuais ou cobertura; sem linha dentro da tabela (tfoot) nem bloco nos cards; rodapé compartilhado idêntico nas outras listas; recorte vazio não inventa totais e "nenhuma expectativa" não vira "0".
- **O que reavaliar (hipóteses da intenção):**
- _"sem consulta nova se os dados do recorte já estiverem carregados"_ — confirmado: para **todo** o staff (inclui assessor — `isCampaignStaff`), o conjunto filtrado completo já está em memória: `staffScope.municipalities` (`municipalityPageData.ts:529-541`, `limit: 0`) na trilha paginada e `listResult.docs`/`allDocs` na trilha load-all. Zero consultas novas.
- _"o cenário ativo hoje é estado local do cliente"_ — a linha mostra os três sempre, mas o destaque do cenário ativo exige uma folha client (decisão em Componentes).
- _"o assessor nunca vê total maior que a carteira"_ — o B178 já expõe a linha da cidade (agregado das 19 ZE) ao assessor que busca "salvador" (`municipalityPageData.ts:481`); o total apenas espelha as linhas visíveis. É semântica de linha herdada, não canal novo (registrado em Riscos).
## Abordagem recomendada
```mermaid
flowchart LR
SP[searchParams] --> L[loadMunicipalityListPageBundle]
L --> W["where + access<br/>overrideAccess:false + user"]
W --> BQ{"paged?"}
BQ -- não --> FS["full set em memória<br/>listResult.docs → allDocs"]
BQ -- sim --> SS["staffScope.municipalities<br/>loadMunicipalityScope limit 0"]
FS --> T["computeMunicipalitySliceTotals<br/>src/lib/municipalitySliceTotals.ts"]
SS --> T
ART["computeVoteRankByYear(2022)<br/>artefato imutável, cache de módulo"] --> T
T --> RB["bundle.sliceTotals"]
RB --> PG[page.tsx]
PG --> FT["CampaignListFooter totals?: ReactNode"]
FT --> LN["MunicipalitySliceTotalsLine<br/>folha client: destaque do cenário ativo"]
```
**Opções consideradas:** A | B | C
**Recomendação:** **A** — somar no loader sobre o conjunto filtrado completo já carregado (fonte: `staffScope.municipalities` na trilha paginada; `allDocs` na load-all), com a matemática pura num módulo novo de `src/lib` (`municipalitySliceTotals.ts`). Porque não adiciona consulta alguma, usa exatamente o mesmo `where`+`user` que produziu as linhas (escopo e soma são o mesmo objeto), reusa o artefato memoizado que já alimenta `votePosition2022` por linha e cabe no appetite.
**Rejeitadas:**
- **B — agregado SQL/drizzle espelhando `supporterListOverviewAggregate`:** a soma de 2022 não existe no banco (fonte é o artefato commitado por slug), então o SQL ainda precisaria da lista completa de slugs; duplicaria `where`+access em SQL cru; cobre só 3 das 4 somas; e o recorte tem ≤436 linhas — o custo não se paga.
- **C — reusar `loadTerritoryOverview`/`territoryOverview`:** roda com `overrideAccess: true`, mistura pledges (`expected[S] ?? declared`), fixa um único cenário e carrega cobertura/metas/lideranças que a v1 não usa — viola a decisão "sem pledges" e o escopo de access.
- Também rejeitado: qualquer cache cross-request (`unstable_cache`/`cache()` novo) — o total é user-scoped.
### Componentes / mudanças
- **`computeMunicipalitySliceTotals`** (`src/lib/municipalitySliceTotals.ts`, novo, puro/client-safe): recebe linhas `{ slug, expectedVotes? }` e devolve `MunicipalitySliceTotals | null` (o ano é fixo em `DEFAULT_VOTE_RANK_YEAR` — a coluna e a linha são 2022, um parâmetro de ano sem call site seria abstração especulativa). Nome em inglês (`slice`) por AGENTS: o rascunho aprovado escrevia `recorte` nos identificadores; o termo fica só na copy pt-BR ("Total do recorte") e no `cityInRecorte` pré-existente (B178, fora do escopo):
- `votes2022`: soma `computeVoteRankByYear(ano).get(slug)?.votes ?? 0`; se a linha da cidade está no conjunto, as 19 slugs de `salvadorCity.zoneSlugs` são puladas e o fold das MESMAS células entra **uma vez** (nunca `ranks.get('salvador')`, que não existe no artefato; nunca cidade + zonas). Sem a cidade, as ZE somam normalmente — e como a cidade não tem entrada no artefato, o total é idêntico nos dois arranjos (cidade **ou** zonas).
- `expectedByScenario`: soma por cenário via `toVoteEstimateScenarioViewModel` + valores não nulos, **linha a linha inclusive quando a cidade está no recorte** (a cidade não carrega expectativa; as 19 ZE são a única fonte — só os VOTOS têm a escolha cidade-ou-zonas). Cenário que ninguém preencheu fica `null` (a UI mostra "—", nunca "0"); valores explícitos (inclusive 0) somam.
- `withEstimateCount`: nº de linhas com ≥1 cenário preenchido (`hasAnyVoteEstimate`); `rowCount` = nº de linhas do recorte (denominador do "N de M", igual ao `totalDocs` exibido; inclui a linha virtual quando presente).
- Conjunto vazio → `null` (o rodapé não renderiza a linha).
- **`loadMunicipalityListPageBundle`** (`src/utilities/municipality/municipalityPageData.ts:446-673`): novo campo `sliceTotals: MunicipalitySliceTotals | null` no `MunicipalityListPageBundle`; o early-return do líder (`:456-464`) ganha `sliceTotals: null`; cálculo no fim do loader a partir de `staffScope.municipalities` (trilha paginada) ou `allDocs` (load-all) — os mesmos docs que produzem `totalDocs`. **Sem consulta nova.**
- **`CampaignListFooter`** (`src/components/campaign/shared/CampaignListFooter.tsx:8-31`): novo prop opcional `totals?: ReactNode` renderizado entre a contagem e a paginação. Nenhuma outra mudança; as ~12 listas que não passam o prop ficam byte-idênticas.
- **`MunicipalitySliceTotalsLine`** (`src/components/campaign/municipality/MunicipalitySliceTotalsLine.tsx`, novo, `'use client'`): linha compacta com `data-slot="municipality-slice-totals"`; formata com `formatElectionNumber`; destaca o cenário ativo via `useMunicipalityEstimateScenarioOptional()` com fallback `DEFAULT_VOTE_ESTIMATE_SCENARIO` (mesmo padrão de `MunicipalityListExpectedVotesControl.tsx:73-74`); rótulos completos no `title` e um `sr-only` com `formatVoteEstimateScenarioAriaLabel` ("não informado") + a contagem — `aria-label` em `<p>` é ignorado por AT (achado do simplify). Sem estado próprio.
- **`MunicipalitiesPage`** (`src/app/(campaign)/campanha/(app)/municipios/page.tsx:79-86,199-206`): desestrutura `sliceTotals` e passa `totals={sliceTotals ? <MunicipalitySliceTotalsLine totals={sliceTotals} /> : undefined}`.
- **Migration:** sem migration (nenhuma collection/global/field novo).
- **Access / Consent:** nenhum helper novo; a soma lê só os docs já carregados com `user` + `overrideAccess: false`, então o escopo do total é literalmente o da query das linhas (não re-derivar com `getAdvisorMunicipalityIds`, que divergiria de `canReadMunicipality` no caso `visibility: 'tudo'`, `access/municipalities.ts:201`). Sem Consent/LGPD; sem PII (slug + expectedVotes). Gate `noLeader` da página e early-return do loader intactos.
- **UI:** Impeccable B — shape do rascunho aprovado → craft com os tokens da casa (`text-muted-foreground`, `border`, `bg-card`; o zinc do rascunho é mock), `tabular-nums`, wrap mobile → critique contra o anti-goal B129 e o rodapé compartilhado → polish dos estados vazio/sem expectativa.
### Dados → forma
- **Uma linha compacta no rodapé compartilhado** (decisão do gate): `Total do recorte: 2022 … · Pess. … · Média … · Otim. … · N de M com expectativa`, com o cenário ativo em destaque e os três sempre legíveis. Rótulos abreviados como no rascunho (com o rótulo completo em `title`/`aria`), ano "2022" igual ao da coluna.
- Rejeitadas: `tfoot`/linha na tabela (quebraria o header sticky e não cobre os cards mobile); bloco dedicado nos cards (duas superfícies para manter); overview acima da lista (anti-goal B129); tooltip-only (esconderia o número, que é o ponto do item).
- Sem médias, percentuais, cobertura, pledges ou seletor novo; números em pt-BR via `formatElectionNumber`; "—" para cenário sem nenhum preenchimento; frase secundária omitida quando `withEstimateCount === 0`.
## Fases verificáveis
1. **Tracer / server + testes** (~60% do appetite): módulo puro + campo no bundle + testes unit/int. Verificação: `pnpm test:unit` e `pnpm exec vitest run --config ./vitest.config.mts tests/int/municipalityPageData.int.spec.ts`.
2. **UI** (~30%): slot opcional no rodapé + folha client + fio na página; shape→craft→critique→polish (Impeccable B), conferindo desktop, mobile, recorte vazio e recorte sem expectativa contra `docs/plans/totais-recorte-lista-municipios-ui-draft.html`.
3. **Gates** (~10%): `pnpm gate:fast` (lint + typecheck + unit); int alvo; `node scripts/e2e-affected.mjs` (esperado: `campaignMunicipalities` selecionado pelos paths `src/utilities/municipality` e `src/components/campaign/municipality`); `pnpm test:e2e:affected`; `pnpm format:check`; push via `pnpm push`.
### Testes previstos
- **Unit `tests/unit/municipalitySliceTotals.unit.spec.ts`**: soma 2022 por slug contra `computeVoteRankByYear`; catálogo inteiro (sem cidade) = `getStatewideFederalTotals(2022)`; Salvador cidade+zonas e só zonas (ambos = fold da cidade, nunca 2×); **cidade presente ainda soma as expectativas das ZE** (o fold é só dos votos — regressão pega na conferência visual); conjunto vazio → `null`; sem expectativa → cenários `null` e contagem 0; soma parcial por cenário + contagem de linhas (não de cenários). (Unit roda com `DATABASE_URL` inválida — o módulo é puro.)
- **Int `tests/int/municipalityPageData.int.spec.ts` (estende)**: default do coordenador → `sliceTotals.rowCount === totalDocs === 436` e `votes2022 === getStatewideFederalTotals(2022).ownVotes`; trilha paginada (região sem a cidade + `sort: 'name'`) → 25 linhas visíveis mas `rowCount === totalDocs > 25` e soma independente por região; expectativas somadas por cenário e `withEstimateCount` (com uma linha toda `null`); `q: 'salvador'` → 20 linhas e 2022 = `cityFederalBaseline().votesByYear['2022']` (não cidade+zonas); `slug: ['salvador']` → 1 linha e 2022 = fold da cidade; assessor com carteira → total = carteira; assessor `visibility: 'tudo'` → espelha o catálogo (435); líder → `sliceTotals === null` (estende o pin `:445-461`); recorte vazio → `null`. A regra "cidade folda votos mas não expectativas" fica no unit: mutar `salvador-ze-1` por slug fixo violaria a disciplina do alocador (`testMunicipalityAllocatorConventions`).
- **E2E `tests/e2e/campaignMunicipalities.e2e.spec.ts`**: dois fluxos novos no describe B202 — recorte de 1 município (com `expectedVotes` do fixture) confere `2022` formatado + os três cenários no texto visível/sr-only e **nenhum** `table [data-slot="municipality-slice-totals"]` (guardrail B129); lista sem filtro soma o catálogo inteiro (contagem derivada do catálogo, não literal) até o `ownVotes` estadual (a string de settle `'436 municípios encontrados'` segue intocada).
## Rabbit holes / Não escopo (engenharia)
- Reusar `rollupMunicipalityStaffVotes` (`votePledgeViews.ts:118-153`): cai para pledges (`estimated[S] ?? declared`) — proibido na v1.
- Reusar `loadTerritoryOverview` (`territory/loadTerritoryOverview.ts:97-216`): `overrideAccess: true`, cenário único, pledges.
- Agregado SQL novo, cache cross-request, `cache()` novo: o total é user-scoped e os dados já estão em memória.
- Linha dentro da tabela (tfoot), bloco nos cards, overview acima da lista (B129), médias/percentuais/cobertura, pledges, seletor de cenário na URL, exportação.
- Mexer em `computeVoteRankByYear`/artefato, criar rank da cidade por slug, ou alterar células/ordenação existentes (a linha é aditiva).
- Reabrir a exposição B178 da linha da cidade ao assessor com `q` — o total apenas espelha as linhas; mudança de access é outro item.
- Tocar no rodapé das outras listas ou no contrato de URL congelado (B18).
- **Débito deferido (simplify, gatilho):** `municipalitySliceTotals` reimplementa o fold "cidade = 19 células das ZE" em cima do rank map, enquanto `salvadorCityAggregates.foldCityFederalBaseline` é o dono do mesmo princípio sobre o baseline rico. Unificar quando houver um **3º consumidor do fold** ou quando as duas fontes (rank map × baseline) forem unificadas; hoje a igualdade está pinada no unit.
## Riscos e mitigação
- **Dupla contagem de Salvador:** regra estrutural no módulo puro (presença da cidade ⇒ pulo das ZE nos VOTOS, com o fold entrando uma vez; expectativas somadas linha a linha) + pins unit/int (`q:'salvador'` = baseline da cidade, não 2×). O primeiro corte pulava o corpo inteiro da linha das ZE e derrubava também as expectativas — pego na conferência visual (a linha mostrava "—" com a ZE 1 preenchida) e pinado no unit antes do push.
- **Total divergir das linhas/do escopo:** soma sobre os MESMOS docs da query (`staffScope`/`allDocs`), nunca re-derivando access; int cobre trilha paginada (25 visíveis × total do recorte), carteira do assessor e `visibility: 'tudo'`.
- **`staffScope` ausente:** o cálculo só ocorre sob `staffScope` (staff); líder retorna cedo com `null`; a página não renderiza a linha sem `sliceTotals`.
- **Rodapé compartilhado quebrar outras listas:** prop opcional (`totals?: ReactNode`); sem prop, nenhum nó novo; revisão do `git diff` + e2e das listas já existentes.
- **Frase secundária/zeros enganosos:** cenário sem preenchimento → `null` → "—"; `withEstimateCount === 0` → frase omitida; pins unit + int.
- **B129:** nada acima da lista; a linha vive no rodapé; e2e falha se aparecer dentro de `table`.
- **Performance:** soma O(recorte) ≤436 sobre artefato memoizado, sem consulta/IO; nenhuma mudança no stream (a string de settle dos e2e segue igual).
- **Assessor + cidade via `q`:** o total pode exceder a carteira porque a linha da cidade (B178) já excede — comportamento herdado das linhas, registrado; se a mesa rejeitar, a mudança é na exposição da linha (outro item), não no total.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto: 4 somas do recorte inteiro + contagem "N de M com expectativa", Salvador uma vez, escopo de access das linhas, lockdown da Liderança, vazio/sem-expectativa honestos, sem reabrir B129.
- [x] Invariantes AGENTS/engineering-standards: Local API com `overrideAccess: false` + `user`; sem collection/Consent/migration; identificadores em inglês e copy pt-BR; sem pledges; sem cache cross-request; rodapé compartilhado intacto para as demais listas.
- [x] Testes de domínio previstos (unit/int) onde access/write paths mudam: suíte unit do módulo puro, int do loader (coordenador/assessor/`visibility:'tudo'`/líder/Salvador/vazio/paginado) e e2e do slot.
## Self-score (decision-quality)
1. **Decisões caras têm rejeitadas?** 1/1 — A/B/C com porquês; forma do prop no rodapé; folha client vs destaque estático; localização do módulo (`lib` puro).
2. **Cabe no appetite da intenção?** 1/1 — zero consulta nova, um arquivo puro, um prop opcional, um componente pequeno, sem migration.
3. **Rabbit holes nomeados?** 1/1 — pledges, territory overview, SQL, cache, tfoot/B129, exposição B178.
4. **Depth check (reusa shells/helpers)?** 1/1 — `CampaignListFooter`, `computeVoteRankByYear`, `salvadorCity.zoneSlugs`, `hasAnyVoteEstimate`, contexto de cenário; nada de twin.
5. **Intenção permanece satisfeita?** 1/1 — todos os pontos do aceite mapeados em decisão/teste; a engenharia não reescreveu o outcome.
**Total: 5/5.**

View file

@ -14,6 +14,7 @@ import {
municipalityListPickerColumns,
} from '@/components/campaign/municipality/MunicipalityList'
import { MunicipalityListPageChrome } from '@/components/campaign/municipality/MunicipalityListPageChrome'
import { MunicipalitySliceTotalsLine } from '@/components/campaign/municipality/MunicipalitySliceTotalsLine'
import { SaveMunicipalityFilterControl } from '@/components/campaign/municipality/SaveMunicipalityFilterControl'
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
@ -81,6 +82,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
municipalities: listMunicipalities,
totalDocs,
totalPages,
sliceTotals,
filterFacets,
leadershipNamesById,
} = pageBundle
@ -203,6 +205,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
page={state.page}
totalPages={totalPages}
hrefForPage={(page) => buildMunicipalityListHref(state, page)}
totals={sliceTotals ? <MunicipalitySliceTotalsLine totals={sliceTotals} /> : undefined}
/>
</>
)

View file

@ -0,0 +1,74 @@
'use client'
import { useMunicipalityEstimateScenarioOptional } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
import { formatElectionNumber } from '@/lib/electionFormat'
import type { MunicipalitySliceTotals } from '@/lib/municipalitySliceTotals'
import { cn } from '@/lib/utils'
import {
DEFAULT_VOTE_ESTIMATE_SCENARIO,
formatVoteEstimateScenarioAriaLabel,
VOTE_ESTIMATE_SCENARIOS,
voteEstimateScenarioLabels,
type VoteEstimateScenario,
} from '@/lib/voteEstimate'
/** Compact labels of the approved draft; the full label rides in `title` and the sr-only summary. */
const scenarioShortLabels: Record<VoteEstimateScenario, string> = {
pessimistic: 'Pess.',
central: 'Média',
optimistic: 'Otim.',
}
const formatScenarioValue = (value: number | null): string =>
value == null ? '—' : formatElectionNumber(value)
/**
* B202 — the compact totals line of the municipality list footer: Solla 2022
* votes + the three 2026 expectation scenarios summed over the WHOLE filtered
* slice, plus how many rows carry at least one estimate. Only the active
* scenario (the same client state the votes column reads) is emphasized; a
* scenario nobody filled renders "—", never a fake zero. The numbers arrive
* pre-summed from the server — this leaf owns only the emphasis.
*/
export const MunicipalitySliceTotalsLine = ({ totals }: { totals: MunicipalitySliceTotals }) => {
const scenarioContext = useMunicipalityEstimateScenarioOptional()
const activeScenario = scenarioContext?.scenario ?? DEFAULT_VOTE_ESTIMATE_SCENARIO
const { expectedByScenario, rowCount, votes2022, withEstimateCount } = totals
const coverageSummary =
withEstimateCount > 0 ? `; ${withEstimateCount} de ${rowCount} com expectativa` : ''
const screenReaderSummary = `Total do recorte: 2022 ${formatElectionNumber(votes2022)}; ${formatVoteEstimateScenarioAriaLabel(expectedByScenario)}${coverageSummary}`
return (
<p
data-slot="municipality-slice-totals"
className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1 rounded-lg border bg-card px-3 py-1.5 text-xs text-muted-foreground tabular-nums"
>
<span>Total do recorte:</span>
<span>
2022{' '}
<span className="font-semibold text-foreground">{formatElectionNumber(votes2022)}</span>
</span>
{VOTE_ESTIMATE_SCENARIOS.map((scenario) => (
<span key={scenario} className="inline-flex items-center gap-2">
<span aria-hidden="true">·</span>
<span title={voteEstimateScenarioLabels[scenario]}>
{scenarioShortLabels[scenario]}{' '}
<span className={cn('text-foreground', scenario === activeScenario && 'font-semibold')}>
{formatScenarioValue(expectedByScenario[scenario])}
</span>
</span>
</span>
))}
{withEstimateCount > 0 ? (
<span className="inline-flex items-center gap-2">
<span aria-hidden="true">·</span>
<span>
{withEstimateCount} de {rowCount} com expectativa
</span>
</span>
) : null}
<span className="sr-only">{screenReaderSummary}</span>
</p>
)
}

View file

@ -1,3 +1,5 @@
import type { ReactNode } from 'react'
import { CampaignListPagination } from '@/components/campaign/shared/CampaignListPagination'
/**
@ -12,6 +14,7 @@ export const CampaignListFooter = ({
page,
totalPages,
hrefForPage,
totals,
}: {
totalDocs: number
/** e.g. "município encontrado" */
@ -21,11 +24,17 @@ export const CampaignListFooter = ({
page: number
totalPages: number
hrefForPage: (page: number) => string
/**
* B202 — optional extra line between the count and the pagination. Only the
* municipalities list passes it; the other lists render byte-identically.
*/
totals?: ReactNode
}) => (
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-muted-foreground">
{totalDocs} {totalDocs === 1 ? singular : plural}
</p>
{totals}
<CampaignListPagination page={page} totalPages={totalPages} hrefForPage={hrefForPage} />
</div>
)

View file

@ -0,0 +1,89 @@
import { computeVoteRankByYear, DEFAULT_VOTE_RANK_YEAR } from '@/lib/municipalityVoteRank'
import { SALVADOR_CITY_SLUG, salvadorCity } from '@/lib/salvadorCity'
import {
hasAnyVoteEstimate,
toVoteEstimateScenarioViewModel,
VOTE_ESTIMATE_SCENARIOS,
type VoteEstimateScenarioFields,
type VoteEstimateScenarioViewModel,
} from '@/lib/voteEstimate'
/**
* B202 — the only shape the slice totals need from a row. The scoped docs the
* loader sums and the list view models both satisfy it, so the sum can read
* whatever set already produced the visible rows.
*/
export type MunicipalitySliceTotalsRow = {
slug: string
expectedVotes?: VoteEstimateScenarioFields | null
}
export type MunicipalitySliceTotals = {
/** Rows of the slice — the same set the footer count shows. */
rowCount: number
/** Solla 2022 votes over the slice; the city replaces its 19 ZE when present. */
votes2022: number
/** Per scenario: `null` when no row of the slice filled it (never a fake zero). */
expectedByScenario: VoteEstimateScenarioViewModel
/** Rows with at least one scenario filled — the numerator of "N de M com expectativa". */
withEstimateCount: number
}
const salvadorZoneSlugs: ReadonlySet<string> = new Set(salvadorCity.zoneSlugs)
/**
* Totals of the WHOLE filtered slice (every page), summed over the same docs
* the rows came from — the sum never re-derives scope. Pure and honest:
*
* - 2022 uses the same per-slug source as the row column
* (`computeVoteRankByYear`), so the sum matches the lines by construction.
* - Salvador counts once: when the city row is in the slice, its 19 zone votes
* enter once as the fold and the zone rows are skipped; with only zones, they
* sum normally. The city carries no expectation of its own, so the zone
* EXPECTATIONS always sum row by row.
* - A scenario nobody filled stays `null` (the UI renders "—"), while filled
* values — including explicit zeros — sum.
* - An empty slice returns `null`: no invented totals.
*/
export const computeMunicipalitySliceTotals = (
rows: readonly MunicipalitySliceTotalsRow[],
): MunicipalitySliceTotals | null => {
if (rows.length === 0) return null
const ranks = computeVoteRankByYear(DEFAULT_VOTE_RANK_YEAR)
const cityInSlice = rows.some((row) => row.slug === SALVADOR_CITY_SLUG)
const expectedByScenario: VoteEstimateScenarioViewModel = {
pessimistic: null,
central: null,
optimistic: null,
}
let votes2022 = 0
let withEstimateCount = 0
// The city has no artifact entry of its own: its votes are the fold of the
// 19 zone cells, added ONCE whether or not the zones ride along.
if (cityInSlice) {
for (const zoneSlug of salvadorCity.zoneSlugs) {
votes2022 += ranks.get(zoneSlug)?.votes ?? 0
}
}
for (const row of rows) {
// Only the VOTES need the city-or-zones choice; expectations sum for every
// row (the city carries none, so its 19 ZE are the only source).
if (!(cityInSlice && (row.slug === SALVADOR_CITY_SLUG || salvadorZoneSlugs.has(row.slug)))) {
votes2022 += ranks.get(row.slug)?.votes ?? 0
}
const estimates = toVoteEstimateScenarioViewModel(row.expectedVotes)
if (hasAnyVoteEstimate(estimates)) withEstimateCount += 1
for (const scenario of VOTE_ESTIMATE_SCENARIOS) {
const value = estimates[scenario]
if (value == null) continue
expectedByScenario[scenario] = (expectedByScenario[scenario] ?? 0) + value
}
}
return { rowCount: rows.length, votes2022, expectedByScenario, withEstimateCount }
}

View file

@ -6,6 +6,10 @@ import { bahiaIdentityTerritories } from '@/lib/bahiaTerritories'
import { advisorEditingScope, type AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
import { engagementLevelRank } from '@/lib/engagementLevel'
import { getMunicipalityCatalogEntry, municipalityCatalog } from '@/lib/municipalityCatalog'
import {
computeMunicipalitySliceTotals,
type MunicipalitySliceTotals,
} from '@/lib/municipalitySliceTotals'
import {
compareMunicipalityVotesForSort,
computeVoteRankByYear,
@ -128,6 +132,8 @@ export type MunicipalityListPageBundle = {
totalDocs: number
totalPages: number
scopeTotal: number
/** B202 — totals of the WHOLE filtered slice (every page), staff-only; null for an empty slice. */
sliceTotals: MunicipalitySliceTotals | null
filterFacets: MunicipalityListFilterFacets
/** B155 — contact-name lookup for the chips of the município list rows. */
leadershipNamesById: ReadonlyMap<number, MunicipalityLeadershipSummary>
@ -459,6 +465,7 @@ export const loadMunicipalityListPageBundle = async (
totalDocs: 0,
totalPages: 0,
scopeTotal: 0,
sliceTotals: null,
filterFacets: emptyMunicipalityListFilterFacets,
leadershipNamesById: EMPTY_LEADERSHIP_NAMES,
}
@ -595,12 +602,16 @@ export const loadMunicipalityListPageBundle = async (
let pageDocs: Municipality[]
let totalDocs: number
let totalPages: number
let sliceTotals: MunicipalitySliceTotals | null
if (isPagedByPayload) {
// Payload select narrows the inferred type; the selected fields cover the view model.
pageDocs = listResult.docs as Municipality[]
totalDocs = listResult.totalDocs
totalPages = listResult.totalPages
// B202 — the scope rows ARE the whole filtered slice on this path (same
// `where` + `user`, `limit: 0`), so the totals cost no extra query.
sliceTotals = computeMunicipalitySliceTotals(staffScope?.municipalities ?? [])
} else {
const scopedDocs = classMatches
? (listResult.docs as Municipality[]).filter((municipality) =>
@ -628,6 +639,8 @@ export const loadMunicipalityListPageBundle = async (
totalPages = Math.max(1, Math.ceil(totalDocs / municipalityPageSize))
const start = (state.page - 1) * municipalityPageSize
pageDocs = allDocs.slice(start, start + municipalityPageSize)
// B202 — totals over the same in-memory slice the count came from.
sliceTotals = computeMunicipalitySliceTotals(allDocs)
}
// B155 — the leaderships of the visible page only (staff surfaces; the
@ -667,6 +680,7 @@ export const loadMunicipalityListPageBundle = async (
totalDocs,
totalPages,
scopeTotal: scopeCount.totalDocs,
sliceTotals,
filterFacets,
leadershipNamesById: leadershipBundle?.summariesById ?? EMPTY_LEADERSHIP_NAMES,
}

View file

@ -1,8 +1,15 @@
import type { Page } from '@playwright/test'
import { getStatewideFederalTotals } from '../../src/lib/bahiaElectionAggregates.js'
import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '../../src/lib/campaignConsentKeys.js'
import { formatBahiaCivilDate, parseBahiaDateTimeInput } from '../../src/lib/campaignTime.js'
import { fallbackDemandTitle } from '../../src/lib/demandTitle.js'
import { formatElectionNumber } from '../../src/lib/electionFormat.js'
import { municipalityCatalog } from '../../src/lib/municipalityCatalog.js'
import {
computeVoteRankByYear,
DEFAULT_VOTE_RANK_YEAR,
} from '../../src/lib/municipalityVoteRank.js'
import { hookFilledCreateData } from '../../src/utilities/hookFilledData.js'
import {
ensureLeasedConsent,
@ -1923,3 +1930,63 @@ test.describe('Municípios — FAB overlay polish (B126)', () => {
await expect(overlay.getByRole('region', { name: 'Sugestões' })).toHaveCount(0)
})
})
/**
* B202 — the compact totals line of the municipality list footer. The numbers
* are computed server-side over the WHOLE filtered slice; the browser asserts
* the reading, the scope and the B129 guardrail (never a line inside the table).
*/
test.describe('Municípios — totais do recorte (B202)', () => {
test('single-municipality recorte reads its 2022 votes and scenario sums in the footer', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const municipality = await fixtures.claimMunicipality()
await fixtures.payload.update({
collection: 'municipality',
id: municipality.id,
data: { expectedVotes: { pessimistic: 100, central: 200, optimistic: 300 } },
depth: 0,
})
fixtures.touchMunicipality(municipality.id)
await page.goto(`${campaign.baseURL}/campanha/municipios?slug=${municipality.slug}`)
await waitForStreamSettled(page)
const totals = page.locator('[data-slot="municipality-slice-totals"]')
await expect(totals).toBeVisible()
await expect(page.getByText('1 município encontrado')).toBeVisible()
const votes2022 =
computeVoteRankByYear(DEFAULT_VOTE_RANK_YEAR).get(municipality.slug)?.votes ?? 0
const text = ((await totals.textContent()) ?? '').replace(/\s+/g, ' ').trim()
expect(text).toContain(`2022 ${formatElectionNumber(votes2022)}`)
expect(text).toContain('Pess. 100')
expect(text).toContain('Média 200')
expect(text).toContain('Otim. 300')
expect(text).toContain('1 de 1 com expectativa')
expect(text).toContain('Pessimista: 100; Média: 200; Otimista: 300')
// B129 — the aggregate never becomes a line inside the table.
await expect(page.locator('table [data-slot="municipality-slice-totals"]')).toHaveCount(0)
})
test('unfiltered recorte totals every page, not only the visible 25', async ({
campaign,
page,
}) => {
await page.goto(`${campaign.baseURL}/campanha/municipios`)
await waitForStreamSettled(page)
// The full catalog + the virtual city row span many pages; the summed 2022
// votes are the artifact's statewide total, which no single page produces.
const rowCount = municipalityCatalog.length + 1
await expect(page.getByText(`${rowCount} municípios encontrados`)).toBeVisible()
const totals = page.locator('[data-slot="municipality-slice-totals"]')
await expect(totals).toBeVisible()
await expect(totals).toContainText(
formatElectionNumber(getStatewideFederalTotals(DEFAULT_VOTE_RANK_YEAR).ownVotes),
)
})
})

View file

@ -4,11 +4,17 @@ import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import {
getMunicipalityFederalBaseline,
getStatewideFederalTotals,
} from '@/lib/bahiaElectionAggregates'
import { getMunicipalityCatalogEntry, municipalityCatalog } from '@/lib/municipalityCatalog'
import { DEFAULT_VOTE_RANK_YEAR } from '@/lib/municipalityVoteRank'
import { territorialClassSortWeight } from '@/lib/territorialClassSortWeight'
import config from '@/payload.config'
import { loadMunicipalityListPageBundle } from '@/utilities/municipality/municipalityPageData'
import { computeMunicipalityTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass'
import { cityFederalBaseline } from '@/utilities/municipality/salvadorCityAggregates'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
@ -32,6 +38,7 @@ describe('loadMunicipalityListPageBundle', () => {
expect(bundle.municipalities).toHaveLength(0)
expect(bundle.totalDocs).toBe(0)
expect(bundle.sliceTotals).toBeNull()
})
it('includes 2022 vote position on list rows', async () => {
@ -458,6 +465,7 @@ describe('loadMunicipalityListPageBundle', () => {
expect(bundle.municipalities).toHaveLength(0)
expect(bundle.scopeTotal).toBe(0)
expect(bundle.sliceTotals).toBeNull()
})
// -------------------------------------------------------------------------
@ -700,4 +708,136 @@ describe('loadMunicipalityListPageBundle', () => {
const searched = await loadMunicipalityListPageBundle(payload, advisor, { q: 'salvador' })
expect(cityRow(searched)).toBeDefined()
})
// -------------------------------------------------------------------------
// B202 — totals of the filtered slice (footer line)
// -------------------------------------------------------------------------
const baselineVotes = (slug: string): number =>
getMunicipalityFederalBaseline(slug).votesByYear[String(DEFAULT_VOTE_RANK_YEAR)] ?? 0
it('sums the whole default recorte to the statewide 2022 votes', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const bundle = await loadMunicipalityListPageBundle(payload, coordinator, {})
expect(bundle.sliceTotals?.rowCount).toBe(bundle.totalDocs)
expect(bundle.sliceTotals?.rowCount).toBe(municipalityCatalog.length + 1)
expect(bundle.sliceTotals?.votes2022).toBe(
getStatewideFederalTotals(DEFAULT_VOTE_RANK_YEAR).ownVotes,
)
})
it('covers every page of a paged recorte: the visible page is not the total', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const region = 'Litoral Sul'
const regionEntries = municipalityCatalog.filter((entry) => entry.region === region)
const expectedVotes = regionEntries.reduce((sum, entry) => sum + baselineVotes(entry.slug), 0)
const bundle = await loadMunicipalityListPageBundle(payload, coordinator, {
region: [region],
sort: 'name',
})
expect(regionEntries.length).toBeGreaterThan(25)
expect(bundle.municipalities).toHaveLength(25)
expect(bundle.totalDocs).toBe(regionEntries.length)
expect(bundle.sliceTotals?.rowCount).toBe(regionEntries.length)
expect(bundle.sliceTotals?.votes2022).toBe(expectedVotes)
})
it('sums expectations per scenario and counts the rows with any estimate', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const first = await fixtures.getMunicipality()
const second = await fixtures.getMunicipality()
const third = await fixtures.getMunicipality()
await payload.update({
collection: 'municipality',
id: first.id,
data: { expectedVotes: { pessimistic: 100, central: 250, optimistic: 400 } },
depth: 0,
overrideAccess: true,
})
await payload.update({
collection: 'municipality',
id: second.id,
data: { expectedVotes: { pessimistic: 50, central: null, optimistic: null } },
depth: 0,
overrideAccess: true,
})
for (const municipality of [first, second, third]) {
fixtures.touchMunicipality(municipality.id)
}
const bundle = await loadMunicipalityListPageBundle(payload, coordinator, {
slug: [first.slug, second.slug, third.slug],
})
expect(bundle.sliceTotals?.rowCount).toBe(3)
expect(bundle.sliceTotals?.expectedByScenario).toEqual({
pessimistic: 150,
central: 250,
optimistic: 400,
})
expect(bundle.sliceTotals?.withEstimateCount).toBe(2)
})
it('counts Salvador once under q: 2022 equals the city fold, never city plus zones', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const bundle = await loadMunicipalityListPageBundle(payload, coordinator, { q: 'salvador' })
const cityVotes = cityFederalBaseline().votesByYear[String(DEFAULT_VOTE_RANK_YEAR)]!
expect(cityVotes).toBeGreaterThan(0)
expect(bundle.municipalities).toHaveLength(20)
expect(bundle.sliceTotals?.rowCount).toBe(20)
expect(bundle.sliceTotals?.votes2022).toBe(cityVotes)
})
it('sums the city baseline when only the city row is selected', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const bundle = await loadMunicipalityListPageBundle(payload, coordinator, {
slug: ['salvador'],
})
expect(bundle.sliceTotals?.rowCount).toBe(1)
expect(bundle.sliceTotals?.votes2022).toBe(
cityFederalBaseline().votesByYear[String(DEFAULT_VOTE_RANK_YEAR)]!,
)
})
it('scopes the totals to the advisor carteira', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const administered = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
const bundle = await loadMunicipalityListPageBundle(payload, advisor, {})
expect(bundle.sliceTotals?.rowCount).toBe(1)
expect(bundle.sliceTotals?.votes2022).toBe(baselineVotes(administered.slug))
})
it('mirrors the row scope for an advisor with Visão Tudo', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor', {
visibility: 'tudo',
editing: 'tudo',
})
const bundle = await loadMunicipalityListPageBundle(payload, advisor, {})
expect(bundle.municipalities.length).toBeLessThan(bundle.totalDocs)
expect(bundle.sliceTotals?.rowCount).toBe(municipalityCatalog.length)
expect(bundle.sliceTotals?.votes2022).toBe(
getStatewideFederalTotals(DEFAULT_VOTE_RANK_YEAR).ownVotes,
)
})
})

View file

@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import {
federalBaselineMunicipalitySlugs,
getMunicipalityFederalBaseline,
getStatewideFederalTotals,
} from '@/lib/bahiaElectionAggregates'
import { computeMunicipalitySliceTotals } from '@/lib/municipalitySliceTotals'
import { computeVoteRankByYear, DEFAULT_VOTE_RANK_YEAR } from '@/lib/municipalityVoteRank'
import { SALVADOR_CITY_SLUG, salvadorCity } from '@/lib/salvadorCity'
import { cityFederalBaseline } from '@/utilities/municipality/salvadorCityAggregates'
const zeroEstimates = { pessimistic: null, central: null, optimistic: null }
describe('computeMunicipalitySliceTotals', () => {
it('returns null for an empty slice — no invented totals', () => {
expect(computeMunicipalitySliceTotals([])).toBeNull()
})
it('sums 2022 over the given rows and ignores unknown slugs', () => {
const ranks = computeVoteRankByYear(DEFAULT_VOTE_RANK_YEAR)
const withVotes = federalBaselineMunicipalitySlugs().filter(
(slug) => (ranks.get(slug)?.votes ?? 0) > 0,
)
const first = withVotes[0]!
const second = withVotes[1]!
const totals = computeMunicipalitySliceTotals([
{ slug: first },
{ slug: second },
{ slug: 'nao-existe' },
])
expect(totals).not.toBeNull()
expect(totals!.rowCount).toBe(3)
expect(totals!.votes2022).toBe(
getMunicipalityFederalBaseline(first).votesByYear['2022']! +
getMunicipalityFederalBaseline(second).votesByYear['2022']!,
)
})
it('sums the whole catalog (no city row) to the statewide own votes', () => {
const totals = computeMunicipalitySliceTotals(
federalBaselineMunicipalitySlugs().map((slug) => ({ slug })),
)
expect(totals!.rowCount).toBe(435)
expect(totals!.votes2022).toBe(getStatewideFederalTotals(DEFAULT_VOTE_RANK_YEAR).ownVotes)
})
it('counts Salvador once: city plus its 19 ZE equals the city fold, never both', () => {
const cityVotes = cityFederalBaseline().votesByYear['2022']!
const cityAndZones = computeMunicipalitySliceTotals([
{ slug: SALVADOR_CITY_SLUG },
...salvadorCity.zoneSlugs.map((slug) => ({ slug })),
])
const zonesOnly = computeMunicipalitySliceTotals(
salvadorCity.zoneSlugs.map((slug) => ({ slug })),
)
expect(cityVotes).toBeGreaterThan(0)
expect(cityAndZones!.votes2022).toBe(cityVotes)
expect(zonesOnly!.votes2022).toBe(cityVotes)
})
it('still sums the zone expectations when the city folds their votes', () => {
const totals = computeMunicipalitySliceTotals([
{ slug: SALVADOR_CITY_SLUG },
{ slug: salvadorCity.zoneSlugs[0]!, expectedVotes: { central: 5_000 } },
{
slug: salvadorCity.zoneSlugs[1]!,
expectedVotes: { pessimistic: 1_000, optimistic: 9_000 },
},
])
expect(totals!.votes2022).toBe(cityFederalBaseline().votesByYear['2022']!)
expect(totals!.expectedByScenario).toEqual({
pessimistic: 1_000,
central: 5_000,
optimistic: 9_000,
})
expect(totals!.withEstimateCount).toBe(2)
})
it('keeps a scenario nobody filled null and counts rows with any estimate', () => {
const totals = computeMunicipalitySliceTotals([
{ slug: 'a', expectedVotes: zeroEstimates },
{ slug: 'b', expectedVotes: { pessimistic: 100, central: 200, optimistic: null } },
{ slug: 'c', expectedVotes: { central: 50, optimistic: 0 } },
])
expect(totals!.expectedByScenario).toEqual({
pessimistic: 100,
central: 250,
optimistic: 0,
})
expect(totals!.withEstimateCount).toBe(2)
})
it('reports all scenarios null when no row has an estimate', () => {
const totals = computeMunicipalitySliceTotals([
{ slug: 'a', expectedVotes: zeroEstimates },
{ slug: 'b' },
])
expect(totals!.expectedByScenario).toEqual(zeroEstimates)
expect(totals!.withEstimateCount).toBe(0)
})
})

View file

@ -0,0 +1,72 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { MunicipalitySliceTotalsLine } from '@/components/campaign/municipality/MunicipalitySliceTotalsLine'
import type { MunicipalitySliceTotals } from '@/lib/municipalitySliceTotals'
import type { VoteEstimateScenario } from '@/lib/voteEstimate'
const scenarioState = { scenario: 'central' as VoteEstimateScenario }
vi.mock('@/components/campaign/municipality/MunicipalityEstimateScenarioContext', () => ({
useMunicipalityEstimateScenarioOptional: () => ({
scenario: scenarioState.scenario,
setScenario: () => {},
}),
}))
const render = (totals: MunicipalitySliceTotals): string =>
renderToStaticMarkup(createElement(MunicipalitySliceTotalsLine, { totals }))
describe('MunicipalitySliceTotalsLine', () => {
it('formats the four sums and reads the full scenario summary to screen readers', () => {
const html = render({
rowCount: 3,
votes2022: 1_234,
expectedByScenario: { pessimistic: 100, central: null, optimistic: 0 },
withEstimateCount: 1,
})
expect(html).toContain('data-slot="municipality-slice-totals"')
expect(html).toContain('1.234')
expect(html).toContain('Pess.')
expect(html).toContain('100')
expect(html).toContain('Média')
expect(html).toContain('—')
expect(html).toContain('Otim.')
expect(html).toContain('1 de 3 com expectativa')
expect(html).toContain(
'Pessimista: 100; Média: não informado; Otimista: 0; 1 de 3 com expectativa',
)
expect(html).not.toContain('aria-label=')
})
it('omits the coverage sentence and keeps the scenario dashes when nobody filled one', () => {
const html = render({
rowCount: 2,
votes2022: 0,
expectedByScenario: { pessimistic: null, central: null, optimistic: null },
withEstimateCount: 0,
})
expect(html).not.toContain('com expectativa')
expect(html).toContain(
'Pessimista: não informado; Média: não informado; Otimista: não informado',
)
})
it('emphasizes only the active scenario', () => {
scenarioState.scenario = 'optimistic'
const html = render({
rowCount: 1,
votes2022: 0,
expectedByScenario: { pessimistic: 100, central: 200, optimistic: 300 },
withEstimateCount: 1,
})
scenarioState.scenario = 'central'
expect(html).toContain('text-foreground font-semibold">300')
expect(html).not.toContain('text-foreground font-semibold">200')
expect(html).toContain('text-foreground">200')
})
})