Add comparative Identity Territory table to campaign dashboard (E17)
First regional surface in /campanha: a sortable 27-TI overview table on the Início staff dashboard, next to the municipal map. Per-TI aggregates only (sums and ratios of aggregates — MAUP safeguard); Metropolitano decomposed into Salvador (19 zones) vs. demais RMS. Loader is server-only with overrideAccess:true (regional context, not management; TI-level exposure only); pure rollup stays client-safe for the sortable table. First slice of E12, which extends with capture/LQ/meta coverage.
This commit is contained in:
parent
fe90cb7450
commit
b6e5d8be57
9 changed files with 650 additions and 6 deletions
|
|
@ -7,6 +7,7 @@ alwaysApply: false
|
|||
|
||||
## Status
|
||||
|
||||
- **E17 entregue em código (2026-07-24):** tabela comparativa dos 27 Territórios de Identidade no Início staff (`/campanha`) — `src/utilities/territoryOverview.ts` (rollup puro, client-safe: `computeTerritoryRollup` + `sortTerritoryRows`) + `src/utilities/loadTerritoryOverview.ts` (loader server-only, `overrideAccess: true` — leitura regional completa para advisor é contexto, não gestão; exposição só agregados TI-level) + `src/components/campaign/TerritoryOverviewTable.tsx` (tabela shadcn densa, ordenação client-side default `% da própria votação desc`, Metropolitano decomposto em Salvador 19 zonas × Demais RMS, linha→`/campanha/municipios?region=<TI>`). Somas/razões apenas (salvaguarda MAUP); sem migration/collection. Unit `territoryOverview.unit.spec.ts` (10 testes); tsc/lint/knip/unit 266/int 345/build verde; Aikido 0 findings. **Débito:** A11 ausente → share calculado localmente no rollup; A11 unifica quando entrar. Plano: [`tabela-ti-inicio.md`](../../../docs/plans/tabela-ti-inicio.md). Primeira fatia de E12 (estende com captura/LQ/cobertura de meta por TI).
|
||||
- **B15 registrado (2026-07-24):** ordenar `/campanha/municipios` pelo header da coluna — contrato URL `?sort=`/`?dir=`, clique no header (desktop) + select mobile; A11 consome a key `votos`. Plano: [`ordenacao-colunas-lista-municipios.md`](../../../docs/plans/ordenacao-colunas-lista-municipios.md).
|
||||
- **Fill-in registrado (2026-07-24):** seletor **Cenário** de `/campanha/municipios` → fileira de `MunicipalityFilters` (provider envolve filtros+resultados; overview sem seletor). Plano: [`cenario-junto-filtros-municipios.md`](../../../docs/plans/cenario-junto-filtros-municipios.md).
|
||||
- **E4R entregue em código + seed em produção (2026-07-24):** `pnpm db:seed:projecao` (`scripts/import-projecao.mjs` + `src/lib/projectionSheetParse.ts`) importa MAPA GERAL canônico → `expectedVotes` (Bom→otimista, Regular→média, Mínimo→pessimista) + `priority` com **overwrite-always**; Salvador pulado; zero PII. No mesmo dia o grupo duplicado `voteGoals` foi removido do app (ver Decisões). Seed local e **produção Neon** (2026-07-24): arquivo canônico `docs/sheets/Mapa projeção de votos Solla 2026.xlsx` (+ referência `Mapa_projecao_votos_Solla_2026.xlsx` só A×B), **189** municípios atualizados, **50** `priority=alta`, Salvador pulado, re-dry-run pós-apply **delta=0**. Plano: [`import-planilha-projecao.md`](../../../docs/plans/import-planilha-projecao.md).
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
# E17 — Tabela comparativa dos Territórios de Identidade no Início
|
||||
|
||||
Status: rascunho (pedido direto do candidato — overview regional ao lado do mapa do Início)
|
||||
Status: entregue em código (2026-07-24)
|
||||
Atualizado em: 2026-07-24
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Demais itens abertos, E17; primeira fatia da camada TI — E12 estende)
|
||||
Impeccable: B — painel novo no dashboard staff existente (`/campanha`), sem rota nova
|
||||
Appetite: ~1 dia eng; sem migration, sem collection
|
||||
Responsável: —
|
||||
|
||||
### Revisão 2026-07-24 (auditoria → implementação)
|
||||
- **A11 ausente confirmado:** `municipalityVoteRank.ts` não existe no repo → `% da própria votação` calculado localmente em `computeTerritoryRollup` (Σ TI ÷ Σ estadual 2022). A11 unifica o helper de share quando entrar.
|
||||
- **Access:** loader `loadTerritoryOverview` usa `overrideAccess: true` (a `canReadMunicipality` do advisor escopa por `advisors contains user.id`) — exposição é agregado TI-level (somas/contagens), nunca PII por município; advisor vê a tabela completa (leitura regional é contexto). Confirmado contra o plano.
|
||||
- **Split client/server:** o rollup puro ficou em `territoryOverview.ts` (client-safe, sem `server-only`) para a tabela client importar `sortTerritoryRows`/tipos; o loader server-only foi para `loadTerritoryOverview.ts`.
|
||||
- **Entrega:** `territoryOverview.unit.spec.ts` (10 testes), tsc/lint/knip/unit 266/int 345/build verde, Aikido 0 findings.
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípios 2 e 6) / `DESIGN.md` (register `product`; "Priority lift" para painéis de overview) · dashboard staff existente (`MunicipalityMapPanel`, `CampaignMetricStrip`).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-24 (B15 ordenação por coluna na lista; fill-in Cenário junto aos filtros — [plano](plans/cenario-junto-filtros-municipios.md); E4R ✓; B14; janela 1: smoke, A11/E17, B15, R6, E8)
|
||||
Atualizado em: 2026-07-24 (B15 ordenação por coluna na lista; fill-in Cenário junto aos filtros — [plano](plans/cenario-junto-filtros-municipios.md); E4R ✓; E17 ✓ tabela TI no Início; B14; janela 1: smoke, A11, B15, R6, E8)
|
||||
Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
|
||||
|
||||
## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026)
|
||||
|
|
@ -45,6 +45,7 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md](
|
|||
- **Admin Payload (2026-07-23)** — export CSV de assinaturas e contatos ([plano](plans/exportar-csv-assinaturas.md)).
|
||||
- **Hardening de engenharia (2026-07-23, Fases 0–6)** — access lockdown (PII/CMS admin-only + gates de rota do leader), tooling gate (knip no CI, `--max-warnings=0`, ban `as never`), artefato TSE commitado + cache (`bahiaElectionAggregates`, `election-tse`, `loadMunicipalityScope`), pending honesto + Suspense streaming, state scoping, splits (`src/utilities/access/*`, `supporterImport.ts`, shells DRY). Tracker: [IMPROVE-CODE-QUALITY-PLAN.md](IMPROVE-CODE-QUALITY-PLAN.md) · ledger: [TECH-DEBT.md](TECH-DEBT.md) · mapa de testes: [TESTING.md](TESTING.md).
|
||||
- **E4R (2026-07-24)** — import único da planilha de projeção → `municipality.expectedVotes` (Bom→otimista, Regular→média, Mínimo→pessimista) + `priority` (`pnpm db:seed:projecao`, always-overwrite, dry-run + runbook; Salvador pulado; zero PII). No mesmo dia o grupo duplicado `voteGoals` ("Meta Bom/Regular/Mínimo") foi **removido do app** (migration `20260724_133600` com backfill metas→estimativas) — a única série por cenário é `expectedVotes`. Plano: [import-planilha-projecao.md](plans/import-planilha-projecao.md). Seed local verificado (189 estimativas / 50 alta); produção após smoke.
|
||||
- **E17 (2026-07-24)** — tabela comparativa dos 27 Territórios de Identidade no Início staff (`/campanha`): `territoryOverview.ts` (rollup puro client-safe: `computeTerritoryRollup` + `sortTerritoryRows`) + `loadTerritoryOverview.ts` (loader server-only, `overrideAccess: true`) + `TerritoryOverviewTable.tsx` (tabela densa, ordenação client-side default `% da própria votação desc`, Metropolitano decomposto em Salvador 19 zonas × Demais RMS, linha→`/campanha/municipios?region=<TI>`). Somas/razões apenas (salvaguarda MAUP); sem migration/collection. Primeira fatia de E12. Plano: [tabela-ti-inicio.md](plans/tabela-ti-inicio.md).
|
||||
|
||||
## Próximos — Campanha (`/campanha`)
|
||||
|
||||
|
|
@ -70,7 +71,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
|
|||
|
||||
- ~~**E4R** import único da planilha de projeção~~ — **entregue 2026-07-24** (`pnpm db:seed:projecao`; overwrite-always; [plano](plans/import-planilha-projecao.md))
|
||||
- **A11** posição em votos do município (rank absoluto + % da própria votação) + ordenação da lista por votação · a lente de prioridade atual da mesa (sessão 2026-07-23) · ~0,5–1d · Janela 1–2 · reusa contrato `sort`/`dir` de **B15**; E9 absorve como coluna/ordenação da fila · [plano](plans/ranking-votos-municipio.md)
|
||||
- **E17** tabela comparativa dos Territórios de Identidade no Início (pedido do candidato) · somas/razões apenas, Metropolitano decomposto (salvaguardas MAUP) · ~1d · Janela 1–2 · primeira fatia de E12 · [plano](plans/tabela-ti-inicio.md)
|
||||
- ~~**E17** tabela comparativa dos Territórios de Identidade no Início (pedido do candidato) · somas/razões apenas, Metropolitano decomposto (salvaguardas MAUP) · ~1d · Janela 1–2 · primeira fatia de E12 · [plano](plans/tabela-ti-inicio.md)~~ — **entregue 2026-07-24** (`territoryOverview.ts` + `loadTerritoryOverview.ts` + `TerritoryOverviewTable.tsx`; rollup puro client-safe, loader `overrideAccess: true`, Metropolitano decomposto, ordenação client-side; sem migration; A11 ausente → share local)
|
||||
- **A6** dobradinha 2026 automática quando o TSE publicar candidaturas · gatilho externo: pós-15/08 · camada de insight sobre o registro operacional `stateDeputy` (M4) · alimenta **E13** · [plano](plans/insight-dobradinha-2026.md)
|
||||
- **B5 F2–F3** cache CLI compartilhado + factory mun/TI (scripts continuam) · [plano](plans/escala-dry-pos-b2.md)
|
||||
- **B8 F2** polígonos dos Municípios-zona de Salvador (ZE 1–19; Camaçari saiu do escopo na M1 — município inteiro): F1 catálogo zona→bairros entregue (hoje Salvador-only); falta prep do catálogo (~½ dia) + dissolve IBGE/malha → TopoJSON no mapa · Janela 3 · cortável · [plano](plans/poligonos-pracas-zona.md)
|
||||
|
|
@ -118,7 +119,7 @@ flowchart TD
|
|||
B15n["B15 Ordenar lista<br/>por coluna"]
|
||||
E4R["E4R ✓ Import planilha<br/>(seed estratégia)"]
|
||||
A11n["A11 Posição em votos"]
|
||||
E17n["E17 Tabela TI no Início"]
|
||||
E17n["E17 ✓ Tabela TI no Início"]
|
||||
|
||||
subgraph Intel["Inteligência de campanha"]
|
||||
E8i["E8 Conta da cadeira"]
|
||||
|
|
@ -158,11 +159,11 @@ flowchart TD
|
|||
B8F2 -.ZE Salvador.-> B14n
|
||||
```
|
||||
|
||||
Paralelizáveis agora: **B15** (sort por coluna na lista; barato, desbloqueia UX de A11), **A11/E17** (semana de onboarding; E4R ✓ seedou o quadro; A11 soft-dep B15), **E8** (A10 ✓ e remodelagem em produção), **C12** (paralelo a E8), **E16** (compõe o existente), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), fill-ins (Cenário junto aos filtros, O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12) não absorver os deltas do ZAP (não compete com E8/E9).
|
||||
Paralelizáveis agora: **B15** (sort por coluna na lista; barato, desbloqueia UX de A11), **A11** (semana de onboarding; E4R ✓ seedou o quadro; A11 soft-dep B15), **E8** (A10 ✓ e remodelagem em produção), **C12** (paralelo a E8), **E16** (compõe o existente), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), fill-ins (Cenário junto aos filtros, O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12) não absorver os deltas do ZAP (não compete com E8/E9).
|
||||
|
||||
### Sequência por janela (só pendentes)
|
||||
|
||||
**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **B15** (sort por coluna) + **A11/E17** na semana de onboarding; gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E8** pode começar em paralelo; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa).
|
||||
**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E17 ✓** tabela TI no Início; **B15** (sort por coluna) + **A11** na semana de onboarding; gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E8** pode começar em paralelo; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa).
|
||||
|
||||
**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; **E8** conta da cadeira → **E9** fila de alocação, com **C12** registro-fundação em paralelo (migrations cedo, longe do congelamento); **E16** dossiê do município (pedido O6 — compõe o existente, melhora com E8); D2 se sobrar folga.
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { CampaignDashboard } from '@/components/campaign/CampaignDashboard'
|
|||
import { LeaderContactsPanel } from '@/components/campaign/LeaderContactsPanel'
|
||||
import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/MunicipalityEstimateScenarioContext'
|
||||
import { MunicipalityMapPanelDynamic } from '@/components/campaign/MunicipalityMapPanelDynamic'
|
||||
import { TerritoryOverviewTable } from '@/components/campaign/TerritoryOverviewTable'
|
||||
import { campaignPrioritySurfaceClassName } from '@/components/campaign/CampaignPageShell'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { isCampaignLeader, isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
|
|
@ -14,6 +16,7 @@ import { getCampaignDashboardData } from '@/utilities/campaignDashboardData'
|
|||
import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions'
|
||||
import { loadLeaderContactsPageData } from '@/utilities/leaderContactsPageData'
|
||||
import { loadMunicipalityMapBundle } from '@/utilities/municipalityMapData'
|
||||
import { loadTerritoryOverview } from '@/utilities/loadTerritoryOverview'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
|
|
@ -63,6 +66,20 @@ export default async function CampaignHomePage({ searchParams }: CampaignHomePag
|
|||
</Suspense>
|
||||
) : null
|
||||
}
|
||||
territorySlot={
|
||||
isStaff ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="h-[280px] w-full animate-pulse rounded-xl border bg-muted/40"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TerritoryOverviewSection payload={payload} />
|
||||
</Suspense>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -88,3 +105,18 @@ const DashboardMapSection = async ({
|
|||
</MunicipalityEstimateScenarioProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const TerritoryOverviewSection = async ({ payload }: { payload: Payload }) => {
|
||||
const rows = await loadTerritoryOverview(payload)
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="territory-overview-title"
|
||||
className={`rounded-xl border p-4 ${campaignPrioritySurfaceClassName}`}
|
||||
>
|
||||
<h2 id="territory-overview-title" className="mb-3 text-base font-medium">
|
||||
Territórios de Identidade
|
||||
</h2>
|
||||
<TerritoryOverviewTable rows={rows} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,11 +27,14 @@ export const CampaignDashboard = ({
|
|||
view,
|
||||
userName,
|
||||
mapSlot = null,
|
||||
territorySlot = null,
|
||||
}: {
|
||||
view: StaffDashboardView
|
||||
userName: string
|
||||
/** Server-streamed map section (composition keeps this component map-agnostic). */
|
||||
mapSlot?: ReactNode
|
||||
/** Server-streamed Territórios de Identidade comparative table (E17). */
|
||||
territorySlot?: ReactNode
|
||||
}) => (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-1">
|
||||
|
|
@ -73,6 +76,8 @@ export const CampaignDashboard = ({
|
|||
/>
|
||||
</section>
|
||||
|
||||
{territorySlot}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<section
|
||||
aria-labelledby="dashboard-missing-title"
|
||||
|
|
|
|||
203
src/components/campaign/TerritoryOverviewTable.tsx
Normal file
203
src/components/campaign/TerritoryOverviewTable.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronDownIcon, ChevronUpIcon, ChevronsUpDownIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/Table'
|
||||
import { formatElectionNumber } from '@/lib/electionInsights'
|
||||
import {
|
||||
sortTerritoryRows,
|
||||
type TerritoryOverviewRow,
|
||||
type TerritorySortDir,
|
||||
type TerritorySortKey,
|
||||
} from '@/utilities/territoryOverview'
|
||||
|
||||
const percentFormatter = new Intl.NumberFormat('pt-BR', {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
|
||||
const formatPercent = (ratio: number): string => `${percentFormatter.format(ratio * 100)}%`
|
||||
|
||||
type ColumnDef = {
|
||||
key: TerritorySortKey
|
||||
label: string
|
||||
align: 'left' | 'right'
|
||||
}
|
||||
|
||||
const COLUMNS: ColumnDef[] = [
|
||||
{ key: 'region', label: 'Território', align: 'left' },
|
||||
{ key: 'municipalities', label: 'Municípios', align: 'right' },
|
||||
{ key: 'votes2022', label: 'Votos 2022', align: 'right' },
|
||||
{ key: 'pct', label: '% da votação', align: 'right' },
|
||||
{ key: 'validVotes2022', label: 'Válidos 2022', align: 'right' },
|
||||
{ key: 'estimate2026', label: 'Estimativa 2026', align: 'right' },
|
||||
{ key: 'coverage', label: 'Com assessor', align: 'right' },
|
||||
]
|
||||
|
||||
type TerritoryOverviewTableProps = {
|
||||
rows: TerritoryOverviewRow[]
|
||||
}
|
||||
|
||||
export const TerritoryOverviewTable = ({ rows }: TerritoryOverviewTableProps) => {
|
||||
const [sortKey, setSortKey] = useState<TerritorySortKey>('pct')
|
||||
const [sortDir, setSortDir] = useState<TerritorySortDir>('desc')
|
||||
|
||||
const sortedRows = useMemo(
|
||||
() => sortTerritoryRows(rows, sortKey, sortDir),
|
||||
[rows, sortKey, sortDir],
|
||||
)
|
||||
|
||||
const toggleSort = (key: TerritorySortKey) => {
|
||||
if (key === sortKey) {
|
||||
setSortDir((dir) => (dir === 'asc' ? 'desc' : 'asc'))
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortDir(key === 'region' ? 'asc' : 'desc')
|
||||
}
|
||||
}
|
||||
|
||||
const sortIcon = (key: TerritorySortKey) => {
|
||||
if (key !== sortKey) return <ChevronsUpDownIcon className="text-muted-foreground/50" aria-hidden="true" />
|
||||
return sortDir === 'asc' ? (
|
||||
<ChevronUpIcon aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDownIcon aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{COLUMNS.map((column) => {
|
||||
const isActive = column.key === sortKey
|
||||
return (
|
||||
<TableHead
|
||||
key={column.key}
|
||||
aria-sort={isActive ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||
className={column.align === 'right' ? 'text-right' : 'text-left'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort(column.key)}
|
||||
className={`inline-flex items-center gap-1 text-xs font-medium uppercase tracking-wide text-muted-foreground hover:text-foreground ${column.align === 'right' ? 'flex-row-reverse' : ''}`}
|
||||
>
|
||||
{sortIcon(column.key)}
|
||||
{column.label}
|
||||
</button>
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedRows.map((row) => (
|
||||
<TerritoryRow key={row.region} row={row} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leitura regional; alocação decide-se por município. % da votação = votos do TI ÷ votos estaduais (2022).
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TerritoryRow = ({ row }: { row: TerritoryOverviewRow }) => (
|
||||
<>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/campanha/municipios?region=${encodeURIComponent(row.region)}`}
|
||||
className="font-medium text-foreground hover:underline"
|
||||
>
|
||||
{row.region}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{row.municipalityCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
<YearSeriesCell votesByYear={row.votesByYear} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums font-medium">
|
||||
{formatPercent(row.pctPropriaVotacao)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{formatElectionNumber(row.validVotes2022)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatElectionNumber(row.estimate2026)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<CoverageCell withAdvisorCount={row.withAdvisorCount} total={row.municipalityCount} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{row.subRows?.map((subRow) => (
|
||||
<TableRow key={`${row.region}-${subRow.label}`} className="bg-muted/30">
|
||||
<TableCell className="pl-6 text-muted-foreground">{subRow.label}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{subRow.municipalityCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
<YearSeriesCell votesByYear={subRow.votesByYear} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{formatPercent(subRow.pctPropriaVotacao)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{formatElectionNumber(subRow.validVotes2022)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{formatElectionNumber(subRow.estimate2026)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<CoverageCell withAdvisorCount={subRow.withAdvisorCount} total={subRow.municipalityCount} muted />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
const YearSeriesCell = ({ votesByYear }: { votesByYear: Record<string, number> }) => {
|
||||
const votes2022 = votesByYear['2022'] ?? 0
|
||||
const prior = [
|
||||
{ year: '2014', value: votesByYear['2014'] ?? 0 },
|
||||
{ year: '2018', value: votesByYear['2018'] ?? 0 },
|
||||
]
|
||||
return (
|
||||
<details className="group inline-flex flex-row-reverse items-center gap-1">
|
||||
<summary className="cursor-pointer list-none tabular-nums" aria-label="Série 2014 e 2018">
|
||||
{formatElectionNumber(votes2022)}
|
||||
</summary>
|
||||
<span className="hidden text-xs text-muted-foreground group-open:inline">
|
||||
{prior.map((p) => `${p.year}: ${formatElectionNumber(p.value)}`).join(' · ')}
|
||||
</span>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
const CoverageCell = ({
|
||||
withAdvisorCount,
|
||||
total,
|
||||
muted = false,
|
||||
}: {
|
||||
withAdvisorCount: number
|
||||
total: number
|
||||
muted?: boolean
|
||||
}) => {
|
||||
if (total === 0) return <span className="text-muted-foreground">—</span>
|
||||
if (withAdvisorCount === 0) {
|
||||
return <Badge variant="estimate-pending">0 de {total}</Badge>
|
||||
}
|
||||
return (
|
||||
<span className={muted ? 'text-muted-foreground tabular-nums' : 'tabular-nums'}>
|
||||
{withAdvisorCount} de {total}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
88
src/utilities/loadTerritoryOverview.ts
Normal file
88
src/utilities/loadTerritoryOverview.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { cache } from 'react'
|
||||
|
||||
import { getMunicipalityFederalBaseline } from '@/lib/bahiaElectionAggregates'
|
||||
import type { Municipality } from '@/payload-types'
|
||||
import {
|
||||
aggregatePledgesByMunicipality,
|
||||
emptyMunicipalityPledgeAggregate,
|
||||
resolveMunicipalityStaffVoteTotal,
|
||||
} from '@/utilities/votePledgeData'
|
||||
import {
|
||||
computeTerritoryRollup,
|
||||
type TerritoryMunicipalityInput,
|
||||
type TerritoryOverviewRow,
|
||||
} from '@/utilities/territoryOverview'
|
||||
import {
|
||||
DEFAULT_VOTE_ESTIMATE_SCENARIO,
|
||||
type VoteEstimateScenario,
|
||||
} from '@/utilities/voteEstimate'
|
||||
|
||||
type MunicipalityDoc = Pick<
|
||||
Municipality,
|
||||
'id' | 'slug' | 'name' | 'city' | 'region' | 'kind' | 'advisors' | 'expectedVotes'
|
||||
>
|
||||
|
||||
/**
|
||||
* E17 — Loads the full 27-TI overview (all 435 municipalities, `overrideAccess: true`).
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
export const loadTerritoryOverview = cache(
|
||||
async (
|
||||
payload: Payload,
|
||||
scenario: VoteEstimateScenario = DEFAULT_VOTE_ESTIMATE_SCENARIO,
|
||||
): Promise<TerritoryOverviewRow[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'municipality',
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
city: true,
|
||||
region: true,
|
||||
kind: true,
|
||||
advisors: true,
|
||||
expectedVotes: true,
|
||||
},
|
||||
})
|
||||
|
||||
const docs = result.docs as MunicipalityDoc[]
|
||||
const aggregates = await aggregatePledgesByMunicipality(
|
||||
payload,
|
||||
docs.map((doc) => doc.id),
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
return {
|
||||
slug: doc.slug,
|
||||
name: doc.name,
|
||||
city: doc.city,
|
||||
region: doc.region,
|
||||
kind: doc.kind,
|
||||
votesByYear: { ...baseline.votesByYear },
|
||||
validVotesByYear: { ...baseline.validVotesByYear },
|
||||
estimate2026: estimate,
|
||||
advisorCount: doc.advisors?.length ?? 0,
|
||||
}
|
||||
})
|
||||
|
||||
return computeTerritoryRollup(inputs)
|
||||
},
|
||||
)
|
||||
206
src/utilities/territoryOverview.ts
Normal file
206
src/utilities/territoryOverview.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
/**
|
||||
* E17 — Territórios de Identidade comparative rollup for the staff Início.
|
||||
*
|
||||
* First slice of the TI layer (E12 extends). Only sums and ratios of aggregates
|
||||
* — never a mean of ratios (MAUP safeguard). The Metropolitano de Salvador TI
|
||||
* is always decomposed into two sub-rows (Salvador = 19 zones, Demais RMS),
|
||||
* the permanent exception from the discovery report (24,4% of the electorate).
|
||||
*
|
||||
* This module is **client-safe** (no `server-only`, no Payload/artifact imports):
|
||||
* `computeTerritoryRollup` / `sortTerritoryRows` are pure and unit-tested with a
|
||||
* fixture. The server loader lives in `loadTerritoryOverview.ts`.
|
||||
*/
|
||||
|
||||
const SALVADOR_CITY = 'Salvador'
|
||||
const SALVADOR_SUB_LABEL = 'Salvador (19 zonas)'
|
||||
const RMS_DEMAIS_SUB_LABEL = 'Demais municípios da RMS'
|
||||
const METROPOLITANO_REGION = 'Metropolitano de Salvador'
|
||||
const ELECTION_YEAR_2022 = '2022'
|
||||
|
||||
export type TerritoryMunicipalityInput = {
|
||||
slug: string
|
||||
name: string
|
||||
city: string
|
||||
region: string
|
||||
kind: 'municipio' | 'zona'
|
||||
votesByYear: Record<string, number>
|
||||
validVotesByYear: Record<string, number>
|
||||
estimate2026: number
|
||||
advisorCount: number
|
||||
}
|
||||
|
||||
export type TerritoryOverviewSubRow = {
|
||||
label: string
|
||||
municipalityCount: number
|
||||
votesByYear: Record<string, number>
|
||||
validVotes2022: number
|
||||
estimate2026: number
|
||||
withAdvisorCount: number
|
||||
pctPropriaVotacao: number
|
||||
}
|
||||
|
||||
export type TerritoryOverviewRow = {
|
||||
region: string
|
||||
municipalityCount: number
|
||||
votesByYear: Record<string, number>
|
||||
validVotes2022: number
|
||||
pctPropriaVotacao: number
|
||||
estimate2026: number
|
||||
withAdvisorCount: number
|
||||
subRows?: TerritoryOverviewSubRow[]
|
||||
}
|
||||
|
||||
export type TerritorySortKey =
|
||||
| 'region'
|
||||
| 'municipalities'
|
||||
| 'votes2022'
|
||||
| 'pct'
|
||||
| 'validVotes2022'
|
||||
| 'estimate2026'
|
||||
| 'coverage'
|
||||
|
||||
export type TerritorySortDir = 'asc' | 'desc'
|
||||
|
||||
type Accumulator = {
|
||||
region: string
|
||||
municipalityCount: number
|
||||
votesByYear: Record<string, number>
|
||||
validVotes2022: number
|
||||
estimate2026: number
|
||||
withAdvisorCount: number
|
||||
}
|
||||
|
||||
const createAccumulator = (region: string): Accumulator => ({
|
||||
region,
|
||||
municipalityCount: 0,
|
||||
votesByYear: {},
|
||||
validVotes2022: 0,
|
||||
estimate2026: 0,
|
||||
withAdvisorCount: 0,
|
||||
})
|
||||
|
||||
const accumulateInto = (acc: Accumulator, input: TerritoryMunicipalityInput): void => {
|
||||
acc.municipalityCount += 1
|
||||
for (const [year, votes] of Object.entries(input.votesByYear)) {
|
||||
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
|
||||
}
|
||||
|
||||
const pctOf = (acc: Accumulator, stateTotal2022: number): number =>
|
||||
stateTotal2022 > 0 ? (acc.votesByYear[ELECTION_YEAR_2022] ?? 0) / stateTotal2022 : 0
|
||||
|
||||
const finalizeRow = (acc: Accumulator, stateTotal2022: number): TerritoryOverviewRow => ({
|
||||
region: acc.region,
|
||||
municipalityCount: acc.municipalityCount,
|
||||
votesByYear: { ...acc.votesByYear },
|
||||
validVotes2022: acc.validVotes2022,
|
||||
pctPropriaVotacao: pctOf(acc, stateTotal2022),
|
||||
estimate2026: acc.estimate2026,
|
||||
withAdvisorCount: acc.withAdvisorCount,
|
||||
})
|
||||
|
||||
const finalizeSubRow = (
|
||||
label: string,
|
||||
acc: Accumulator,
|
||||
stateTotal2022: number,
|
||||
): TerritoryOverviewSubRow => ({
|
||||
label,
|
||||
municipalityCount: acc.municipalityCount,
|
||||
votesByYear: { ...acc.votesByYear },
|
||||
validVotes2022: acc.validVotes2022,
|
||||
estimate2026: acc.estimate2026,
|
||||
withAdvisorCount: acc.withAdvisorCount,
|
||||
pctPropriaVotacao: pctOf(acc, stateTotal2022),
|
||||
})
|
||||
|
||||
/**
|
||||
* Computes the 27-TI comparative rollup. Rows are returned in first-seen order
|
||||
* (catalog order when fed from the loader); the Metropolitano de Salvador row
|
||||
* carries two sub-rows (Salvador 19 zones × Demais RMS). Use `sortTerritoryRows`
|
||||
* for display order.
|
||||
*/
|
||||
export const computeTerritoryRollup = (
|
||||
inputs: ReadonlyArray<TerritoryMunicipalityInput>,
|
||||
): TerritoryOverviewRow[] => {
|
||||
const stateTotal2022 = inputs.reduce(
|
||||
(sum, input) => sum + (input.votesByYear[ELECTION_YEAR_2022] ?? 0),
|
||||
0,
|
||||
)
|
||||
|
||||
const byRegion = new Map<string, Accumulator>()
|
||||
const metropolitanoSalvador = createAccumulator(METROPOLITANO_REGION)
|
||||
const metropolitanoDemais = createAccumulator(METROPOLITANO_REGION)
|
||||
|
||||
for (const input of inputs) {
|
||||
// Metropolitano inputs are decomposed into the Salvador/Demais sub-rows
|
||||
// below AND rolled into the parent TI total — this is intentional, not a
|
||||
// double-count: the parent row sums all of Metropolitano, the sub-rows
|
||||
// break it down.
|
||||
if (input.region === METROPOLITANO_REGION) {
|
||||
if (input.city === SALVADOR_CITY) {
|
||||
accumulateInto(metropolitanoSalvador, input)
|
||||
} else {
|
||||
accumulateInto(metropolitanoDemais, input)
|
||||
}
|
||||
}
|
||||
const acc = byRegion.get(input.region) ?? createAccumulator(input.region)
|
||||
accumulateInto(acc, input)
|
||||
byRegion.set(input.region, acc)
|
||||
}
|
||||
|
||||
const rows: TerritoryOverviewRow[] = []
|
||||
for (const acc of byRegion.values()) {
|
||||
const row = finalizeRow(acc, stateTotal2022)
|
||||
if (acc.region === METROPOLITANO_REGION) {
|
||||
row.subRows = [
|
||||
finalizeSubRow(SALVADOR_SUB_LABEL, metropolitanoSalvador, stateTotal2022),
|
||||
finalizeSubRow(RMS_DEMAIS_SUB_LABEL, metropolitanoDemais, stateTotal2022),
|
||||
]
|
||||
}
|
||||
rows.push(row)
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
const rowSortValue = (row: TerritoryOverviewRow, key: TerritorySortKey): number | string => {
|
||||
switch (key) {
|
||||
case 'region':
|
||||
return row.region
|
||||
case 'municipalities':
|
||||
return row.municipalityCount
|
||||
case 'votes2022':
|
||||
return row.votesByYear[ELECTION_YEAR_2022] ?? 0
|
||||
case 'pct':
|
||||
return row.pctPropriaVotacao
|
||||
case 'validVotes2022':
|
||||
return row.validVotes2022
|
||||
case 'estimate2026':
|
||||
return row.estimate2026
|
||||
case 'coverage':
|
||||
return row.municipalityCount > 0 ? row.withAdvisorCount / row.municipalityCount : 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts the 27 top-level rows. Metropolitano sub-rows stay attached to their
|
||||
* parent (never re-sorted independently). Returns a new array.
|
||||
*/
|
||||
export const sortTerritoryRows = (
|
||||
rows: ReadonlyArray<TerritoryOverviewRow>,
|
||||
key: TerritorySortKey,
|
||||
dir: TerritorySortDir = 'desc',
|
||||
): TerritoryOverviewRow[] => {
|
||||
const factor = dir === 'asc' ? 1 : -1
|
||||
return [...rows].sort((left, right) => {
|
||||
const a = rowSortValue(left, key)
|
||||
const b = rowSortValue(right, key)
|
||||
if (typeof a === 'string' || typeof b === 'string') {
|
||||
return String(a).localeCompare(String(b), 'pt-BR') * factor
|
||||
}
|
||||
return (a - b) * factor
|
||||
})
|
||||
}
|
||||
102
tests/unit/territoryOverview.unit.spec.ts
Normal file
102
tests/unit/territoryOverview.unit.spec.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
computeTerritoryRollup,
|
||||
sortTerritoryRows,
|
||||
type TerritoryMunicipalityInput,
|
||||
} from '@/utilities/territoryOverview'
|
||||
|
||||
const input = (overrides: Partial<TerritoryMunicipalityInput> & Pick<TerritoryMunicipalityInput, 'slug' | 'region' | 'city'>): TerritoryMunicipalityInput => ({
|
||||
name: overrides.slug,
|
||||
kind: 'municipio',
|
||||
votesByYear: { 2022: 0 },
|
||||
validVotesByYear: { 2022: 0 },
|
||||
estimate2026: 0,
|
||||
advisorCount: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const fixture: TerritoryMunicipalityInput[] = [
|
||||
// Irecê (TI 01) — 2 municípios, 1 com assessor
|
||||
input({ slug: 'ibipeba', region: 'Irecê', city: 'Ibipeba', votesByYear: { 2014: 10, 2018: 20, 2022: 30 }, validVotesByYear: { 2022: 1000 }, estimate2026: 40, advisorCount: 1 }),
|
||||
input({ slug: 'irece', region: 'Irecê', city: 'Irecê', votesByYear: { 2014: 5, 2018: 15, 2022: 25 }, validVotesByYear: { 2022: 2000 }, estimate2026: 35, advisorCount: 0 }),
|
||||
// Velho Chico (TI 02) — 1 município, com assessor
|
||||
input({ slug: 'barra', region: 'Velho Chico', city: 'Barra', votesByYear: { 2014: 100, 2018: 110, 2022: 120 }, validVotesByYear: { 2022: 5000 }, estimate2026: 150, advisorCount: 2 }),
|
||||
// Metropolitano de Salvador (TI 26) — Salvador (1 zona) + Camaçari (demais)
|
||||
input({ slug: 'salvador-ze-1', region: 'Metropolitano de Salvador', city: 'Salvador', kind: 'zona', votesByYear: { 2014: 1000, 2018: 1100, 2022: 1200 }, validVotesByYear: { 2022: 40000 }, estimate2026: 1300, advisorCount: 1 }),
|
||||
input({ slug: 'camacari', region: 'Metropolitano de Salvador', city: 'Camaçari', votesByYear: { 2014: 200, 2018: 210, 2022: 220 }, validVotesByYear: { 2022: 8000 }, estimate2026: 250, advisorCount: 0 }),
|
||||
]
|
||||
|
||||
describe('computeTerritoryRollup', () => {
|
||||
const rows = computeTerritoryRollup(fixture)
|
||||
|
||||
it('returns one row per TI (3 regions)', () => {
|
||||
expect(rows.map((row) => row.region).sort()).toEqual(
|
||||
['Irecê', 'Metropolitano de Salvador', 'Velho Chico'].sort(),
|
||||
)
|
||||
})
|
||||
|
||||
it('sums votes per year and valid votes 2022', () => {
|
||||
const irece = rows.find((row) => row.region === 'Irecê')!
|
||||
expect(irece.votesByYear).toEqual({ 2014: 15, 2018: 35, 2022: 55 })
|
||||
expect(irece.validVotes2022).toBe(3000)
|
||||
expect(irece.municipalityCount).toBe(2)
|
||||
})
|
||||
|
||||
it('sums 2026 estimates', () => {
|
||||
const irece = rows.find((row) => row.region === 'Irecê')!
|
||||
expect(irece.estimate2026).toBe(75)
|
||||
})
|
||||
|
||||
it('counts municipalities with advisor', () => {
|
||||
const irece = rows.find((row) => row.region === 'Irecê')!
|
||||
expect(irece.withAdvisorCount).toBe(1)
|
||||
})
|
||||
|
||||
it('computes % da própria votação as fraction of state total', () => {
|
||||
const stateTotal2022 = 30 + 25 + 120 + 1200 + 220 // 1395
|
||||
const velhoChico = rows.find((row) => row.region === 'Velho Chico')!
|
||||
expect(velhoChico.pctPropriaVotacao).toBeCloseTo(120 / stateTotal2022, 6)
|
||||
})
|
||||
|
||||
it('decomposes Metropolitano into Salvador + Demais RMS sub-rows', () => {
|
||||
const metro = rows.find((row) => row.region === 'Metropolitano de Salvador')!
|
||||
expect(metro.subRows).toHaveLength(2)
|
||||
const salvador = metro.subRows![0]
|
||||
const demais = metro.subRows![1]
|
||||
expect(salvador.label).toBe('Salvador (19 zonas)')
|
||||
expect(demais.label).toBe('Demais municípios da RMS')
|
||||
expect(salvador.municipalityCount).toBe(1)
|
||||
expect(demais.municipalityCount).toBe(1)
|
||||
// Sub-rows sum to the Metropolitano total.
|
||||
expect(salvador.votesByYear[2022] + demais.votesByYear[2022]).toBe(metro.votesByYear[2022])
|
||||
expect(salvador.estimate2026 + demais.estimate2026).toBe(metro.estimate2026)
|
||||
})
|
||||
|
||||
it('returns zero % when state total is zero', () => {
|
||||
const empty = computeTerritoryRollup([input({ slug: 'x', region: 'Irecê', city: 'X' })])
|
||||
expect(empty[0].pctPropriaVotacao).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortTerritoryRows', () => {
|
||||
const rows = computeTerritoryRollup(fixture)
|
||||
|
||||
it('sorts by % da própria votação desc by default', () => {
|
||||
const sorted = sortTerritoryRows(rows, 'pct')
|
||||
expect(sorted[0].region).toBe('Metropolitano de Salvador')
|
||||
})
|
||||
|
||||
it('keeps Metropolitano sub-rows attached to the parent', () => {
|
||||
const sorted = sortTerritoryRows(rows, 'municipalities', 'asc')
|
||||
const metro = sorted.find((row) => row.region === 'Metropolitano de Salvador')!
|
||||
expect(metro.subRows).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('sorts ascending when dir=asc', () => {
|
||||
const sorted = sortTerritoryRows(rows, 'votes2022', 'asc')
|
||||
expect(sorted[0].votesByYear[2022] ?? 0).toBeLessThanOrEqual(
|
||||
sorted[sorted.length - 1].votesByYear[2022] ?? 0,
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue