From e75bb1c2f3454f28a250724040d8f879588e495d Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sat, 8 Aug 2026 23:57:59 -0300 Subject: [PATCH] =?UTF-8?q?feat(campaignTerritories):=20repintar=20a=20lis?= =?UTF-8?q?ta=20de=20Territ=C3=B3rios=20no=20padr=C3=A3o=20da=20lista=20de?= =?UTF-8?q?=20Munic=C3=ADpios=20(B175)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Colunas responsivas ao painel via @container/territory-list (P0→P3), sem scroll horizontal - Contagem de municípios no nome ("Irecê (20)"); % e votos absorvidos na célula 2022 (hover = válidos) - Célula 2026 no detalhe de Municípios (valor central + %, faixa de cenários no hover, leitura) - Colunas de rede só leitura: Assessor, Liderança, Dobradinha (para todo o staff) - Cobertura oculta por padrão no seletor; labels curtos (2022/2026/Captura/Cobertura) - loadTerritoryOverview devolve { rows, references }; rollup puro com soma por cenário, hasEstimate e uniões de rede --- docs/plans/repintar-lista-territorios-impl.md | 95 +++ .../campanha/(app)/territorios/page.tsx | 10 +- .../campaign/municipality/TerritoryList.tsx | 37 +- .../municipality/TerritoryListColumns.tsx | 589 +++++++++++------- src/lib/campaignColumnVisibility.ts | 3 + .../homeSearch/searchHomeMunicipalities.ts | 3 +- .../territory/loadTerritoryOverview.ts | 131 +++- .../territory/territoryListLabels.ts | 9 +- src/utilities/territory/territoryListUrl.ts | 8 +- src/utilities/territory/territoryOverview.ts | 68 +- tests/e2e/campaignTerritories.e2e.spec.ts | 46 +- .../campaignColumnVisibility.unit.spec.ts | 11 + tests/unit/territoryOverview.unit.spec.ts | 78 ++- 13 files changed, 794 insertions(+), 294 deletions(-) create mode 100644 docs/plans/repintar-lista-territorios-impl.md diff --git a/docs/plans/repintar-lista-territorios-impl.md b/docs/plans/repintar-lista-territorios-impl.md new file mode 100644 index 00000000..46d3e824 --- /dev/null +++ b/docs/plans/repintar-lista-territorios-impl.md @@ -0,0 +1,95 @@ +# Impl: Repintar a lista de Territórios no padrão da lista de Municípios + +Status: aprovado +Atualizado em: 2026-08-08 +Issue: #451 +Intenção: docs/plans/repintar-lista-territorios.md +Appetite restante: herdado (~1,5–2 dias eng); sem corte — a abordagem reusa o padrão B158 das colunas responsivas, não constrói novo. + +## Leitura da intenção + +- **Outcome:** `/campanha/territorios` passa ao padrão da lista de Municípios (B158): headers curtos, contagem de municípios colada ao nome do TI, % e votos absorvidos na célula de 2022, célula de 2026 no detalhe de Municípios (valor + faixa de cenários no hover, só leitura), novas colunas de rede só leitura (Assessor, Liderança, Dobradinha), `Cobertura` oculta por padrão no seletor, tabela responsiva à largura do painel sem rolagem horizontal, ordem visual fixa `<2022><2026>`. +- **O que NÃO negociar:** barreira `noLeader` intacta; leitura permanece por agregado por TI (soma/contagem, nunca dado de município individual); sub-linhas do Metropolitano seguem o pai (nunca ordenam sozinhas); sort default contínua `pct` desc; contratos de URL intactos (sort keys `region|municipalities|votes2022|pct|validVotes2022|estimate2026|coverage|cobertura|captura|classe`, filtros `region`/`coverage`); sem migração; sem Consent; formulários de visualização para 2026 e rede ficam fora (nenhuma célula abre editor). +- **O que reavaliar:** a hipótese apontava células reutilizando os componentes de Municípios (`MunicipalityVotePositionReadout`, editor de 2026). Ajuste: as células de território reutilizam **formatters** e o **padrão visual**, mas são read-outs próprios (sem `rank` por TI; sem editor; sem série 2014/2018). A hipótese também não resolveu o acesso da rede por TI — decidido abaixo. + +## Abordagem recomendada + +```mermaid +flowchart LR + P[territorios/page.tsx] --> LO[loadTerritoryOverview → bundle rows+references] + LO --> ROLLUP[computeTerritoryRollup puro] + LO -.-> REF[advisorNamesById / leadershipNamesById / stateDeputyById] + P --> TL[TerritoryList @container/territory-list] + TL --> COLS[TerritoryListColumns responsivas + novas células read-only] + COLS -.-> SH[TerritorySortableHead] + COLS -.-> AV[MunicipalityRelationAvatarStack] + COLS -.-> STRIP[VoteEstimateScenarioStrip hover] +``` + +**Opções consideradas:** A (fiar no bundle único do loader + padrão B158) | B (carregar rede em loader separado do overview p/ homeSearch não pagar) | C (reusar 1:1 os componentes editáveis de Municípios) +**Recomendação:** **A** — um `loadTerritoryOverview` que devolve `{ rows, references }`; as consultas de rede são poucas e pequenas (advisor names por ids, batch reverso de lideranças por município, catálogo de dobradinhas) e o home-search (`searchHomeMunicipalities`) passa a usar `.rows` — o custo adicional (3–4 queries pequenas) é aceitável contra a coesão de UM loader e zero leituras duplicadas de `municipality`. O `Termo` `TerritoryReader` (union `CampaignUser | User`) é apertado para `CampaignUser` — os dois call sites passam `CampaignUser`. +**Rejeitadas:** B porque duplicaria a leitura de municípios e criaria um segundo módulo de domínio para o mesmo bundle (smell da Depth Check); C porque traria editores e `rank`/série 2014-2018 que o gate explicitamente cortou e duplicaria tooltip no mesmo gesto. + +### Componentes / mudanças + +- **`territoryOverview.ts`** (`src/utilities/territory/`, puro/client-safe): estende `TerritoryMunicipalityInput` com `estimateByScenario: Record`, `hasEstimate: boolean`, e os conjuntos `advisorIDs: number[]`, `leadershipIDs: number[]`, `stateDeputyIDs: number[]` (elimina `advisorCount` redundante — cobertura deriva de `advisorIDs.length > 0`). O rollup (`computeTerritoryRollup`) passa a somar por cenário (`estimateByScenario`), OR de `hasEstimate` e união (dedup) dos três conjuntos por TI — covariando nas sub-linhas do Metropolitano. `sortTerritoryRows` para `estimate2026` usará `estimateByScenario.central`. +- **`loadTerritoryOverview.ts`** (`server-only`): novo retorno `{ rows, references }`; `references = { advisorNamesById, leadershipNamesById, stateDeputyById }` via `loadAdvisorSummaries` + `loadMunicipalityLeadershipSummaries` (batch reverso por municípios, honra `canReadLeadership`, fail-closed) + catálogo `loadStateDeputyOptions` (honra `canReadStateDeputy`). Por município: `advisorIDs` de `doc.advisors`, `stateDeputyIDs` de `doc.stateDeputies` (select novo), `leadershipIDs` do batch reverso; `estimateByScenario` por `resolveMunicipalityStaffVoteTotalForScenario`; `hasEstimate` = `hasAnyVoteEstimate(expectedVotes)` OU `pledgeAggregate.declaredTotal > 0`. +- **`searchHomeMunicipalities.ts`**: consome `.rows` do novo retorno (não usa rede). +- **`territoryListUrl.ts` / `territoryListLabels.ts`**: labels de coluna curtos — `votes2022: '2022'`, `estimate2026: '2026'`, `captura: 'Captura'`, `cobertura: 'Cobertura'`; removem-se as descrições mortas (`municipalities`, `pct`, `validVotes2022` — colunas saem da tabela e o tooltip de header não as usa). **Não** se remove nenhuma sort key (todas permanecem válidas via omnibox/resumo — só o header clicável some). +- **`campaignColumnVisibility.ts`** (`lib/`): `DEFAULT_HIDDEN_COLUMN_IDS.territorios = ['cobertura']` (Espelho de `municipios: ['goalCoverage','lastSignal']`). +- **`TerritoryList.tsx`**: envolve a tabela em `
`; `CampaignTable` com `containerClassName="overflow-x-auto supports-[container-type:inline-size]:overflow-x-hidden"` e `overflow-visible`; passa `references` às colunas. Sem aba mobile (a leitura estreita para no núcleo P0 — intenção). +- **`TerritoryListColumns.tsx`**: nova ordem visual fixa e classes responsivas por coluna (`hidden @min-[Xrem]/territory-list:table-cell`): + - `region` (mandatory, sticky left, `min-w-56`): name-link + ` (N)` da contagem (`Metropolitano de Salvador (40)`; sub-linhas `Salvador (19)` / `Demais RMS (21)` — mesma convenção). + - `votes2022` (header `2022`, P0): célula `% (primária) + votos (linha secundária)` — read-out próprio (formatters `formatVoteSharePercent`/`formatElectionNumber`; sem `rank`; sem curva 2014/2018); `cellTooltip` = **exclusivamente** os votos válidos 2022 (`Votos válidos 2022: N`). + - `captura` (header `Captura`, P1 `@min-[46rem]`): célula atual (taxa de captura agregada), `cellTooltip` atual (mediana/amplitude/município crítico/beacon) — inalterada. + - `estimate2026` (header `2026`, P0): read-out leitura — valor `estimateByScenario.central` + `%` como linha secundária; `—` quando `!hasEstimate`; `cellTooltip` = `VoteEstimateScenarioStrip` dos 3 cenários (`labelMode="endpoints"`, `markerMode="active-only"`, espelho do hover compacto de Municípios). Sem editor, sem seletor de cenário (lista lê o cenário default — intenção). + - `advisor` (header `Assessor`, P2 `@min-[62rem]`): read-only via `MunicipalityRelationAvatarStack` (avatars + nomes no tooltip — padrão Municípios); vazio = `—` mudo (o alerta de lacuna continua na Assessoria); aporta `advisorNamesById`. + - `leadership` (header `Liderança`, P2 `@min-[68rem]`): read-only (avatars, href `/campanha/liderancas/`, nomes no tooltip); vazio = `—`. + - `stateDeputy` (header `Dobradinha`, P2 `@min-[74rem]`): read-only (avatars, href `/campanha/dobradinhas/`, nome+partido no tooltip); vazio = `—`. **Coluna para TODO o staff** (decisão do gate humano 2026-08-08): nomes via catálogo `loadStateDeputyOptions` (honra `canReadStateDeputy` = todo staff), sem restrição de papel na coluna. + - `classe` (header `Classe`, P1 `@min-[52rem]`): célula atual inalterada. + - `coverage` (header `Assessoria`, P1 `@min-[56rem]`): célula atual (`X de N`) + filtro `coverage` no header (inalterado). + - `cobertura` (header `Cobertura`, P3 `@min-[80rem]`): célula atual (ratio + déficit), oculta por padrão no seletor. + - Saem da tabela: `municipalities`, `pct`, `validVotes2022` (absorvidas; sort keys preservadas via omnibox/resumo). +- **Novos read-outs** ficam inline em `TerritoryListColumns.tsx` (componentes pequenos no próprio arquivo — 3 células pequenas + 2 read-outs; sem módulos novos só para células de 1 call site, contra a Depth Check). +- **`TerritorySortableHead.tsx`**: sem mudança estrutural (labels vêm de `territoryListSortLabels`). +- **Migration:** sem migration (nenhuma mudança de schema). + +### Dados → forma + +- Forma: cell 2022 = % primário + votos secundário (espelho `MunicipalityVotePositionReadout`, sem `rank`); 2026 = valor central + % secundário, faixa de cenários só no hover (espelho do compacto de `StaffMunicipalityVotesDisplay`). Rejeitadas: manter coluna `% da própria votação` (o painel estourava — objeto do item); válidos na célula (empilharia 3 linhas no mesmo gesto — gate); série 2014/2018 no hover (decisão do gate: sai; hover = só válidos). + +## Fases verificáveis + +1. **Schema/server** — `territoryOverview.ts` (input/rollup/sort) + `loadTerritoryOverview.ts` (bundle+references) + `searchHomeMunicipalities.ts` (`.rows`) + `tests/unit/territoryOverview.unit.spec.ts` (fixture com novos campos; asserts de soma por cenário, `hasEstimate`, uniões de advisor/leadership/stateDeputy, sub-linhas). +2. **UI** — labels (listUrl/listLabels), default-hidden (campaignColumnVisibility + teste unit), `TerritoryList.tsx` (container), `TerritoryListColumns.tsx` (ordem, células, responsivas, células novas), picker (passa as colunas novas; `stateDeputy` condicional por unrestricted). +3. **Gates** — `pnpm gate:fast` em iteração, e2e (`campaignTerritories.e2e.spec.ts`: atualizar `Ordenar por Votos 2022` → `Ordenar por 2022`; assert regex de contagem `(N)` e de colunas novas), `pnpm format:check`, `knip`, `check:cycles`, `pnpm build`; entrega via `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- Não adicionar seletor de cenário ao território (fora de escopo da intenção — lista lê o cenário default). +- Não criar módulo novo para as células (1 call site; inline no arquivo de colunas). +- Não mexer em fórmulas (captura/cobertura/%) nem na malha/`territorialClass`. +- Não aplicar o padrão às demais listas (lideranças, dobradinhas, organizações…). +- Não tocar em migration/schema/Consent. +- Não reabrir cards mobile de territórios (não existem; a estreiteza para no núcleo P0). + +## Riscos e mitigação + +- **Breakpoints de container** (P1/P2/P3): valores concretos são ajustáveis (baratos) — começar com os acima e afinar pelo widget/preview; registrar gatilho de revisitação no CHANGELOG se o painel mostrar degraus feios. +- **e2e sensível a label**: `Ordenar por Votos 2022` → `Ordenar por 2022` (aria-label vem de `territoryListSortLabels.votes2022`). +- **Acesso da rede por TI**: nomes de assessores via `loadAdvisorSummaries` (todos os staff; drop de ids não-elegíveis, fail-closed); nomes de lideranças via `loadMunicipalityLeadershipSummaries` (honra `canReadLeadership` — assessor só vê lideranças dos municípios que administra; fora do escopo, célula vazia `—`, fail-closed); **Dobradinha para todo o staff** (decisão do gate humano 2026-08-08; `canReadStateDeputy` = todo staff). Sem overrideAccess: false nos loads de rede. +- **Performance**: rollup permanece puro e O(n) por TI; referências usam poucos `where in` (não N+1 por TI). + +## Decisões de engenharia (formato obrigatório) + +1. **Loader de rede**: Opções: bundle único | loader separado | reusar componentes editáveis. Recomendação: bundle único (`loadTerritoryOverview` → `{ rows, references }`). Rejeitadas: loader separado (2º módulo + leitura dupla de municípios), componentes de Municípios 1:1 (editores/rank/série fora do gate). +2. **Células 2022/2026**: Opções: reusar `MunicipalityVotePositionReadout`/`StaffMunicipalityVotesDisplay` | read-outs próprios com formatters. Recomendação: read-outs próprios (hospedados no arquivo de colunas), reusando `formatVoteSharePercent`/`formatElectionNumber` e `VoteEstimateScenarioStrip`. Rejeitadas: reuso direto (arrastaria `rank`/`totalUnits` e o layout de editor/pledge). +3. **Assessor vazio**: Opções: badge `MissingAdvisorBadge` (padrão Municípios) | `—` mudo. Recomendação: `—` mudo — o alarme de lacuna já vive na coluna Assessoria (`X de N`), duplicar o mesmo aviso no mesmo gesto é ruído. +4. **Coluna Dobradinha por papel**: Opções: restringir a `isCampaignUnrestricted` (consistência com B157) | expor a todo o staff (leitura simples da intenção + `canReadStateDeputy` já é todo staff). Recomendação (confirmada no gate humano 2026-08-08): **expor a todo o staff** — nenhum editável é duplicado e a leitura é agregada por TI; o acesso de dados já permite. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto (headers curtos, `(N)` no nome, %/válidos absorvidos, 2026 espelho, rede leitura, Cobertura oculta, sem scroll, ordem fixa) +- [x] Invariantes AGENTS/engineering-standards (sem overrideAccess: false na rede; sort/URL/filtros conservados; copy pt-BR / ids em inglês; sem migration; sem Consent) +- [x] Testes de domínio previstos: unit (rollup puro — cenários, hasEstimate, uniões, Metropolitano) + e2e (labels, contagem, colunas novas, url/âncoras) + campaignColumnVisibility (default oculto de territorios) +- [x] Self-score decision-quality: 5/5 (decididas com rejeitadas; cabe no appetite; rabbit holes nomeados; reuso de shells; intenção preservada) diff --git a/src/app/(campaign)/campanha/(app)/territorios/page.tsx b/src/app/(campaign)/campanha/(app)/territorios/page.tsx index 9cc82bbe..82f4601a 100644 --- a/src/app/(campaign)/campanha/(app)/territorios/page.tsx +++ b/src/app/(campaign)/campanha/(app)/territorios/page.tsx @@ -4,7 +4,7 @@ import { getPayload } from 'payload' import { TerritoryFilters } from '@/components/campaign/municipality/TerritoryFilters' import { TerritoryList } from '@/components/campaign/municipality/TerritoryList' -import { territoryListColumns } from '@/components/campaign/municipality/TerritoryListColumns' +import { territoryListPickerColumns } from '@/components/campaign/municipality/TerritoryListColumns' import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing' import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter' import { @@ -12,7 +12,6 @@ import { CampaignListResults, } from '@/components/campaign/shared/CampaignListPending' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' -import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility' import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' @@ -39,7 +38,7 @@ export default async function TerritoriesPage({ searchParams }: TerritoriesPageP getPayload({ config }), ]) - const allRows = await loadTerritoryOverview(payload, user) + const { rows: allRows, references } = await loadTerritoryOverview(payload, user) const columnVisibility = await readCampaignColumnVisibility('territorios') const { state } = resolvedUrl const { sort, dir } = resolveTerritoryListSort(state) @@ -57,9 +56,7 @@ export default async function TerritoriesPage({ searchParams }: TerritoriesPageP trailing={ } /> @@ -68,6 +65,7 @@ export default async function TerritoriesPage({ searchParams }: TerritoriesPageP rows={rows} state={state} regionOptions={regionOptions} + references={references} columnVisibility={columnVisibility} /> { const { sort, dir } = resolveTerritoryListSort(state) const sortSummary = formatTerritoryListSortSummary(sort, dir) return ( - (row.variant === 'parent' ? row.region : `${row.parentRegion}-${row.label}`)} - rowId={(row) => (row.variant === 'parent' ? territoryAnchorId(row.region) : undefined)} - rowClassName={(row) => - row.variant === 'sub' ? 'bg-muted/30' : 'scroll-mt-6 target:bg-muted/50' - } - empty={} - /> +
+ + row.variant === 'parent' ? row.region : `${row.parentRegion}-${row.label}` + } + rowId={(row) => (row.variant === 'parent' ? territoryAnchorId(row.region) : undefined)} + rowClassName={(row) => + row.variant === 'sub' ? 'bg-muted/30' : 'scroll-mt-6 target:bg-muted/50' + } + empty={} + /> +
) } diff --git a/src/components/campaign/municipality/TerritoryListColumns.tsx b/src/components/campaign/municipality/TerritoryListColumns.tsx index ee63bf9a..c4dbe3d7 100644 --- a/src/components/campaign/municipality/TerritoryListColumns.tsx +++ b/src/components/campaign/municipality/TerritoryListColumns.tsx @@ -1,10 +1,19 @@ import Link from 'next/link' +import type { ReactElement } from 'react' import { TerritorySortableHead } from '@/components/campaign/municipality/TerritorySortableHead' import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending' -import type { CampaignTableColumn } from '@/components/campaign/shared/CampaignTable' +import { + CampaignTableHead, + type CampaignTableColumn, +} from '@/components/campaign/shared/CampaignTable' +import type { MunicipalityRelationEntry } from '@/components/campaign/shared/MunicipalityRelationAvatarStack' +import { MunicipalityRelationAvatarStack } from '@/components/campaign/shared/MunicipalityRelationAvatarStack' +import { VoteEstimateScenarioStrip } from '@/components/campaign/votePledge/VoteEstimateScenarioStrip' import { Badge } from '@/components/ui/Badge' -import { formatElectionNumber } from '@/lib/electionFormat' +import { formatElectionNumber, formatVoteSharePercent } from '@/lib/electionFormat' +import { stateDeputyDisplayName } from '@/lib/stateDeputyNameParty' +import { formatVoteEstimateScenarioAriaLabel } from '@/lib/voteEstimate' import { formatGoalCoverageDeficitShortLabel, formatGoalCoverageRatioLabel, @@ -15,6 +24,10 @@ import { territorialClassBadgeVariant, territorialClassLabels, } from '@/utilities/municipality/municipalityLabels' +import type { + TerritoryNetworkReferences, + TerritoryStateDeputyReference, +} from '@/utilities/territory/loadTerritoryOverview' import type { TerritoryFilterOption } from '@/utilities/territory/territoryListFilters' import { territoryColumnDescriptions } from '@/utilities/territory/territoryListLabels' import { @@ -23,34 +36,6 @@ import { } from '@/utilities/territory/territoryListUrl' import type { TerritoryTableRow } from '@/utilities/territory/territoryOverview' -const percentFormatter = new Intl.NumberFormat('pt-BR', { - minimumFractionDigits: 1, - maximumFractionDigits: 1, -}) - -const formatPercent = (ratio: number): string => `${percentFormatter.format(ratio * 100)}%` - -const YearSeriesCell = ({ votesByYear }: { votesByYear: Record }) => { - const prior = [ - { year: '2014', value: votesByYear['2014'] ?? 0 }, - { year: '2018', value: votesByYear['2018'] ?? 0 }, - ] - - return ( -
- - {formatElectionNumber(votesByYear['2022'] ?? 0)} - - - {prior.map(({ year, value }) => `${year}: ${formatElectionNumber(value)}`).join(' · ')} - -
- ) -} - const CoverageCell = ({ withAdvisorCount, total, @@ -139,189 +124,373 @@ const captureTooltipContent = (row: TerritoryTableRow) => { ) } +/** B175 — 2022 cell mirroring the municípios pattern: % first, votes as the meta line. */ +const TerritoryVoteReadout = ({ row }: { row: TerritoryTableRow }) => { + const votes = row.votesByYear['2022'] ?? 0 + const share = row.pctPropriaVotacao + return ( +
+ {formatVoteSharePercent(share)} + {formatElectionNumber(votes)} + {/* The valid votes live only in the hover — keep them as sr-only text so the tooltip repeats, never invents. */} + + Votos válidos de deputado federal em 2022 no território:{' '} + {formatElectionNumber(row.validVotes2022)} + +
+ ) +} + +/** B175 — 2026 read-only cell: central-scenario total + % as a secondary line; scenarios on hover. */ +const TerritoryEstimateReadout = ({ row }: { row: TerritoryTableRow }) => { + if (!row.hasEstimate) { + return — + } + return ( +
+ {formatElectionNumber(row.estimateByScenario.central)} + + {formatVoteSharePercent(row.pctPropriaVotacao)} + + {/* Same redundancy contract as the cell's hover strip (municípios precedent). */} + {formatVoteEstimateScenarioAriaLabel(row.estimateByScenario)} +
+ ) +} + +// --------------------------------------------------------------------------- +// B175 — read-only network columns (Assessor / Liderança / Dobradinha) +// --------------------------------------------------------------------------- + +const territoryNetworkColumnDescriptions = { + advisor: + 'Assessores de staff atribuídos nos municípios do território. Nomes no detalhe da célula.', + leadership: 'Lideranças vinculadas aos municípios do território. Nomes no detalhe da célula.', + stateDeputy: + 'Deputados estaduais em dobradinha nos municípios do território. Nomes no detalhe da célula.', +} as const + +const territoryNetworkColumnLabels = { + advisor: 'Assessor', + leadership: 'Liderança', + stateDeputy: 'Dobradinha', +} as const + +const advisorEntries = ( + row: TerritoryTableRow, + advisorNamesById: TerritoryNetworkReferences['advisorNamesById'], +): MunicipalityRelationEntry[] => + row.advisorIDs.flatMap((id) => { + const advisor = advisorNamesById.get(id) + return advisor ? [{ id: advisor.id, label: advisor.name }] : [] + }) + +const leadershipEntries = ( + row: TerritoryTableRow, + leadershipNamesById: TerritoryNetworkReferences['leadershipNamesById'], +): MunicipalityRelationEntry[] => + row.leadershipIDs.flatMap((id) => { + const leadership = leadershipNamesById.get(id) + return leadership + ? [ + { + id: leadership.id, + label: leadership.name, + href: `/campanha/liderancas/${leadership.id}`, + }, + ] + : [] + }) + +const stateDeputyEntries = ( + row: TerritoryTableRow, + stateDeputyById: TerritoryNetworkReferences['stateDeputyById'], +): MunicipalityRelationEntry[] => + row.stateDeputyIDs.flatMap((id) => { + const deputy: TerritoryStateDeputyReference | undefined = stateDeputyById.get(id) + if (!deputy) return [] + return [ + { + id: deputy.id, + label: stateDeputyDisplayName(deputy.plainName, deputy.party), + initialsLabel: deputy.plainName, + href: deputy.href, + }, + ] + }) + +const namesTooltip = (entries: MunicipalityRelationEntry[]): ReactElement | null => + entries.length === 0 ? null : ( +
+ {entries.map((entry) => ( + {entry.label} + ))} +
+ ) + +const NetworkCell = ({ entries }: { entries: MunicipalityRelationEntry[] }) => ( + —} + maxVisible={3} + /> +) + +/** + * B175 — column widths keep the density ladder: P0 core is always on, one + * column joins per panel-width rung, never a horizontal scrollbar. + */ +const responsiveColumnClassName = { + captura: 'hidden @min-[44rem]/territory-list:table-cell', + classe: 'hidden @min-[50rem]/territory-list:table-cell', + coverage: 'hidden @min-[54rem]/territory-list:table-cell', + advisor: 'hidden @min-[60rem]/territory-list:table-cell', + leadership: 'hidden @min-[66rem]/territory-list:table-cell', + stateDeputy: 'hidden @min-[72rem]/territory-list:table-cell', + cobertura: 'hidden @min-[78rem]/territory-list:table-cell', +} as const + +type TerritoryListColumnsProps = { + state: TerritoryListState + regionOptions: TerritoryFilterOption[] + references: TerritoryNetworkReferences +} + export const territoryListColumns = ({ state, regionOptions, -}: { - state: TerritoryListState - regionOptions: TerritoryFilterOption[] -}): Array> => [ - { - id: 'region', - mandatory: true, - label: territoryListSortLabels.region, - head: ( - - ), - cell: (row) => - row.variant === 'parent' ? ( - - {row.region} - - ) : ( - {row.label} + references, +}: TerritoryListColumnsProps): Array> => { + const { advisorNamesById, leadershipNamesById, stateDeputyById } = references + + return [ + { + id: 'region', + mandatory: true, + label: territoryListSortLabels.region, + head: ( + ), - cellClassName: (row) => - `sticky left-0 z-[5] min-w-56 ${row.variant === 'sub' ? 'bg-muted' : 'bg-background'}`, - }, - { - id: 'municipalities', - label: territoryListSortLabels.municipalities, - head: ( - - ), - cell: (row) => row.municipalityCount, - cellClassName: 'text-right tabular-nums', - }, - { - id: 'votes2022', - label: territoryListSortLabels.votes2022, - head: ( - - ), - cell: (row) => , - cellClassName: 'text-right tabular-nums', - }, - { - id: 'pct', - label: territoryListSortLabels.pct, - head: ( - - ), - cell: (row) => formatPercent(row.pctPropriaVotacao), - cellClassName: (row) => - `text-right tabular-nums ${row.variant === 'parent' ? 'font-medium' : 'text-muted-foreground'}`, - }, - { - id: 'validVotes2022', - label: territoryListSortLabels.validVotes2022, - head: ( - - ), - cell: (row) => formatElectionNumber(row.validVotes2022), - cellClassName: 'text-right tabular-nums text-muted-foreground', - }, - { - id: 'estimate2026', - label: territoryListSortLabels.estimate2026, - head: ( - - ), - cell: (row) => formatElectionNumber(row.estimate2026), - cellClassName: (row) => - `text-right tabular-nums ${row.variant === 'sub' ? 'text-muted-foreground' : ''}`, - }, - { - id: 'cobertura', - label: territoryListSortLabels.cobertura, - head: ( - - ), - cell: (row) => ( -
- - {formatGoalCoverageRatioLabel(row.goalCoverage)} - - + row.variant === 'parent' ? ( + + {row.region}{' '} + ({row.municipalityCount}) + + ) : ( + // The Metropolitano sub-labels already carry a count group ("Salvador (19 zonas)"), + // so the count rides the name only when the label has none — never stacking "(19 zonas) (19)". + + {row.label}{' '} + {/\(\d[^)]*\)$/.test(row.label) ? null : ( + ({row.municipalityCount}) + )} + + ), + cellClassName: (row) => + `sticky left-0 z-[5] min-w-56 ${row.variant === 'sub' ? 'bg-muted' : 'bg-background'}`, + }, + { + id: 'votes2022', + label: territoryListSortLabels.votes2022, + head: ( + + ), + cell: (row) => , + cellClassName: 'text-right tabular-nums', + // The valid-votes figure is the cell's exclusive hover content (B175). + cellTooltip: (row) => + `Votos válidos de deputado federal em 2022 no território: ${formatElectionNumber(row.validVotes2022)}`, + }, + { + id: 'captura', + label: territoryListSortLabels.captura, + head: ( + + ), + cell: (row) => formatRatioAsPercentLabel(row.captureRate), + cellClassName: (row) => + `${responsiveColumnClassName.captura} text-right tabular-nums ${ + row.variant !== 'parent' ? 'text-muted-foreground' : '' + }`, + cellTooltip: (row) => captureTooltipContent(row), + }, + { + id: 'estimate2026', + label: territoryListSortLabels.estimate2026, + head: ( + + ), + cell: (row) => , + cellClassName: (row) => + `text-right tabular-nums ${row.variant === 'sub' ? 'text-muted-foreground' : ''}`, + cellTooltip: (row) => + row.hasEstimate ? ( +
+ +
+ ) : null, + }, + { + id: 'advisor', + label: territoryNetworkColumnLabels.advisor, + head: ( + - {formatGoalCoverageDeficitShortLabel(row.goalCoverage)} -
-
- ), - cellClassName: 'text-right', - }, - { - id: 'captura', - label: territoryListSortLabels.captura, - head: ( - - ), - cell: (row) => formatRatioAsPercentLabel(row.captureRate), - cellClassName: 'text-right tabular-nums', - cellTooltip: (row) => captureTooltipContent(row), - }, - { - id: 'classe', - label: territoryListSortLabels.classe, - head: ( - - ), - cell: (row) => , - cellClassName: 'text-right', - cellTooltip: (row) => - row.territorialClass ? formatTerritorialClassWhy(row.territorialClass.factors) : undefined, - }, - { - id: 'coverage', - label: territoryListSortLabels.coverage, - head: ( - - ), - cell: (row) => ( - - ), - cellClassName: 'text-right', - }, + {territoryNetworkColumnLabels.advisor} + + ), + cellClassName: responsiveColumnClassName.advisor, + cell: (row) => , + cellTooltip: (row) => namesTooltip(advisorEntries(row, advisorNamesById)), + }, + { + id: 'leadership', + label: territoryNetworkColumnLabels.leadership, + head: ( + + {territoryNetworkColumnLabels.leadership} + + ), + cellClassName: responsiveColumnClassName.leadership, + cell: (row) => , + cellTooltip: (row) => namesTooltip(leadershipEntries(row, leadershipNamesById)), + }, + { + id: 'stateDeputy', + label: territoryNetworkColumnLabels.stateDeputy, + head: ( + + {territoryNetworkColumnLabels.stateDeputy} + + ), + cellClassName: responsiveColumnClassName.stateDeputy, + cell: (row) => , + cellTooltip: (row) => namesTooltip(stateDeputyEntries(row, stateDeputyById)), + }, + { + id: 'classe', + label: territoryListSortLabels.classe, + head: ( + + ), + cell: (row) => , + cellClassName: (row) => + `${responsiveColumnClassName.classe} text-right ${ + row.variant !== 'parent' ? 'text-muted-foreground' : '' + }`, + cellTooltip: (row) => + row.territorialClass ? formatTerritorialClassWhy(row.territorialClass.factors) : undefined, + }, + { + id: 'coverage', + label: territoryListSortLabels.coverage, + head: ( + + ), + cell: (row) => ( + + ), + cellClassName: `${responsiveColumnClassName.coverage} text-right`, + }, + { + id: 'cobertura', + label: territoryListSortLabels.cobertura, + head: ( + + ), + cell: (row) => ( +
+ + {formatGoalCoverageRatioLabel(row.goalCoverage)} + + + {formatGoalCoverageDeficitShortLabel(row.goalCoverage)} + +
+ ), + cellClassName: `${responsiveColumnClassName.cobertura} text-right`, + }, + ] +} + +/** B17 — the picker list; `region` is mandatory, the rest follow the visual order. */ +export const territoryListPickerColumns = () => [ + { id: 'region', label: territoryListSortLabels.region, mandatory: true }, + { id: 'votes2022', label: territoryListSortLabels.votes2022 }, + { id: 'captura', label: territoryListSortLabels.captura }, + { id: 'estimate2026', label: territoryListSortLabels.estimate2026 }, + { id: 'advisor', label: territoryNetworkColumnLabels.advisor }, + { id: 'leadership', label: territoryNetworkColumnLabels.leadership }, + { id: 'stateDeputy', label: territoryNetworkColumnLabels.stateDeputy }, + { id: 'classe', label: territoryListSortLabels.classe }, + { id: 'coverage', label: territoryListSortLabels.coverage }, + { id: 'cobertura', label: territoryListSortLabels.cobertura }, ] diff --git a/src/lib/campaignColumnVisibility.ts b/src/lib/campaignColumnVisibility.ts index 8b5cdf6e..e268794e 100644 --- a/src/lib/campaignColumnVisibility.ts +++ b/src/lib/campaignColumnVisibility.ts @@ -68,6 +68,9 @@ const COLUMN_ID_PATTERN = /^[A-Za-z0-9_-]+$/ const DEFAULT_HIDDEN_COLUMN_IDS: Partial> = { municipios: ['goalCoverage', 'lastSignal'], + // B175 — Cobertura is the deepest rung (P3) and only shows when the actor + // opts in from the picker, mirroring the municipios precedent. + territorios: ['cobertura'], } const isCampaignListId = (value: string): value is CampaignListId => diff --git a/src/utilities/homeSearch/searchHomeMunicipalities.ts b/src/utilities/homeSearch/searchHomeMunicipalities.ts index 91efe139..036b830f 100644 --- a/src/utilities/homeSearch/searchHomeMunicipalities.ts +++ b/src/utilities/homeSearch/searchHomeMunicipalities.ts @@ -48,10 +48,11 @@ export const searchHomeMunicipalities = async ( const normalizedQuery = normalizeHomeSearchName(query) - const [municipalityHits, territoryRows] = await Promise.all([ + const [municipalityHits, territoryOverview] = await Promise.all([ searchStaffMunicipalityHits(payload, user, query), loadTerritoryOverview(payload, user), ]) + const territoryRows = territoryOverview.rows const territoryHits: HomeSearchTerritoryHit[] = territoryRows .map((row) => { const normalizedName = normalizeHomeSearchName(row.region) diff --git a/src/utilities/territory/loadTerritoryOverview.ts b/src/utilities/territory/loadTerritoryOverview.ts index b36b0f1b..6b7f046a 100644 --- a/src/utilities/territory/loadTerritoryOverview.ts +++ b/src/utilities/territory/loadTerritoryOverview.ts @@ -5,28 +5,60 @@ import { cache } from 'react' import { getMunicipalityFederalBaseline } from '@/lib/bahiaElectionAggregates' import { slugsForMetropolitanoSubRowLabel } from '@/lib/metropolitanoTerritoryPeers' -import { DEFAULT_VOTE_ESTIMATE_SCENARIO, type VoteEstimateScenario } from '@/lib/voteEstimate' -import type { CampaignUser, Municipality, User } from '@/payload-types' +import { relationshipId } from '@/lib/relationship' +import { + DEFAULT_VOTE_ESTIMATE_SCENARIO, + hasAnyVoteEstimate, + resolveMunicipalityStaffVoteTotalForScenario, + toVoteEstimateScenarioViewModel, + VOTE_ESTIMATE_SCENARIOS, + type VoteEstimateScenario, +} from '@/lib/voteEstimate' +import type { CampaignUser, Municipality } from '@/payload-types' +import { loadStateDeputyOptions } from '@/utilities/campaignRelationOptions' import { loadMunicipalityScopeFromDocs } from '@/utilities/municipality/campaignMunicipalityScope' import { loadMunicipalityGoalCoverageBundle } from '@/utilities/municipality/municipalityGoalAccount' import { fieldCeiling, ownVotes2022 } from '@/utilities/municipality/municipalityPotential' import { computeAggregateTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass' +import { + loadAdvisorSummaries, + loadMunicipalityLeadershipSummaries, + type MunicipalityAdvisorSummary, + type MunicipalityLeadershipSummary, +} from '@/utilities/municipality/municipalityViewModels' import { computeTerritoryRollup, type TerritoryMunicipalityInput, type TerritoryOverviewRow, } from '@/utilities/territory/territoryOverview' -import { - emptyMunicipalityPledgeAggregate, - resolveMunicipalityStaffVoteTotal, -} from '@/utilities/votePledgeViews' +import { emptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeViews' type MunicipalityDoc = Pick< Municipality, - 'id' | 'slug' | 'name' | 'city' | 'region' | 'kind' | 'advisors' | 'expectedVotes' + | 'id' + | 'slug' + | 'name' + | 'city' + | 'region' + | 'kind' + | 'advisors' + | 'stateDeputies' + | 'expectedVotes' > -type TerritoryReader = CampaignUser | User +/** Aggregate name lookups the territory network columns render (B175). */ +export type TerritoryStateDeputyReference = { + id: number + plainName: string + party: string | null + href: string +} + +export type TerritoryNetworkReferences = { + advisorNamesById: ReadonlyMap + leadershipNamesById: ReadonlyMap + stateDeputyById: ReadonlyMap +} const attachTerritorialClasses = ( inputs: ReadonlyArray, @@ -50,19 +82,27 @@ const attachTerritorialClasses = ( }) /** - * E17 + E12 — Loads the full 27-TI overview (all 435 municipalities). + * E17 + E12 + B175 — Loads the full 27-TI overview (all 435 municipalities) + * plus the aggregate name lookups for the read-only network columns + * (Assessor / Liderança / Dobradinha). * * Regional context is intentionally non-scoped: an advisor sees the complete * comparative table (leitura regional é contexto, não gestão). Exposure is - * TI-level aggregates only — never per-municipality PII. Leaders never reach - * this (the Início page routes them to the contact tool first). + * TI-level aggregates only — never per-municipality PII. Network names honour + * the existing read-access contracts (`canReadCampaignUsers` for advisors, + * `canReadLeadership` for leaderships, `canReadStateDeputy` for dobradinhas), + * so an advisor never learns leaderships outside the municipalities they + * administer. Leaders never reach this (`gate: noLeader` on the page). */ export const loadTerritoryOverview = cache( async ( payload: Payload, - user: TerritoryReader, + user: CampaignUser, scenario: VoteEstimateScenario = DEFAULT_VOTE_ESTIMATE_SCENARIO, - ): Promise => { + ): Promise<{ + rows: TerritoryOverviewRow[] + references: TerritoryNetworkReferences + }> => { const result = await payload.find({ collection: 'municipality', depth: 0, @@ -77,6 +117,7 @@ export const loadTerritoryOverview = cache( region: true, kind: true, advisors: true, + stateDeputies: true, expectedVotes: true, }, }) @@ -94,11 +135,15 @@ export const loadTerritoryOverview = cache( const inputs: TerritoryMunicipalityInput[] = docs.map((doc) => { const baseline = getMunicipalityFederalBaseline(doc.slug) const aggregate = aggregates.get(doc.id) ?? emptyMunicipalityPledgeAggregate - const estimate = resolveMunicipalityStaffVoteTotal( - doc.expectedVotes ?? null, - aggregate.effectiveByScenario[scenario], - scenario, - ) + const expectedView = toVoteEstimateScenarioViewModel(doc.expectedVotes) + const estimateByScenario = {} as Record + for (const key of VOTE_ESTIMATE_SCENARIOS) { + estimateByScenario[key] = resolveMunicipalityStaffVoteTotalForScenario( + doc.expectedVotes, + aggregate.effectiveByScenario[key], + key, + ) + } const coverageByScenario = coverageByMunicipalityID.get(doc.id) const goalCoverage = coverageByScenario?.[scenario] ?? { goal: 0, @@ -115,15 +160,57 @@ export const loadTerritoryOverview = cache( kind: doc.kind, votesByYear: { ...baseline.votesByYear }, validVotesByYear: { ...baseline.validVotesByYear }, - estimate2026: estimate, - advisorCount: doc.advisors?.length ?? 0, + estimateByScenario, + hasEstimate: hasAnyVoteEstimate(expectedView) || aggregate.declaredTotal > 0, + advisorIDs: (doc.advisors ?? []) + .map(relationshipId) + .filter((id): id is number => id !== null), + stateDeputyIDs: (doc.stateDeputies ?? []) + .map(relationshipId) + .filter((id): id is number => id !== null), + leadershipIDs: [], ownVotes2022: ownVotes2022(baseline), fieldCeiling2022: fieldCeiling(baseline), goalCoverage, } }) - const rows = computeTerritoryRollup(inputs) - return attachTerritorialClasses(inputs, rows) + const municipalityIDs = docs.map((doc) => doc.id) + const { leadershipIDsByMunicipality, summariesById: leadershipNamesById } = + await loadMunicipalityLeadershipSummaries(payload, user, municipalityIDs) + + for (let index = 0; index < docs.length; index += 1) { + const leadershipIDs = leadershipIDsByMunicipality.get(docs[index]!.id) + if (leadershipIDs) inputs[index]!.leadershipIDs = leadershipIDs + } + + const rows = attachTerritorialClasses(inputs, computeTerritoryRollup(inputs)) + + const usedStateDeputyIDs = new Set(inputs.flatMap((input) => input.stateDeputyIDs)) + const stateDeputyById = new Map() + if (usedStateDeputyIDs.size > 0) { + for (const option of await loadStateDeputyOptions(payload, user)) { + if (!usedStateDeputyIDs.has(option.id)) continue + stateDeputyById.set(option.id, { + id: option.id, + plainName: option.plainName, + party: option.party ?? null, + href: `/campanha/dobradinhas/${option.id}`, + }) + } + } + + const usedAdvisorIDs = [...new Set(inputs.flatMap((input) => input.advisorIDs))] + const advisorSummaries = await loadAdvisorSummaries(payload, user, usedAdvisorIDs) + const advisorNamesById = new Map(advisorSummaries.map((advisor) => [advisor.id, advisor])) + + return { + rows, + references: { + advisorNamesById, + leadershipNamesById, + stateDeputyById, + }, + } }, ) diff --git a/src/utilities/territory/territoryListLabels.ts b/src/utilities/territory/territoryListLabels.ts index 9f805b04..b751ad6a 100644 --- a/src/utilities/territory/territoryListLabels.ts +++ b/src/utilities/territory/territoryListLabels.ts @@ -4,13 +4,10 @@ import type { TerritoryListSortKey } from '@/utilities/territory/territoryListUr /** B22 — header tooltips for `/campanha/territorios` (E12 columns included). */ export const territoryColumnDescriptions: Partial> = { region: 'Território de Identidade da Bahia — unidade de coordenação regional.', - municipalities: 'Quantidade de municípios (ou zonas de Salvador) no território.', - votes2022: 'Soma dos votos do Jorge Solla para deputado federal em 2022 no território.', - pct: 'Quanto da votação própria do candidato na Bahia em 2022 veio deste território.', - validVotes2022: - 'Soma dos votos válidos de deputado federal em 2022 nos municípios do território.', + votes2022: + 'Porcentagem da votação própria do candidato na Bahia em 2022 e o nº de votos do território. Válidos de 2022 no detalhe da célula.', estimate2026: - 'Soma das estimativas de votos da mesa (cenário média) nos municípios do território.', + 'Soma das estimativas de votos da mesa (cenário central no número; faixa de cenários no detalhe da célula).', coverage: 'Quantos municípios têm pelo menos um assessor atribuído.', cobertura: `${campaignConceptOneLiner('cobertura-da-meta')} Soma metas e compromissos dos municípios do território (cenário média).`, captura: `${campaignConceptOneLiner('captura')} No território, usa a soma dos votos próprios ÷ soma dos tetos — nunca a média das capturas por município. Passe o mouse na célula para mediana, amplitude e município crítico.`, diff --git a/src/utilities/territory/territoryListUrl.ts b/src/utilities/territory/territoryListUrl.ts index 61aa5e8b..2e4c938d 100644 --- a/src/utilities/territory/territoryListUrl.ts +++ b/src/utilities/territory/territoryListUrl.ts @@ -45,13 +45,13 @@ const DEFAULT_TERRITORY_LIST_SORT_DIR: TerritoryListSortDirection = 'desc' export const territoryListSortLabels: Record = { region: 'Território', municipalities: 'Municípios', - votes2022: 'Votos 2022', + votes2022: '2022', pct: '% da própria votação', validVotes2022: 'Válidos 2022', - estimate2026: 'Estimativa 2026 (média)', + estimate2026: '2026', coverage: 'Assessoria', - cobertura: 'Cobertura da meta', - captura: 'Captura (2022)', + cobertura: 'Cobertura', + captura: 'Captura', classe: 'Classe', } diff --git a/src/utilities/territory/territoryOverview.ts b/src/utilities/territory/territoryOverview.ts index 7a19cbaa..b1befe17 100644 --- a/src/utilities/territory/territoryOverview.ts +++ b/src/utilities/territory/territoryOverview.ts @@ -21,6 +21,11 @@ import { METROPOLITANO_SALVADOR_SUB_ROW_LABEL, } from '@/lib/metropolitanoTerritoryPeers' import { territorialClassSortWeight } from '@/lib/territorialClassSortWeight' +import { + VOTE_ESTIMATE_SCENARIOS, + zeroByVoteEstimateScenario, + type VoteEstimateScenario, +} from '@/lib/voteEstimate' import { normalizeSearchPhrase } from '@/lib/wordStartFilter' import type { MunicipalityGoalCoverage } from '@/utilities/municipality/goalCoverage' import { aggregateGoalCoverage } from '@/utilities/municipality/goalCoverage' @@ -60,8 +65,13 @@ export type TerritoryMunicipalityInput = { kind: 'municipio' | 'zona' votesByYear: Record validVotesByYear: Record - estimate2026: number - advisorCount: number + /** Staff totals per scenario (estimated[S] ?? pledge aggregate for S), summed per TI. */ + estimateByScenario: Record + /** Any municipality in the TI carries an estimate or a declared pledge — drives "—" vs value. */ + hasEstimate: boolean + advisorIDs: number[] + leadershipIDs: number[] + stateDeputyIDs: number[] ownVotes2022: number fieldCeiling2022: number goalCoverage: MunicipalityGoalCoverage @@ -72,8 +82,12 @@ type TerritoryOverviewSubRow = { municipalityCount: number votesByYear: Record validVotes2022: number - estimate2026: number + estimateByScenario: Record + hasEstimate: boolean withAdvisorCount: number + advisorIDs: number[] + leadershipIDs: number[] + stateDeputyIDs: number[] pctPropriaVotacao: number } & TerritoryE12Rollup & { territorialClass?: MunicipalityTerritorialClassification | null @@ -85,8 +99,12 @@ export type TerritoryOverviewRow = { votesByYear: Record validVotes2022: number pctPropriaVotacao: number - estimate2026: number + estimateByScenario: Record + hasEstimate: boolean withAdvisorCount: number + advisorIDs: number[] + leadershipIDs: number[] + stateDeputyIDs: number[] subRows?: TerritoryOverviewSubRow[] } & TerritoryE12Rollup & { territorialClass?: MunicipalityTerritorialClassification | null @@ -115,8 +133,18 @@ type Accumulator = { municipalityCount: number votesByYear: Record validVotes2022: number - estimate2026: number + estimateByScenario: Record + hasEstimate: boolean withAdvisorCount: number + advisorIDs: number[] + leadershipIDs: number[] + stateDeputyIDs: number[] +} + +/** Unique-append of relation ids — sets per TI are a handful of entries. */ +const pushUniqueIds = (target: number[], ids: readonly number[]): number[] => { + for (const id of ids) if (!target.includes(id)) target.push(id) + return target } /** @@ -189,8 +217,12 @@ const createAccumulator = (region: string): Accumulator => ({ municipalityCount: 0, votesByYear: {}, validVotes2022: 0, - estimate2026: 0, + estimateByScenario: zeroByVoteEstimateScenario(), + hasEstimate: false, withAdvisorCount: 0, + advisorIDs: [], + leadershipIDs: [], + stateDeputyIDs: [], }) const accumulateInto = (acc: Accumulator, input: TerritoryMunicipalityInput): void => { @@ -199,8 +231,14 @@ const accumulateInto = (acc: Accumulator, input: TerritoryMunicipalityInput): vo acc.votesByYear[year] = (acc.votesByYear[year] ?? 0) + votes } acc.validVotes2022 += input.validVotesByYear[ELECTION_YEAR_2022] ?? 0 - acc.estimate2026 += input.estimate2026 - if (input.advisorCount > 0) acc.withAdvisorCount += 1 + for (const scenario of VOTE_ESTIMATE_SCENARIOS) { + acc.estimateByScenario[scenario] += input.estimateByScenario[scenario] + } + acc.hasEstimate = acc.hasEstimate || input.hasEstimate + if (input.advisorIDs.length > 0) acc.withAdvisorCount += 1 + pushUniqueIds(acc.advisorIDs, input.advisorIDs) + pushUniqueIds(acc.leadershipIDs, input.leadershipIDs) + pushUniqueIds(acc.stateDeputyIDs, input.stateDeputyIDs) } const pctOf = (acc: Accumulator, stateTotal2022: number): number => @@ -216,8 +254,12 @@ const finalizeRow = ( votesByYear: { ...acc.votesByYear }, validVotes2022: acc.validVotes2022, pctPropriaVotacao: pctOf(acc, stateTotal2022), - estimate2026: acc.estimate2026, + estimateByScenario: { ...acc.estimateByScenario }, + hasEstimate: acc.hasEstimate, withAdvisorCount: acc.withAdvisorCount, + advisorIDs: [...acc.advisorIDs], + leadershipIDs: [...acc.leadershipIDs], + stateDeputyIDs: [...acc.stateDeputyIDs], ...e12, }) @@ -231,8 +273,12 @@ const finalizeSubRow = ( municipalityCount: acc.municipalityCount, votesByYear: { ...acc.votesByYear }, validVotes2022: acc.validVotes2022, - estimate2026: acc.estimate2026, + estimateByScenario: { ...acc.estimateByScenario }, + hasEstimate: acc.hasEstimate, withAdvisorCount: acc.withAdvisorCount, + advisorIDs: [...acc.advisorIDs], + leadershipIDs: [...acc.leadershipIDs], + stateDeputyIDs: [...acc.stateDeputyIDs], pctPropriaVotacao: pctOf(acc, stateTotal2022), ...e12, }) @@ -323,7 +369,7 @@ const rowSortValue = (row: TerritoryOverviewRow, key: TerritorySortKey): number case 'validVotes2022': return row.validVotes2022 case 'estimate2026': - return row.estimate2026 + return row.estimateByScenario.central case 'coverage': return row.municipalityCount > 0 ? row.withAdvisorCount / row.municipalityCount : 0 case 'cobertura': diff --git a/tests/e2e/campaignTerritories.e2e.spec.ts b/tests/e2e/campaignTerritories.e2e.spec.ts index 129aa278..8a55cf44 100644 --- a/tests/e2e/campaignTerritories.e2e.spec.ts +++ b/tests/e2e/campaignTerritories.e2e.spec.ts @@ -18,7 +18,11 @@ test.describe('Territórios de Identidade', () => { await expect(page).toHaveURL(/\/campanha\/territorios$/) await expect(campaignPageChrome(page, 'Territórios de Identidade')).toBeVisible() - await page.getByRole('link', { name: /Ordenar por Votos 2022/ }).click() + // B175 — the count rides the name ("Irecê (N)") and the header is short ("2022"). + await expect(page.getByRole('link', { name: /Irecê \(\d+\)/ }).first()).toBeVisible() + await expect(page.getByRole('columnheader', { name: '2022' })).toBeVisible() + + await page.getByRole('link', { name: /Ordenar por 2022/ }).click() await expect(page).toHaveURL(/\/campanha\/territorios\?sort=votes2022/) await page.getByRole('button', { name: 'Filtrar por Território' }).click() @@ -26,7 +30,10 @@ test.describe('Territórios de Identidade', () => { await expect(page).toHaveURL(/region=Irec%C3%AA/) await expect(page.getByText('1 território encontrado')).toBeVisible() - await page.getByRole('link', { name: 'Irecê', exact: true }).first().click() + await page + .getByRole('link', { name: /Irecê \(\d+\)/ }) + .first() + .click() await expect(page).toHaveURL(/\/campanha\/municipios\?region=Irec%C3%AA/) }) @@ -48,6 +55,41 @@ test.describe('Territórios de Identidade', () => { await expect(page.locator('#ti-velho-chico')).toBeVisible() }) + test('a wide panel surfaces the read-only network columns; Cobertura stays hidden by default', async ({ + campaign, + page, + }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator', { + name: fixtures.value('Coordenadora Rede Larga'), + }) + const password = coordinator.password + const email = coordinator.email! + + await page.setViewportSize({ width: 2200, height: 1000 }) + await campaign.login(page, email, password) + await page.goto('/campanha/territorios') + + // B175 — the responsive rungs are all visible at a wide panel (P0 + Captura + // + network + Classe + Assessoria). Cobertura is P3 AND hidden in the picker. + // Sortable headers are announced "Ordenar por