Merge branch 'cursor/3e72ba50' — E17 tabela TI no Início

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-24 17:17:48 -03:00
commit 6523824b3a
9 changed files with 650 additions and 6 deletions

View file

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

View file

@ -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`).

View file

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

View file

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

View file

@ -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"

View 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>
)
}

View 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)
},
)

View 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
})
}

View 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,
)
})
})