E12: camada TI com rollups MAUP e benchmark intra-TI.

Estende territórios com cobertura/captura/classe agregadas e compara captura do município aos pares do mesmo TI no card Conta da cadeira.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 21:26:47 -03:00
parent ba90a3b2dc
commit f06e573f15
21 changed files with 879 additions and 111 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **E12 entregue em código (2026-07-26):** camada TI — rollups MAUP + benchmark intra-TI. Estende `territoryOverview.ts` (`computeTerritoryE12Rollup`: cobertura agregada E8 cenário `central`, captura regional como razão dos agregados, mediana/amplitude, município crítico e farol) e `loadTerritoryOverview.ts` (passa `user`, `loadMunicipalityGoalCoverageBundle`, `computeAggregateTerritorialClass` por TI e sub-linhas do Metropolitano). `/campanha/territorios`: colunas Cobertura/Captura/Classe, sorts e descrições B22 em `territoryListUrl.ts` / `territoryListLabels.ts` / `TerritoryListColumns.tsx`. Detalhe: `territoryIntraCaptureBenchmark.ts` + linha T4 no `MunicipalityGoalAccountCard`. Conceitos E18 `captura-regional` e `benchmark-intra-ti`. Cortes v1: sem agrupamento na lista de municípios, sem mapa TI. `sanityCheckSuggestedGoalsByTerritory` segue sem UI. Gate: tsc/lint/format/check:cycles/533 unit/406 int/build (com `PAYLOAD_SECRET` local); knip P3 pré-existente em `payload.config.ts`; Aikido 0 findings. Plano: [`camada-territorios-identidade.md`](../../../docs/plans/camada-territorios-identidade.md).
- **B36 entregue em código (2026-07-26):** coluna "Lideranças" em `/campanha/dobradinhas` — chips nomeados (link `/campanha/liderancas/[id]`) + Popover+Command + auto-save por delta, reusando **sem alteração** a mutação/schema/lock do **B31 ✓** (`setLeadershipStateDeputyMembership`, `leadership-state-deputies:{id}`, `nextStateDeputyIdsAfterMembership`). **Auditoria do plano** (escrito antes do B31 as-built) corrigiu duas defasagens: (1) sem `RelationOptionsProvider`/Context — B31 já tinha trocado Context por columns factory no `/simplify`; (2) casca `dobradinhas/formActions.ts` própria (não import cross-rota de `liderancas/formActions`). **Extração paga aqui:** `LeadershipStateDeputiesCell` (domínio) → `shared/LeadershipStateDeputyRelationCell` bidirecional (`direction: 'fromLeadership' | 'fromStateDeputy'`, `measureOverflow` default true — clamp 3 linhas + "Ver mais…" no lado do deputado; `liderancas` passa `false`). `loadLeadershipOptions`; `StateDeputyRowViewModel.leadershipCount` → `leaderships: {id,name}[]` na mesma query batch. Gate: tsc/lint/format/knip/cycles/unit 530 + int (flakes de timeout re-rodados verdes)/build; Aikido 0 findings. Plano: [`chips-liderancas-lista-dobradinhas.md`](../../../docs/plans/chips-liderancas-lista-dobradinhas.md).
- **B30 entregue em código (2026-07-26):** convite de completar cadastro por WhatsApp na lista `/campanha/liderancas` — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` existente (B28 ✓), ao lado do `MessageCircleIcon` de contato; Popover two-step (`Gerar convite` → Alert com âncora `wa.me` + copiar link) reusa `createCampaignInvite`/`kind: 'autopreenchimento'`; disable alinhado ao `whatsAppHrefForPhone` da mesma célula (`hasValidPhone`); sem migration/action/collection nova. Plano revisado pós-B28 (encaixe em `actions`, não coluna `inviteAction`). Gate: tsc/lint/format/cycles; 532 unit (+2) / 406 int; build não rodou sem `PAYLOAD_SECRET`; knip P3 pré-existente; Aikido 0 findings. Plano: [`convite-whatsapp-lista-liderancas.md`](../../../docs/plans/convite-whatsapp-lista-liderancas.md).
- **B31 entregue em código (2026-07-26):** coluna "Dobradinhas" em `/campanha/liderancas` — chips clicáveis (`StateDeputyChips`, link para `/campanha/dobradinhas/<slug>`) com edição por Popover+Command (adicionar/remover sem "Salvar"), gravação **por delta** com lock (`leadership-state-deputies:{id}`), nunca replace do array inteiro (que `updateLeadershipInternalRecord` continua fazendo, inalterado, para o form completo da ficha). **Auditoria do plano antes de codar** achou 5 defasagens (ordem de colunas mudou com o B28 ✓; B27 ✓ já tinha entregue a mecânica Popover+Command+chips, então B31 é o 2º call site, não o 1º; o precedente de UI correto é duplo — mecânica de `MunicipalityListAdvisorsControl` (B27) + escrita por server-action-direta-do-cliente de `AdvisorMunicipalityCell`/`setAdvisorMunicipalityMembership` (B19), nenhum dos dois sozinho; `MAX_LEADERSHIP_STATE_DEPUTIES` era privado; `loadLeadershipDetail` fazia uma 2ª chamada redundante a `loadStateDeputySummaries`), nenhuma delas mudou a abordagem central do plano. **Fase 1:** `LeadershipRowViewModel.stateDeputyNames: string[]` → `stateDeputies: StateDeputySummary[]` (via `loadStateDeputySummaries`, já existente); `loadLeadershipDetail` parou de duplicar a chamada — deriva `stateDeputyIDs` de `row.stateDeputies`; `MAX_LEADERSHIP_STATE_DEPUTIES` exportado; novo `leadershipStateDeputyMembershipSchema`; novo helper puro `nextStateDeputyIdsAfterMembership` (`src/lib/leadershipStateDeputyMembership.ts`, espelha `nextAdvisorIdsAfterMembership` linha a linha); `loadStateDeputyOptions` ganhou `plainName`/`party`/`slug` no retorno (`StateDeputyRelationOption`) para a célula montar um chip otimista completo sem 2ª busca. **Fase 2:** `setLeadershipStateDeputyMembershipRecord`/`setLeadershipStateDeputyMembership` em `actions/leadership.ts` (transação + `acquireTextAdvisoryLocks` + `revalidatePath` em `/campanha/liderancas`, `/campanha/liderancas/[id]`, `/campanha/dobradinhas` e `/campanha/dobradinhas/<slug>` do deputado tocado); novo `liderancas/formActions.ts` (a rota da lista não tinha um ainda — só `nova/` e `[id]/` tinham). **Fase 3 (Impeccable craft→critique→polish):** catálogo uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory) + `LeadershipStateDeputiesCell` (chips removíveis + `Command`/`matchesAtWordStart`, `useTransition` chamando a form action diretamente — não `fetch` —, reversão otimista + `toast.error` em falha, `aria-live` de status); coluna nova entre Organizações e Acesso ao app. Sem "Ver mais…" (teto de schema 20, uso típico 0–3); sem filtro/sort por dobradinha (gatilho no B29, ainda não entregue). **`/simplify` (1ª rodada):** corrigido revert otimista por delta (não pelo snapshot inteiro — um toggle falho não apaga mais um toggle anterior já salvo), removido fallback silencioso na adição de chip, extraído `sameIdSet` compartilhado com `MunicipalityListAdvisorsControl` (B27) para `src/lib/sameIdSet.ts`, e o caminho no-op da action parou de pagar uma leitura + revalidação supérfluas. **`/simplify` (2ª rodada, pós-rebase):** o catálogo tinha nascido como `StateDeputyOptionsProvider` (Context) — trocado pela columns factory acima ao confirmar que `MunicipalityList.tsx`/`municipalityListColumns` já resolve o mesmo problema sem Context (1ª rodada tinha validado o Context por falta dessa evidência); `revalidatePath('/campanha/liderancas/[id]', 'page')` trocado pelo id concreto (o template dinâmico invalidava toda ficha de liderança a cada toggle); non-null assertion em `optionById.get(...)!` movida para dentro do `setCurrent` updater. Adiado (documentado no plano): passar o `slug` do deputado pelo form em vez do `findByID` extra na action — fica para o 3º call site. Gate verde (tsc, lint --max-warnings=0, format:check, knip — P3 pré-existente, unit 530/530, int 406/406, build); Aikido 0 achados. Plano: [`dobradinhas-lista-liderancas.md`](../../../docs/plans/dobradinhas-lista-liderancas.md).

View file

@ -70,6 +70,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
**Recently resolved (2026-07-25): Engineering Consolidation Pass 2** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2; architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); agent map: `.cursor/rules/codebase-map.mdc`). Highlights: **W1 sistema de listas** — colunas como dado em `CampaignTable` (seams B17/B18), URL state compartilhado (`campaignListUrl` + módulos por domínio; o god module `municipalityUi.ts` virou `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`), shells (`CampaignListFooter`, `CampaignListEmptyState`), 8 superfícies migradas (municípios, lideranças, organizações, dobradinhas, demandas, apoiadores, assessores, atividades-shells), B16+ absorvido (`useOptimistic`, hrefs por serializador canônico, facet por slugs, facets no mesmo `Promise.all`); **W2** — fronteiras: `slug`/`phone`/`wordStartFilter`/`voteEstimate` movidos para `lib/`, 21 loaders `server-only`, `campaignRoles.ts` client-safe (sidebar sem o barrel de access), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em 15 subpastas de domínio; decisão D1: sem `src/domains/`, sem ports-and-adapters; **W4a** — `electionInsights.ts` (941 linhas, 2 exports vivos) deletado; sobreviventes em `electionFormat.ts`/`voteTrend.ts`; E10 nasce limpo; **W4b** — knip `exports`/`types`/`enumMembers` em **ERROR** no CI (0 findings; kit ui sem peças mortas); **W4c** — tabs das páginas de detalhe extraídas (`MunicipalityDetailTabs`, `ActivityOverviewTab`) + factory `detailTabUi`; **W4d** — `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções documentadas); **W4e** — tipos de fonte única; **W4f** — copy "Praça"→"Município" varrida (apoiadores desambiguado: cidade do contato = "Cidade"); **D3** — consent do WhatsApp por chave estável (`whatsapp-inscricao`, fail-closed). Suites: 46 unit / 48 int / 7 e2e.
**Recently resolved (2026-07-26):** **E12** Camada TI — extends the E17/B21 rollup (`territoryOverview.ts` + `loadTerritoryOverview.ts`) with E8 goal coverage (`central`), MAUP-safe regional capture (aggregate ratio Σ own votes ÷ Σ field ceiling — never the mean of municipal ratios; median, min–max, named critical municipality and beacon), and aggregated territorial class via `computeAggregateTerritorialClass` (same rule as B13 for TI groups). Staff surface: three new columns on `/campanha/territorios` (Cobertura, Captura, Classe) with B22 header descriptions and sorts `cobertura`/`captura`/`classe`; municipality detail gets intra-TI benchmark T4 on `MunicipalityGoalAccountCard` (`territoryIntraCaptureBenchmark.ts` — Salvador zone slugs compare among the 19 zones, other Metropolitano peers among RMS municipalities). Two E18 concepts (`captura-regional`, `benchmark-intra-ti`). v1 cuts kept: no `?group=territorio` on the municipality list, no TI map mode. No migration. Plano: [`docs/plans/camada-territorios-identidade.md`](docs/plans/camada-territorios-identidade.md).
**Recently resolved (2026-07-26):** **B24** Auto-save + justificativa no popover de Tendência — `MunicipalityListTrendControl` grava sozinho via `POST /campanha/municipios/political-trend` (status ~150 ms, nota 600 ms, flush blur/fechamento), `Textarea` de justificativa no Popover, sem `revalidatePath` da lista; `/editar` mantém submit explícito. Plano: [`docs/plans/autosave-tendencia-lista-municipios.md`](docs/plans/autosave-tendencia-lista-municipios.md).
**Recently resolved (2026-07-26):** **B14** Município mais próximo (atalho geo no Início staff) — a new client island `NearestMunicipalityCard` ("Onde estou") answers "open the município I am standing in", and the answer is **point-in-polygon**, not a nearest-centroid guess. The source plan had rejected polygons as a bundle rabbit hole; the premise was false by the time it shipped, because `MunicipalityMapPanel` always mounts in `mode="municipality"` and `BahiaMap` calls `loadMunicipalityGeometryModule()` on mount, so the 417 polygons (132 KB raw, **41 KB gzip**, own memoized chunk) are already in the browser of anyone on `/campanha` — the card reuses that promise and adds no geometry. So **no centroid artifact, no build script, no migration, no collection, no server action and no `Consent` key**: the position is read by `navigator.geolocation` and matched in the browser, and never leaves the device. Pure math in `src/lib/municipalityProximity.ts` (haversine, ray casting with holes, area-weighted centroid, discriminated union `inScope | zoneCity | outOfScope | outsideBahia`; **measured, so nobody re-derives it:** the mesh decodes to 417 plain `Polygon`s with zero holes and zero MultiPolygons, a full containment scan costs **0.106 ms** and the whole fallback **0.32 ms** — 81% of it centroids — so there is no hot path here and a centroid artifact would still not pay for itself; building a fresh `Map` per call measured _slower_, and the module's existing `getMunicipalityFeature` index is passed in only because the map already built it); browser plumbing in `src/utilities/campaignGeolocation.ts` (sibling of `recentVisits.ts`: session flag `teqo:campaign:geo-prompted-session` so the automatic prompt fires at most once per tab, guarded permission read where Safari's missing descriptor legitimately answers `unknown`, typed failures). Centroid distance survives only to rank the fallback: an advisor standing outside his portfolio is told where he is **and** offered the closest município he can open, up to `NEAREST_IN_SCOPE_MAX_KM` = 150 km. Salvador still gets no guessed ZE (trigger stays **B8 F2**) — it links to the filtered list, with the zone count limited to what the actor can read. `StaffDashboardView` gained `accessibleMunicipalities` (~6.6 KB gzip of RSC payload for all 435), and the zone-list href reaches the island as a `zoneCityHrefs` prop serialized by `CampaignDashboard` (the RSC that already builds list hrefs) rather than by the Payload loader: the first version imported `buildMunicipalityListHref` into the island and cost **21 kB** of First Load JS for a single link, because the canonical serializer drags `bahiaTerritories` + `municipalityCatalog` in — the "client components must not import server data modules for values" rule applies to **URL serializers** too, so the href is serialized on the server. `/campanha` First Load JS: 265 → **268 kB** (measured against a stashed baseline, not against the number in a previous note). A coarse fix (accuracy > 10 km, typical of a desk) renders the result **with** an explicit "localização aproximada" caveat rather than silently naming the wrong município. The shortcuts row hides itself in **CSS** (`hidden … has-[>*]:grid`, written inline in `CampaignDashboard`): having the children report visibility upward cost two state slots, a callback prop, an effect and a layout shift on every load, because the server rendered the row hidden and the effect only unhid it after first paint — and with that state gone, the `DashboardShortcuts` wrapper was a named `div`, so it is gone too. `loadMunicipalityGeometryModule` memoizes the dynamic import's **rejection**, so the card catches it and falls into an actionable failure state — otherwise a ChunkLoadError left a spinner with a retry button that could never recover. Plano: [`docs/plans/municipio-mais-proximo.md`](docs/plans/municipio-mais-proximo.md).

View file

@ -1,15 +1,17 @@
# E12 — Camada Territórios de Identidade (rollups regionais com salvaguardas MAUP)
Status: rascunho
Atualizado em: 2026-07-24 (refs sincronizadas pós-remodelagem Municípios + hardening)
Status: entregue (2026-07-26)
Atualizado em: 2026-07-26 (entrega E12: colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no card Conta da cadeira, conceitos E18)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Inteligência de campanha", E12; plano-mestre [inteligencia-campanha.md](inteligencia-campanha.md))
Impeccable: B — agrupamento/rollup nas superfícies existentes (lista/overview, detalhe do município, mapa TI opcional); sem rota nova **própria** — desde 2026-07-25 a superfície regional dedicada é **B21** (`/campanha/territorios`, [plano](pagina-territorios-identidade.md)), onde as métricas deste item pousam
Impeccable: B — colunas em `/campanha/territorios` (B21) + benchmark intra-TI no `MunicipalityGoalAccountCard`; sem rota nova
Appetite: ~1,5 dia eng; sem migration (mapeamento município→TI é estático)
Responsável: —
**Revisão 2026-07-26:** O plano de 2026-07-24 previa `territoryRollup.ts` e agrupamento `?group=territorio` na lista de municípios. No código, E17/B21 já entregaram `computeTerritoryRollup` em `src/utilities/territoryOverview.ts` e a página `/campanha/territorios`. E12 **estende** esse rollup com métricas E8/E10 (cobertura, captura MAUP, classe via `computeAggregateTerritorialClass`). **Cortado na v1:** agrupamento na lista de municípios (B21 cobre a leitura comparativa); mapa TI (gatilho mantido). Benchmark no detalhe pousa no card **Conta da cadeira**, não no `MunicipalityBaselineCard`.
## Design (Impeccable)
Âncoras: `PRODUCT.md` (princípio 5) / `DESIGN.md` (register `product`) · `MunicipalityListOverview`, geometria `bahia-identity-territories.topo.json` (B2).
Âncoras: `PRODUCT.md` (princípio 5) / `DESIGN.md` (register `product`) · `TerritoryList` (B21), geometria `bahia-identity-territories.topo.json` (B2).
Na implementação: craft compacto → critique → polish.
@ -25,73 +27,75 @@ Relatório §6.5: TI é a camada default de coordenação (decisões sobre gente
## Objetivos
- **Rollup por TI** sobre o bundle existente: votos em jogo, meta/cobertura (Σ), captura regional (Σ nominais ÷ Σ teto — razão dos agregados), nº de municípios por classe/nível, município crítico (pior déficit/frescor) nomeado, mediana e amplitude da captura.
- **Agrupamento na lista:** modo "por Território" na lista de municípios (grupos colapsáveis com o rollup no header do grupo) — mesma URL/filtros.
- **Benchmark intra-TI (T4):** no detalhe do município, comparação com a mediana do TI ("captura 2,1× a mediana do seu território") + município-farol nomeado.
- **Rollup por TI** sobre o bundle existente: votos em jogo, meta/cobertura (Σ), captura regional (Σ nominais ÷ Σ teto — razão dos agregados), nº de municípios por classe (E10; E14 adiado), município crítico (pior déficit) nomeado, mediana e amplitude da captura.
- **Superfície:** colunas novas em `/campanha/territorios` (B21).
- **Benchmark intra-TI (T4):** no detalhe do município (`MunicipalityGoalAccountCard`), comparação com a mediana do TI + município-farol nomeado.
- **Metropolitano decomposto:** o grupo "Metropolitano de Salvador" sempre expande zonas SSA + municípios RMS; nunca agrega num número único sem breakdown visível.
- **Malha congelada:** constante de versão da composição TI usada no ciclo (já é estática em `bahiaTerritories.ts` — documentar como frozen 2026).
- Opcional barato (se couber no appetite): modo TI no mapa usando a geometria B2 (fills por rollup, mesmas escalas B13).
## Decisões travadas
- **TI como única camada regional do produto neste ciclo** (default operacional — relatório H4). **Rejeitado:** Regiões Imediatas IBGE como camada paralela (teste de sensibilidade fica manual/ad-hoc — G10 adiado); cluster empírico como camada operacional (instável e ilegível; vira exercício de auditoria única fora do produto).
- **Razão dos agregados, nunca média das razões** — regra de implementação com teste unit dedicado (divergência entre as duas contas exposta como alarme de heterogeneidade). **Rejeitado:** médias simples (a média que mente — §6.5).
- **i18n e naming:** `territoryRollup`, `computeTerritoryRollups`, `criticalMunicipality`, `medianCapture`; labels pt-BR ("Território", "Município crítico").
- **Classe do TI:** `computeAggregateTerritorialClass(slugs)` (B13) — nunca votação/média de classes por município.
- **i18n e naming:** `criticalMunicipality`, `medianCapture`; labels pt-BR ("Território", "Município crítico").
## Questões em aberto
## Questões em aberto (resolvidas na entrega)
- **Padrões T1–T3/T5 entram aqui ou no E11 fase 2?** Opções: avaliador TI neste item | tudo no motor. **Recomendação:** os rollups (insumo) aqui; os gatilhos T no E11 fase 2 — evita duplicar o mecanismo de sugestão.
- **Mapa TI na v1?** **Recomendação:** sim se sobrar ½ dia (geometria pronta, `BahiaMap` aceita features de TI); senão adiado com gatilho.
- **Padrões T1–T3/T5:** rollups aqui; gatilhos T no E11 fase 2.
- **Mapa TI na v1:** adiado (gatilho: primeiro giro regional com mapa municipal insuficiente).
- **Agrupamento na lista de municípios:** adiado (B21 cobre comparação dos 27 TIs).
## Abordagem proposta
## Abordagem (as-built)
```mermaid
flowchart LR
Bundle["loadMunicipalityListPageBundle"]
Terr["bahiaTerritories.ts<br/>(município→TI, frozen 2026)"]
Roll["computeTerritoryRollups<br/>(Σ, mediana, amplitude, crítica)"]
List["MunicipalityList agrupada + headers de grupo"]
Detail["benchmark intra-TI no detalhe"]
Map["(opcional) fills TI no BahiaMap"]
Bundle --> Roll
Terr --> Roll
Roll --> List
Roll --> Detail
Roll --> Map
Loader["loadTerritoryOverview"]
Terr["territoryOverview.ts<br/>computeTerritoryRollup"]
Page["/campanha/territorios"]
Detail["MunicipalityGoalAccountCard"]
Loader --> Terr
Terr --> Page
Loader --> Detail
```
Componentes:
- **`src/utilities/territoryRollup.ts`**: rollup puro sobre as linhas do bundle + derivados E8; salvaguardas como invariantes testadas (razão de agregados, Metropolitano flagged).
- **`MunicipalityList.tsx`**: modo de agrupamento (param `?group=territorio`) com headers de grupo; `MunicipalityListOverview` ganha corte por TI quando agrupado.
- **Detalhe do município:** linha de benchmark no card de baseline (reusa `MunicipalityBaselineCard`).
- **Mapa (opcional):** `bahiaTerritoryGeometries.ts`/`getTerritoryFeature` já existem; modo TI colore os 27 polígonos pelo rollup.
- **`src/utilities/territoryOverview.ts`**: rollup puro (E17) estendido com cobertura, captura MAUP, município crítico; Metropolitano com sub-rows.
- **`src/utilities/loadTerritoryOverview.ts`**: E8 bundle + classe agregada por TI.
- **`TerritoryListColumns.tsx`**: colunas Cobertura, Captura, Classe.
- **Detalhe:** benchmark intra-TI no `MunicipalityGoalAccountCard`.
- **Sem migration.**
## Dependências
- Dura: **E8** (captura/meta/cobertura por município para agregar). Suaves: E9 (agrupamento compõe com as colunas da fila), E14 (nível no rollup), B13 (escalas do mapa TI).
- Reusa: `bahiaTerritories.ts`, `bahiaTerritoryGeometries.ts`/`bahiaGeometries.ts` (B2), bundle A9+.
- Dura: **E8** ✓. Suaves: E17 ✓, B21 ✓, E10 ✓, B13 ✓. E14 não entregue (rollup por nível adiado).
## Não escopo
- Gatilhos T1–T5 como sugestões (E11 fase 2); malha Regiões Imediatas (G10 — adiado no plano-mestre); carteiras de assessor como entidade (continua `municipality.advisors`); qualquer edição em nível TI.
- Gatilhos T1–T5 como sugestões (E11 fase 2); malha Regiões Imediatas (G10); carteiras de assessor como entidade; edição em nível TI; `?group=territorio` na lista de municípios (v1); mapa TI (v1).
## Rabbit holes
- **Rollup virar segunda página de analytics.** É um agrupamento da lista + números no header — não um dashboard regional novo. _(Atualização 2026-07-25: a página regional existe como item próprio, **B21** — tabela comparativa com sort/filtro, sem cards de KPI. As métricas deste item entram como colunas lá; a proibição continua valendo contra transformá-la em dashboard.)_
- **Zonas de Salvador no rollup do Metropolitano.** As 19 Municípios-zona têm `region` = Metropolitano; agregar por soma funciona, mas o breakdown obrigatório precisa listar zonas E municípios RMS separados — não "resolver" juntando tudo.
- **Comparação entre TIs virar ranking público.** Mesmo risco de gaming do benchmark (T4-contraindicação): benchmark é para aprender mecanismo, não régua punitiva — copy da UI reflete isso.
- **Rollup virar segunda página de analytics.** Métricas entram como colunas em B21 — não dashboard.
- **Zonas de Salvador no rollup do Metropolitano.** Breakdown Salvador + Demais RMS obrigatório.
- **Comparação entre TIs virar ranking público.** Copy: benchmark é aprendizado, não punição.
## Adiado com gatilho
- **Mapa TI** (se não couber na v1). Gatilho: primeiro giro regional planejado com o mapa municipal se mostrando insuficiente.
- **Mapa TI.** Gatilho: primeiro giro regional planejado com o mapa municipal se mostrando insuficiente.
- **Agrupamento na lista de municípios.** Gatilho: mesa pedir leitura agrupada após adoção de B21.
- **Teste de sensibilidade TI×Imediatas.** Gatilho: decisão cara sustentada por leitura regional divergente (§6.5 salvaguarda 4).
## Revisão na entrega (2026-07-26)
- Estende `territoryOverview.ts` / `loadTerritoryOverview.ts` (E17/B21) com cobertura E8 (`central`), captura regional (razão Σ votos ÷ Σ teto, mediana, amplitude, município crítico e farol), classe agregada via `computeAggregateTerritorialClass`.
- UI: três colunas novas em `/campanha/territorios` + sort `cobertura`/`captura`/`classe` + descrições B22; benchmark T4 em `MunicipalityGoalAccountCard` via `territoryIntraCaptureBenchmark.ts` (pares Metropolitano = Salvador zonas × demais RMS).
- Dois conceitos E18 (`captura-regional`, `benchmark-intra-ti`). `sanityCheckSuggestedGoalsByTerritory` permanece sem superfície de UI (export/testada; gatilho E11/E13).
- Cortes mantidos: sem `?group=territorio`, sem mapa TI.
## Referências
- `docs/roadmap.md` (Inteligência de campanha, E12) · [plano-mestre](inteligencia-campanha.md)
- `docs/research/relatorio-entrevista-persona-campanha.md` §6.5 (veredito TI, análises, T1–T5, salvaguardas MAUP)
- `src/lib/bahiaTerritories.ts`, `src/lib/bahiaTerritoryGeometries.ts`, `src/lib/geometries/bahia-identity-territories.topo.json` (B2)
- `src/components/campaign/MunicipalityList.tsx`, `MunicipalityListOverview.tsx`, `MunicipalityBaselineCard.tsx`, `src/utilities/municipalityPageData.ts`
- AGENTS.md — B2 as-built, naming
- `docs/roadmap.md` (Inteligência de campanha, E12) · [plano-mestre](inteligencia-campanha.md) · [pagina-territorios-identidade.md](pagina-territorios-identidade.md) (B21)
- `docs/research/relatorio-entrevista-persona-campanha.md` §6.5
- `src/utilities/territoryOverview.ts`, `src/utilities/loadTerritoryOverview.ts`, `src/components/campaign/municipality/TerritoryListColumns.tsx`, `MunicipalityGoalAccountCard.tsx`

View file

@ -0,0 +1,48 @@
# Escala e DRY pós-E12 (camada TI)
Status: rascunho
Atualizado em: 2026-07-26
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, **E12+**, fill-in de engenharia)
Impeccable: A — N/A (higiene de loaders/utilities; sem superfície nova)
Appetite: ~0,25–0,5 dia eng; duas fases opcionais, nenhuma com migration
Responsável: —
## Dados → decisão → apresentação
- **Vou apresentar dados?** Não (N/A).
## Contexto
**E12** ([camada-territorios-identidade.md](camada-territorios-identidade.md), entregue 2026-07-26) estendeu E17/B21 com rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios` e benchmark intra-TI no card Conta da cadeira. O `/simplify` da entrega (duas rodadas, 2026-07-26) aplicou o cleanup barato (`medianOf`, `territorialClassSortWeight` em `lib/`, constantes Metropolitano exportadas de `territoryOverview.ts`) e deixou achados maiores que cleanup, registrados aqui.
**Já resolvido no simplify (não reabrir):** `medianOf` compartilhado (`src/lib/median.ts`) e usado no classificador E10; pesos de sort de classe em `src/lib/territorialClassSortWeight.ts`; `METROPOLITANO_REGION` / `SALVADOR_CITY` / `METROPOLITANO_SALVADOR_SUB_ROW_LABEL` exportados de `territoryOverview.ts` e consumidos por `loadTerritoryOverview` e `territoryIntraCaptureBenchmark`.
## Objetivos
- Um módulo client-safe para regras de peers/sub-linhas do Metropolitano (quando valer o custo de extrair).
- Evitar recomputar o mapa de captura por TI no detalhe do município se medição mostrar custo.
## Decisões travadas
- **F1 só extrai quando o 3º consumidor dedicado aparecer ou a regra de peers crescer.** Hoje são três imports de constantes + lógica espelhada em `territoryIntraCaptureBenchmark` — ainda cabe em `territoryOverview.ts`. **Rejeitado:** `lib/metropolitanoTerritoryPeers.ts` prematuro com uma função por arquivo (YAGNI).
- **F2 memo de peer-group só com evidência.** O benchmark T4 roda uma vez por request de detalhe; cache de processo só se profiling mostrar duplicação dentro da mesma árvore RSC.
## Abordagem proposta
- **F1 — `metropolitanoTerritoryPeers` (condicional).** Extrair helpers puros (`isSalvadorSubRow`, `peerSlugsForMunicipality`, labels) quando um quarto call site surgir (ex. mapa TI ou agrupamento na lista de municípios) ou quando testes de peers precisarem de fixture isolada.
- **F2 — memo opcional.** `cache()` ou mapa por request em `territoryIntraCaptureBenchmark` apenas se o loader do detalhe passar a chamar o benchmark mais de uma vez.
## Não escopo
- **Bundle E8 só `central` em `/campanha/territorios`** — adiado até cenário na URL ou perf medido no loader (`loadMunicipalityGoalCoverageBundle` hoje carrega os três cenários).
- **Célula compartilhada de classe territorial** entre lista de municípios e lista de TIs — cosmético (2 surfaces); extrair só com 3º call site.
- **UI de `sanityCheckSuggestedGoalsByTerritory`** — continua fora do E12 v1 (plano-pai).
## Explicitamente fora (triage close-delivery 2026-07-26)
| Achado | Destino | Gatilho / racional |
| --- | --- | --- |
| Módulo Metropolitano peers | defer → F1 | 4º call site ou regra de peers mais complexa |
| E8 bundle escopado a `central` | defer | `?scenario=` na lista de TIs ou perf |
| Célula classe compartilhada | descartar | score 2; 2 surfaces |
| Memo peer-group T4 | defer → F2 | profiling no detalhe |

File diff suppressed because one or more lines are too long

View file

@ -106,7 +106,7 @@ const GoalAccountCard = async ({
pledgeCoverage: MunicipalityPledgeCoverageView | null
pledgeAggregate: MunicipalityPledgeAggregate
}) => {
const { suggestedGoal, goalCoverage, potential, territorialClass } =
const { suggestedGoal, goalCoverage, potential, territorialClass, territoryCaptureBenchmark } =
await loadMunicipalityGoalAccount(payload, user, { slug, expectedVotes }, pledgeAggregate)
return (
@ -118,6 +118,7 @@ const GoalAccountCard = async ({
goalCoverage={goalCoverage}
potential={potential}
territorialClass={territorialClass}
territoryCaptureBenchmark={territoryCaptureBenchmark}
/>
)
}

View file

@ -30,7 +30,7 @@ export default async function TerritoriesPage({ searchParams }: TerritoriesPageP
if (!user) return null
if (isCampaignLeader(user)) redirect('/campanha')
const allRows = await loadTerritoryOverview(payload)
const allRows = await loadTerritoryOverview(payload, user)
const { state } = resolvedUrl
const { sort, dir } = resolveTerritoryListSort(state)
const rows = sortTerritoryRows(filterTerritoryRows(allRows, state), sort, dir)

View file

@ -31,6 +31,7 @@ import {
} from '@/utilities/municipalityLabels'
import type { MunicipalityPotential, RollOff } from '@/utilities/municipalityPotential'
import type { MunicipalityTerritorialClassification } from '@/utilities/municipalityTerritorialClass'
import type { MunicipalityIntraTerritoryCaptureBenchmark } from '@/utilities/territoryIntraCaptureBenchmark'
import type { MunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews'
/**
@ -186,6 +187,7 @@ export const MunicipalityGoalAccountCard = ({
goalCoverage,
potential,
territorialClass,
territoryCaptureBenchmark,
}: {
municipalityID: number
expectedVotes: VoteEstimateScenarioViewModel
@ -194,6 +196,7 @@ export const MunicipalityGoalAccountCard = ({
goalCoverage: MunicipalityGoalCoverage
potential: MunicipalityPotential
territorialClass: MunicipalityTerritorialClassification
territoryCaptureBenchmark: MunicipalityIntraTerritoryCaptureBenchmark
}) => {
const usesMesaEstimate = expectedVotes.central != null
const latestIntraFieldShareYear = Math.max(
@ -201,6 +204,20 @@ export const MunicipalityGoalAccountCard = ({
)
const latestIntraFieldShare = potential.intraFieldShareByYear[latestIntraFieldShareYear] ?? null
const benchmarkRatioLabel =
territoryCaptureBenchmark.ratioToMedian != null
? `${territoryCaptureBenchmark.ratioToMedian.toLocaleString('pt-BR', { maximumFractionDigits: 1 })}×`
: '—'
const benchmarkLead =
territoryCaptureBenchmark.medianCapture != null ? (
<>
Captura <strong>{benchmarkRatioLabel}</strong> a mediana do seu território (
{formatRatioAsPercentLabel(territoryCaptureBenchmark.medianCapture)}).
</>
) : (
<>Sem mediana de captura no território — faltam tetos de 2022 nos pares.</>
)
return (
<section
aria-labelledby="municipality-goal-account-title"
@ -310,6 +327,25 @@ export const MunicipalityGoalAccountCard = ({
/>
}
/>
</dl>
<p className="text-xs text-muted-foreground">
{benchmarkLead}
{territoryCaptureBenchmark.beacon ? (
<>
{' '}
Referência no território:{' '}
<Link
href={`/campanha/municipios/${territoryCaptureBenchmark.beacon.slug}`}
className="font-medium text-primary underline underline-offset-4"
>
{territoryCaptureBenchmark.beacon.name}
</Link>{' '}
({formatRatioAsPercentLabel(territoryCaptureBenchmark.beacon.captureRate)}). Leitura
para aprender mecanismo — não é nota.
</>
) : null}
</p>
<dl className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<GoalAccountMetric
label={`Share intracampo (${latestIntraFieldShareYear})`}
value={formatRatioAsPercentLabel(latestIntraFieldShare)}

View file

@ -1,9 +1,22 @@
import Link from 'next/link'
import { TerritorySortableHead } from '@/components/campaign/municipality/TerritorySortableHead'
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
import type { CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
import { Badge } from '@/components/ui/Badge'
import { formatElectionNumber } from '@/lib/electionFormat'
import {
formatGoalCoverageDeficitShortLabel,
formatGoalCoverageRatioLabel,
formatRatioAsPercentLabel,
} from '@/utilities/goalCoverage'
import {
formatTerritorialClassWhy,
territorialClassBadgeVariant,
territorialClassLabels,
} from '@/utilities/municipalityLabels'
import type { TerritoryFilterOption } from '@/utilities/territoryListFilters'
import { territoryColumnDescriptions } from '@/utilities/territoryListLabels'
import type { TerritoryListState } from '@/utilities/territoryListUrl'
import type { TerritoryTableRow } from '@/utilities/territoryOverview'
@ -55,6 +68,74 @@ const CoverageCell = ({
)
}
const TerritorialClassCell = ({ row }: { row: TerritoryTableRow }) => {
const territorialClass = row.territorialClass
if (!territorialClass) {
return <span className="text-muted-foreground">—</span>
}
const why = formatTerritorialClassWhy(territorialClass.factors)
if (territorialClass.class === 'sem_base') {
return (
<span className="text-muted-foreground">
<span aria-hidden="true">—</span>
<span className="sr-only">{why}</span>
</span>
)
}
return (
<>
<Badge variant={territorialClassBadgeVariant[territorialClass.class]}>
{territorialClassLabels[territorialClass.class]}
</Badge>
<span className="sr-only">{why}</span>
</>
)
}
const captureTooltipContent = (row: TerritoryTableRow) => {
const parts: string[] = []
if (row.medianCapture != null) {
parts.push(`Mediana no território: ${formatRatioAsPercentLabel(row.medianCapture)}`)
}
if (row.captureMin != null && row.captureMax != null) {
parts.push(
`Amplitude: ${formatRatioAsPercentLabel(row.captureMin)} – ${formatRatioAsPercentLabel(row.captureMax)}`,
)
}
if (row.criticalMunicipality && row.criticalMunicipality.deficit > 0) {
parts.push(
`Município crítico (maior déficit de meta): ${row.criticalMunicipality.name} (faltam ${formatElectionNumber(row.criticalMunicipality.deficit)} votos)`,
)
}
if (row.captureBeacon) {
parts.push(
`Referência de captura no território: ${row.captureBeacon.name} (${formatRatioAsPercentLabel(row.captureBeacon.captureRate)})`,
)
}
if (parts.length === 0) {
return 'Sem dados de captura 2022 neste recorte.'
}
return (
<div className="flex flex-col gap-1">
{parts.map((line) => (
<p key={line}>{line}</p>
))}
{row.criticalMunicipality && row.criticalMunicipality.deficit > 0 ? (
<Link
href={`/campanha/municipios/${row.criticalMunicipality.slug}`}
className="font-medium underline underline-offset-2"
>
Abrir município crítico
</Link>
) : null}
<p className="text-background/70">
A captura do território é a soma dos votos próprios ÷ soma dos tetos — não a média das
capturas por município.
</p>
</div>
)
}
export const territoryListColumns = ({
state,
regionOptions,
@ -72,6 +153,7 @@ export const territoryListColumns = ({
sortKey="region"
filterParam="region"
filterOptions={regionOptions}
description={territoryColumnDescriptions.region}
className="sticky left-0 z-20 min-w-56 bg-background"
/>
),
@ -92,21 +174,42 @@ export const territoryListColumns = ({
{
id: 'municipalities',
defaultVisible: true,
head: <TerritorySortableHead state={state} sortKey="municipalities" align="right" />,
head: (
<TerritorySortableHead
state={state}
sortKey="municipalities"
align="right"
description={territoryColumnDescriptions.municipalities}
/>
),
cell: (row) => row.municipalityCount,
cellClassName: 'text-right tabular-nums',
},
{
id: 'votes2022',
defaultVisible: true,
head: <TerritorySortableHead state={state} sortKey="votes2022" align="right" />,
head: (
<TerritorySortableHead
state={state}
sortKey="votes2022"
align="right"
description={territoryColumnDescriptions.votes2022}
/>
),
cell: (row) => <YearSeriesCell votesByYear={row.votesByYear} />,
cellClassName: 'text-right tabular-nums',
},
{
id: 'pct',
defaultVisible: true,
head: <TerritorySortableHead state={state} sortKey="pct" align="right" />,
head: (
<TerritorySortableHead
state={state}
sortKey="pct"
align="right"
description={territoryColumnDescriptions.pct}
/>
),
cell: (row) => formatPercent(row.pctPropriaVotacao),
cellClassName: (row) =>
`text-right tabular-nums ${row.variant === 'parent' ? 'font-medium' : 'text-muted-foreground'}`,
@ -114,18 +217,89 @@ export const territoryListColumns = ({
{
id: 'validVotes2022',
defaultVisible: true,
head: <TerritorySortableHead state={state} sortKey="validVotes2022" align="right" />,
head: (
<TerritorySortableHead
state={state}
sortKey="validVotes2022"
align="right"
description={territoryColumnDescriptions.validVotes2022}
/>
),
cell: (row) => formatElectionNumber(row.validVotes2022),
cellClassName: 'text-right tabular-nums text-muted-foreground',
},
{
id: 'estimate2026',
defaultVisible: true,
head: <TerritorySortableHead state={state} sortKey="estimate2026" align="right" />,
head: (
<TerritorySortableHead
state={state}
sortKey="estimate2026"
align="right"
description={territoryColumnDescriptions.estimate2026}
/>
),
cell: (row) => formatElectionNumber(row.estimate2026),
cellClassName: (row) =>
`text-right tabular-nums ${row.variant === 'sub' ? 'text-muted-foreground' : ''}`,
},
{
id: 'cobertura',
defaultVisible: true,
head: (
<TerritorySortableHead
state={state}
sortKey="cobertura"
align="right"
description={territoryColumnDescriptions.cobertura}
/>
),
cell: (row) => (
<div className="flex flex-col items-end">
<span className="font-medium tabular-nums">
{formatGoalCoverageRatioLabel(row.goalCoverage)}
</span>
<span
className="text-xs text-muted-foreground tabular-nums"
title="Déficit agregado (cenário média)"
>
{formatGoalCoverageDeficitShortLabel(row.goalCoverage)}
</span>
</div>
),
cellClassName: 'text-right',
},
{
id: 'captura',
defaultVisible: true,
head: (
<TerritorySortableHead
state={state}
sortKey="captura"
align="right"
description={territoryColumnDescriptions.captura}
/>
),
cell: (row) => formatRatioAsPercentLabel(row.captureRate),
cellClassName: 'text-right tabular-nums',
cellTooltip: (row) => captureTooltipContent(row),
},
{
id: 'classe',
defaultVisible: true,
head: (
<TerritorySortableHead
state={state}
sortKey="classe"
align="right"
description={territoryColumnDescriptions.classe}
/>
),
cell: (row) => <TerritorialClassCell row={row} />,
cellClassName: 'text-right',
cellTooltip: (row) =>
row.territorialClass ? formatTerritorialClassWhy(row.territorialClass.factors) : undefined,
},
{
id: 'coverage',
defaultVisible: true,
@ -135,6 +309,7 @@ export const territoryListColumns = ({
sortKey="coverage"
align="right"
filterParam="coverage"
description={territoryColumnDescriptions.coverage}
/>
),
cell: (row) => (

View file

@ -3,6 +3,11 @@
import type { ReactNode } from 'react'
import { TerritoryHeaderFilter } from '@/components/campaign/municipality/TerritoryHeaderFilter'
import {
CampaignHoverTooltip,
campaignHoverExplanationClassName,
campaignHoverTooltipAlign,
} from '@/components/campaign/shared/CampaignHoverTooltip'
import { CampaignSortableHead } from '@/components/campaign/shared/CampaignSortableHead'
import type { TerritoryFilterOption } from '@/utilities/territoryListFilters'
import {
@ -18,6 +23,7 @@ type TerritorySortableHeadProps = {
state: TerritoryListState
sortKey: TerritoryListSortKey
children?: ReactNode
description?: ReactNode
align?: 'left' | 'center' | 'right'
className?: string
} & (
@ -39,6 +45,7 @@ export const TerritorySortableHead = ({
state,
sortKey,
children,
description,
align = 'left',
className,
filterParam,
@ -47,6 +54,7 @@ export const TerritorySortableHead = ({
const { sort: activeSort, dir } = resolveTerritoryListSort(state)
const active = activeSort === sortKey
const nextDir = active ? (dir === 'asc' ? 'desc' : 'asc') : defaultTerritoryListSortDir(sortKey)
const label = children ?? territoryListSortLabels[sortKey]
return (
<CampaignSortableHead
@ -64,8 +72,17 @@ export const TerritorySortableHead = ({
<TerritoryHeaderFilter state={state} filterParam="coverage" />
) : null
}
wrapSortControl={
description
? (control) => (
<CampaignHoverTooltip content={description} align={campaignHoverTooltipAlign(align)}>
{control}
</CampaignHoverTooltip>
)
: undefined
}
>
{children ?? territoryListSortLabels[sortKey]}
{description ? <span className={campaignHoverExplanationClassName}>{label}</span> : label}
</CampaignSortableHead>
)
}

View file

@ -29,6 +29,8 @@ export type CampaignConceptId =
| 'posicao-no-municipio'
| 'meta'
| 'cobertura-da-meta'
| 'captura-regional'
| 'benchmark-intra-ti'
export type CampaignConceptCategoryId = 'base' | 'diagnostico' | 'meta'
@ -95,7 +97,37 @@ export const campaignIntelligenceConcepts: ReadonlyArray<CampaignIntelligenceCon
example: '1.200 votos de Solla sobre um teto de 12.000 = 10% de captura.',
whyItMatters:
'Separa "município pequeno" de "município onde o campo é grande e a campanha não chegou". Captura baixa com teto alto é espaço a ocupar; captura alta é base a defender. É só diagnóstico: não entra no cálculo da meta, para não premiar o que já está consolidado nem punir onde ainda não se trabalhou.',
whereItAppears: 'Card "Conta da cadeira", no detalhe do município ("Captura (2022)").',
whereItAppears:
'Card "Conta da cadeira", no detalhe do município ("Captura (2022)"), e a coluna "Captura" em `/campanha/territorios`.',
},
{
id: 'captura-regional',
categoryID: 'diagnostico',
title: 'Captura regional (território)',
oneLiner:
'Quanto do teto do campo o Jorge Solla conquistou em 2022, somando todos os municípios do Território de Identidade.',
formula:
'captura do TI = Σ votos de Solla (2022) nos municípios do território ÷ Σ tetos do campo (2022) nos mesmos municípios. A mediana e a amplitude vêm das capturas município a município — só para leitura, nunca como substituto do agregado.',
example:
'Dois municípios com 10% e 50% de captura não têm "30% no território" se os tetos forem muito diferentes — o número certo é a soma dos votos ÷ soma dos tetos.',
whyItMatters:
'Evita a média que mente (MAUP): uma média de percentuais esconde onde o campo é grande e a campanha não chegou. O agregado do território sempre vem com mediana, amplitude e município crítico (maior déficit de meta) a um clique.',
whereItAppears: 'Coluna "Captura" em `/campanha/territorios` (tooltip com decomposição).',
},
{
id: 'benchmark-intra-ti',
categoryID: 'diagnostico',
title: 'Benchmark intra-TI (captura)',
oneLiner:
'Compara a captura deste município com a mediana dos pares no mesmo Território de Identidade.',
formula:
'razão = captura do município ÷ mediana das capturas dos municípios do mesmo TI (Salvador compara só com as 19 zonas; demais RMS só entre si).',
example:
'Captura 12% com mediana 6% no TI = 2× a mediana — mecanismo acima do padrão local, não "nota".',
whyItMatters:
'Responde se o município rende acima ou abaixo do que o próprio território já mostrou possível — aprendizado de mecanismo, não régua punitiva entre TIs.',
whereItAppears:
'Parágrafo abaixo de "Captura (2022)" no card "Conta da cadeira" do detalhe do município.',
},
{
id: 'share-intracampo',
@ -148,7 +180,7 @@ export const campaignIntelligenceConcepts: ReadonlyArray<CampaignIntelligenceCon
whyItMatters:
'Responde onde a perna vai na semana: defender reduto dormente ou abrir rede em expansão. A classe é sugestão, não sentença — ela nunca aparece sozinha, sempre com os dois fatores que a produziram, e a mesa pode decidir contra ela. Os cortes exatos (2 e 0,5) são ilustrativos: valem até o backtest contra 2014–2022 calibrá-los.',
whereItAppears:
'Card "Conta da cadeira" e capa do dossiê, no detalhe do município, e a coluna "Classe" da lista de municípios (com filtro e ordenação).',
'Card "Conta da cadeira" e capa do dossiê, no detalhe do município, coluna "Classe" da lista de municípios (com filtro e ordenação), e coluna "Classe" em `/campanha/territorios`.',
},
{
id: 'quantis-do-mapa',
@ -205,7 +237,7 @@ export const campaignIntelligenceConcepts: ReadonlyArray<CampaignIntelligenceCon
whyItMatters:
'É a diferença entre querer e ter: a expectativa da mesa define a meta, mas nunca conta como compromisso — se contasse, todo município com estimativa preenchida apareceria coberto sem uma única liderança por trás. O déficit descoberto é o que ordena onde a campanha precisa de mais rede.',
whereItAppears:
'Início (staff), visão geral e coluna "Cobertura da meta" na lista de municípios, e o card "Conta da cadeira" no detalhe.',
'Início (staff), visão geral e coluna "Cobertura da meta" na lista de municípios, coluna homônima em `/campanha/territorios`, e o card "Conta da cadeira" no detalhe.',
},
]

7
src/lib/median.ts Normal file
View file

@ -0,0 +1,7 @@
/** Median of numeric samples; empty input yields null (not zero). */
export const medianOf = (values: ReadonlyArray<number>): number | null => {
if (values.length === 0) return null
const sorted = [...values].sort((a, b) => a - b)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
}

View file

@ -0,0 +1,14 @@
/**
* Ordinal weights for `classe` list sort (client-safe).
* Single source for municipality list (E10) and territory list (E12).
*/
export const territorialClassSortWeight = {
reduto: 4,
expansao: 3,
manutencao: 2,
marginal: 1,
sem_base: null,
} as const satisfies Record<
'reduto' | 'expansao' | 'manutencao' | 'marginal' | 'sem_base',
number | null
>

View file

@ -5,9 +5,15 @@ import { cache } from 'react'
import { getMunicipalityFederalBaseline } from '@/lib/bahiaElectionAggregates'
import { DEFAULT_VOTE_ESTIMATE_SCENARIO, type VoteEstimateScenario } from '@/lib/voteEstimate'
import type { Municipality } from '@/payload-types'
import type { CampaignUser, Municipality, User } from '@/payload-types'
import { loadMunicipalityGoalCoverageBundle } from '@/utilities/municipalityGoalAccount'
import { fieldCeiling, ownVotes2022 } from '@/utilities/municipalityPotential'
import { computeAggregateTerritorialClass } from '@/utilities/municipalityTerritorialClass'
import {
computeTerritoryRollup,
METROPOLITANO_REGION,
METROPOLITANO_SALVADOR_SUB_ROW_LABEL,
SALVADOR_CITY,
type TerritoryMunicipalityInput,
type TerritoryOverviewRow,
} from '@/utilities/territoryOverview'
@ -22,8 +28,45 @@ type MunicipalityDoc = Pick<
'id' | 'slug' | 'name' | 'city' | 'region' | 'kind' | 'advisors' | 'expectedVotes'
>
type TerritoryReader = CampaignUser | User
const slugsForMetropolitanoSubRow = (
inputs: ReadonlyArray<TerritoryMunicipalityInput>,
label: string,
): string[] => {
if (label === METROPOLITANO_SALVADOR_SUB_ROW_LABEL) {
return inputs
.filter((input) => input.region === METROPOLITANO_REGION && input.city === SALVADOR_CITY)
.map((input) => input.slug)
}
return inputs
.filter((input) => input.region === METROPOLITANO_REGION && input.city !== SALVADOR_CITY)
.map((input) => input.slug)
}
const attachTerritorialClasses = (
inputs: ReadonlyArray<TerritoryMunicipalityInput>,
rows: TerritoryOverviewRow[],
): TerritoryOverviewRow[] =>
rows.map((row) => {
const regionSlugs = inputs
.filter((input) => input.region === row.region)
.map((input) => input.slug)
const subRows = row.subRows?.map((subRow) => ({
...subRow,
territorialClass: computeAggregateTerritorialClass(
slugsForMetropolitanoSubRow(inputs, subRow.label),
),
}))
return {
...row,
territorialClass: computeAggregateTerritorialClass(regionSlugs),
subRows,
}
})
/**
* E17 — Loads the full 27-TI overview (all 435 municipalities, `overrideAccess: true`).
* E17 + E12 — Loads the full 27-TI overview (all 435 municipalities).
*
* Regional context is intentionally non-scoped: an advisor sees the complete
* comparative table (leitura regional é contexto, não gestão). Exposure is
@ -33,6 +76,7 @@ type MunicipalityDoc = Pick<
export const loadTerritoryOverview = cache(
async (
payload: Payload,
user: TerritoryReader,
scenario: VoteEstimateScenario = DEFAULT_VOTE_ESTIMATE_SCENARIO,
): Promise<TerritoryOverviewRow[]> => {
const result = await payload.find({
@ -59,6 +103,13 @@ export const loadTerritoryOverview = cache(
docs.map((doc) => doc.id),
)
const { coverageByMunicipalityID } = await loadMunicipalityGoalCoverageBundle(
payload,
user,
docs,
aggregates,
)
const inputs: TerritoryMunicipalityInput[] = docs.map((doc) => {
const baseline = getMunicipalityFederalBaseline(doc.slug)
const aggregate = aggregates.get(doc.id) ?? emptyMunicipalityPledgeAggregate
@ -67,6 +118,14 @@ export const loadTerritoryOverview = cache(
aggregate.effectiveByScenario[scenario],
scenario,
)
const coverageByScenario = coverageByMunicipalityID.get(doc.id)
const goalCoverage = coverageByScenario?.[scenario] ?? {
goal: 0,
committed: 0,
coverageRatio: null,
deficit: 0,
}
return {
slug: doc.slug,
name: doc.name,
@ -77,9 +136,13 @@ export const loadTerritoryOverview = cache(
validVotesByYear: { ...baseline.validVotesByYear },
estimate2026: estimate,
advisorCount: doc.advisors?.length ?? 0,
ownVotes2022: ownVotes2022(baseline),
fieldCeiling2022: fieldCeiling(baseline),
goalCoverage,
}
})
return computeTerritoryRollup(inputs)
const rows = computeTerritoryRollup(inputs)
return attachTerritorialClasses(inputs, rows)
},
)

View file

@ -29,6 +29,10 @@ import {
computeMunicipalityTerritorialClass,
type MunicipalityTerritorialClassification,
} from '@/utilities/municipalityTerritorialClass'
import {
getMunicipalityIntraTerritoryCaptureBenchmark,
type MunicipalityIntraTerritoryCaptureBenchmark,
} from '@/utilities/territoryIntraCaptureBenchmark'
import {
emptyMunicipalityPledgeAggregate,
type MunicipalityPledgeAggregate,
@ -124,6 +128,8 @@ export type MunicipalityGoalAccount = {
potential: MunicipalityPotential
/** E10 — the one-word reading of `potential` plus the factors behind it. */
territorialClass: MunicipalityTerritorialClassification
/** E12 T4 — capture vs median of peers in the same TI (or Metropolitano sub-group). */
territoryCaptureBenchmark: MunicipalityIntraTerritoryCaptureBenchmark
}
/**
@ -154,5 +160,6 @@ export const loadMunicipalityGoalAccount = async (
goalCoverage,
potential,
territorialClass: computeMunicipalityTerritorialClass(municipality.slug),
territoryCaptureBenchmark: getMunicipalityIntraTerritoryCaptureBenchmark(municipality.slug),
}
}

View file

@ -6,7 +6,9 @@ import {
} from '@/lib/bahiaElectionAggregates'
import { ELECTION_YEAR_2022 } from '@/lib/electionResults'
import { computeVoteRankByYear } from '@/lib/municipalityVoteRank'
import { medianOf } from '@/lib/median'
import { TERRITORIAL_CLASS_ANCHORS } from '@/lib/territorialClassAnchors'
import { territorialClassSortWeight } from '@/lib/territorialClassSortWeight'
import {
captureRate,
fieldCeiling,
@ -138,13 +140,6 @@ export const classifyMunicipalityTerritory = (
const fieldHeadroomOf = (baseline: MunicipalityFederalBaseline): number =>
Math.max(0, projectedFieldCeiling(baseline) - ownVotes2022(baseline))
const median = (values: number[]): number => {
if (values.length === 0) return 0
const sorted = [...values].sort((a, b) => a - b)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
}
type CatalogContext = {
medianFieldHeadroom: number
coreBlockSlugs: ReadonlySet<string>
@ -163,12 +158,13 @@ const getCatalogContext = (): CatalogContext => {
// Slugs whose majoritarian cell is missing would drag the median to zero and
// make every weak município read as "expansão".
const medianFieldHeadroom = median(
federalBaselineMunicipalitySlugs()
.map(getMunicipalityFederalBaseline)
.filter((baseline) => projectedFieldCeiling(baseline) > 0)
.map(fieldHeadroomOf),
)
const medianFieldHeadroom =
medianOf(
federalBaselineMunicipalitySlugs()
.map(getMunicipalityFederalBaseline)
.filter((baseline) => projectedFieldCeiling(baseline) > 0)
.map(fieldHeadroomOf),
) ?? 0
// A11 already ranks every slug by own vote, descending, and memoizes it —
// walking that map is the same order this used to re-sort for itself.
@ -265,15 +261,4 @@ export const computeAggregateTerritorialClass = (
})
}
/**
* Sort weight for the list's `classe` ordering: descending puts the
* municípios that carry the campaign (reduto) first and the ones without a
* baseline last, in either direction.
*/
export const territorialClassSortWeight: Record<MunicipalityTerritorialClass, number | null> = {
reduto: 4,
expansao: 3,
manutencao: 2,
marginal: 1,
sem_base: null,
}
export { territorialClassSortWeight }

View file

@ -0,0 +1,99 @@
/**
* E12 — intra-TI capture benchmark (T4) for municipality detail.
*
* Pure over the committed TSE artifact + `municipalityCatalog` (client-safe).
* Peers share the same Território de Identidade; Metropolitano de Salvador
* splits Salvador (all zone slugs) vs demais RMS municipalities — same rule as
* E17/E12 rollups.
*/
import { getMunicipalityFederalBaseline } from '@/lib/bahiaElectionAggregates'
import { medianOf } from '@/lib/median'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import { captureRate } from '@/utilities/municipalityPotential'
import {
METROPOLITANO_REGION,
SALVADOR_CITY,
} from '@/utilities/territoryOverview'
type TerritoryCaptureBeacon = {
slug: string
name: string
captureRate: number
}
export type MunicipalityIntraTerritoryCaptureBenchmark = {
/** This municipality's 2022 capture rate, or null without a field ceiling. */
captureRate: number | null
/** Median capture among peers in the same TI (or Metropolitano sub-group). */
medianCapture: number | null
/**
* `captureRate / medianCapture` when both exist — how many times above/below
* the TI median (T4).
*/
ratioToMedian: number | null
/** Peer with the highest capture in the group (learning reference, not a scoreboard). */
beacon: TerritoryCaptureBeacon | null
}
const peersForSlug = (slug: string): ReadonlyArray<(typeof municipalityCatalog)[number]> => {
const entry = municipalityCatalog.find((row) => row.slug === slug)
if (!entry) return []
if (entry.region !== METROPOLITANO_REGION) {
return municipalityCatalog.filter((row) => row.region === entry.region)
}
const isSalvadorZone = entry.city === SALVADOR_CITY
return municipalityCatalog.filter((row) =>
isSalvadorZone
? row.region === METROPOLITANO_REGION && row.city === SALVADOR_CITY
: row.region === METROPOLITANO_REGION && row.city !== SALVADOR_CITY,
)
}
/** T4 benchmark for one catalog slug — memoized per slug per process. */
const benchmarkBySlug = new Map<string, MunicipalityIntraTerritoryCaptureBenchmark>()
export const getMunicipalityIntraTerritoryCaptureBenchmark = (
slug: string,
): MunicipalityIntraTerritoryCaptureBenchmark => {
const cached = benchmarkBySlug.get(slug)
if (cached) return cached
const peers = peersForSlug(slug)
const rates: Array<{ slug: string; name: string; rate: number }> = []
for (const peer of peers) {
const baseline = getMunicipalityFederalBaseline(peer.slug)
const rate = captureRate(baseline)
if (rate == null) continue
rates.push({ slug: peer.slug, name: peer.name, rate })
}
const baseline = getMunicipalityFederalBaseline(slug)
const municipalityCapture = captureRate(baseline)
const medianCapture = medianOf(rates.map((entry) => entry.rate))
let beacon: TerritoryCaptureBeacon | null = null
for (const entry of rates) {
if (!beacon || entry.rate > beacon.captureRate) {
beacon = { slug: entry.slug, name: entry.name, captureRate: entry.rate }
}
}
const ratioToMedian =
municipalityCapture != null && medianCapture != null && medianCapture > 0
? municipalityCapture / medianCapture
: null
const result: MunicipalityIntraTerritoryCaptureBenchmark = {
captureRate: municipalityCapture,
medianCapture,
ratioToMedian,
beacon,
}
benchmarkBySlug.set(slug, result)
return result
}

View file

@ -0,0 +1,19 @@
import { campaignConceptOneLiner } from '@/lib/campaignIntelligenceConcepts'
import type { TerritoryListSortKey } from '@/utilities/territoryListUrl'
/** B22 — header tooltips for `/campanha/territorios` (E12 columns included). */
export const territoryColumnDescriptions: Partial<Record<TerritoryListSortKey, string>> = {
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.',
estimate2026:
'Soma das estimativas de votos da mesa (cenário média) nos municípios do território.',
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.`,
classe:
'Classe do território lida como um só (soma dos insumos de 2022), não a média das classes dos municípios.',
}

View file

@ -33,6 +33,9 @@ const territoryListSortKeys: TerritoryListSortKey[] = [
'validVotes2022',
'estimate2026',
'coverage',
'cobertura',
'captura',
'classe',
]
const territoryListSortKeySet = new Set<string>(territoryListSortKeys)
const territoryListSortDirSet = new Set<TerritoryListSortDirection>(['asc', 'desc'])
@ -47,6 +50,9 @@ export const territoryListSortLabels: Record<TerritoryListSortKey, string> = {
validVotes2022: 'Válidos 2022',
estimate2026: 'Estimativa 2026 (média)',
coverage: 'Assessoria',
cobertura: 'Cobertura da meta',
captura: 'Captura (2022)',
classe: 'Classe',
}
const canonicalTerritoryBySearchValue = new Map(
@ -67,7 +73,10 @@ const parseRegions = (value: string | string[] | undefined): BahiaIdentityTerrit
export const defaultTerritoryListSortDir = (
key: TerritoryListSortKey,
): TerritoryListSortDirection => (key === 'region' ? 'asc' : 'desc')
): TerritoryListSortDirection => {
if (key === 'region') return 'asc'
return 'desc'
}
export const resolveTerritoryListSort = (
state: TerritoryListState,

View file

@ -1,24 +1,57 @@
/**
* E17 — Territórios de Identidade comparative rollup for the staff Início.
* E12 — extends rows with goal coverage, capture (MAUP), critical municipality.
*
* 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).
* 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).
*
* 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`.
* This module is **client-safe** (no `server-only`, no Payload imports):
* `computeTerritoryRollup` / `sortTerritoryRows` are pure and unit-tested.
* Territorial class is attached in `loadTerritoryOverview.ts` (B13 artifact).
*/
import { medianOf } from '@/lib/median'
import { territorialClassSortWeight } from '@/lib/territorialClassSortWeight'
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
import type { MunicipalityGoalCoverage } from '@/utilities/goalCoverage'
import { aggregateGoalCoverage } from '@/utilities/goalCoverage'
import type {
MunicipalityTerritorialClassification,
} from '@/utilities/municipalityTerritorialClass'
const SALVADOR_CITY = 'Salvador'
const SALVADOR_SUB_LABEL = 'Salvador (19 zonas)'
export const METROPOLITANO_REGION = 'Metropolitano de Salvador'
export const SALVADOR_CITY = 'Salvador'
/** Metropolitano sub-row label — shared with `loadTerritoryOverview` for aggregate class slugs. */
export const METROPOLITANO_SALVADOR_SUB_ROW_LABEL = 'Salvador (19 zonas)'
const SALVADOR_SUB_LABEL = METROPOLITANO_SALVADOR_SUB_ROW_LABEL
const RMS_DEMAIS_SUB_LABEL = 'Demais municípios da RMS'
const METROPOLITANO_REGION = 'Metropolitano de Salvador'
const ELECTION_YEAR_2022 = '2022'
type TerritoryCriticalMunicipality = {
slug: string
name: string
deficit: number
}
type TerritoryCaptureBeacon = {
slug: string
name: string
captureRate: number
}
/** E12 metrics shared by parent rows and Metropolitano sub-rows. */
export type TerritoryE12Rollup = {
goalCoverage: MunicipalityGoalCoverage
/** Σ own ÷ Σ ceiling — never the mean of per-municipality capture rates. */
captureRate: number | null
medianCapture: number | null
captureMin: number | null
captureMax: number | null
criticalMunicipality: TerritoryCriticalMunicipality | null
captureBeacon: TerritoryCaptureBeacon | null
}
export type TerritoryMunicipalityInput = {
slug: string
name: string
@ -29,6 +62,9 @@ export type TerritoryMunicipalityInput = {
validVotesByYear: Record<string, number>
estimate2026: number
advisorCount: number
ownVotes2022: number
fieldCeiling2022: number
goalCoverage: MunicipalityGoalCoverage
}
type TerritoryOverviewSubRow = {
@ -39,7 +75,9 @@ type TerritoryOverviewSubRow = {
estimate2026: number
withAdvisorCount: number
pctPropriaVotacao: number
}
} & TerritoryE12Rollup & {
territorialClass?: MunicipalityTerritorialClassification | null
}
export type TerritoryOverviewRow = {
region: string
@ -50,7 +88,9 @@ export type TerritoryOverviewRow = {
estimate2026: number
withAdvisorCount: number
subRows?: TerritoryOverviewSubRow[]
}
} & TerritoryE12Rollup & {
territorialClass?: MunicipalityTerritorialClassification | null
}
export type TerritoryTableRow =
| ({ variant: 'parent' } & Omit<TerritoryOverviewRow, 'subRows'>)
@ -64,6 +104,9 @@ export type TerritorySortKey =
| 'validVotes2022'
| 'estimate2026'
| 'coverage'
| 'cobertura'
| 'captura'
| 'classe'
export type TerritorySortDir = 'asc' | 'desc'
@ -76,6 +119,71 @@ type Accumulator = {
withAdvisorCount: number
}
/**
* E12 rollup for a set of municipalities in one TI (or Metropolitano sub-group).
* Exposes aggregate capture vs mean-of-ratios only through tests — UI uses aggregate.
*/
export const computeTerritoryE12Rollup = (
inputs: ReadonlyArray<TerritoryMunicipalityInput>,
): TerritoryE12Rollup => {
const goalCoverage = aggregateGoalCoverage(inputs.map((input) => input.goalCoverage))
let sumOwn = 0
let sumCeiling = 0
const perMunicipalityRates: number[] = []
let criticalMunicipality: TerritoryCriticalMunicipality | null = null
let captureBeacon: TerritoryCaptureBeacon | null = null
for (const input of inputs) {
sumOwn += input.ownVotes2022
sumCeiling += input.fieldCeiling2022
if (input.fieldCeiling2022 > 0) {
const rate = input.ownVotes2022 / input.fieldCeiling2022
perMunicipalityRates.push(rate)
if (!captureBeacon || rate > captureBeacon.captureRate) {
captureBeacon = { slug: input.slug, name: input.name, captureRate: rate }
}
}
if (input.goalCoverage.goal > 0) {
if (!criticalMunicipality || input.goalCoverage.deficit > criticalMunicipality.deficit) {
criticalMunicipality = {
slug: input.slug,
name: input.name,
deficit: input.goalCoverage.deficit,
}
}
}
}
const captureRate = sumCeiling > 0 ? sumOwn / sumCeiling : null
const medianCapture = medianOf(perMunicipalityRates)
const captureMin = perMunicipalityRates.length > 0 ? Math.min(...perMunicipalityRates) : null
const captureMax = perMunicipalityRates.length > 0 ? Math.max(...perMunicipalityRates) : null
return {
goalCoverage,
captureRate,
medianCapture,
captureMin,
captureMax,
criticalMunicipality,
captureBeacon,
}
}
/** Mean of per-municipality capture rates — MAUP alarm only; not shown in UI. */
export const meanCaptureRateOfMunicipalities = (
inputs: ReadonlyArray<TerritoryMunicipalityInput>,
): number | null => {
const rates = inputs
.filter((input) => input.fieldCeiling2022 > 0)
.map((input) => input.ownVotes2022 / input.fieldCeiling2022)
if (rates.length === 0) return null
return rates.reduce((sum, rate) => sum + rate, 0) / rates.length
}
const createAccumulator = (region: string): Accumulator => ({
region,
municipalityCount: 0,
@ -98,7 +206,11 @@ const accumulateInto = (acc: Accumulator, input: TerritoryMunicipalityInput): vo
const pctOf = (acc: Accumulator, stateTotal2022: number): number =>
stateTotal2022 > 0 ? (acc.votesByYear[ELECTION_YEAR_2022] ?? 0) / stateTotal2022 : 0
const finalizeRow = (acc: Accumulator, stateTotal2022: number): TerritoryOverviewRow => ({
const finalizeRow = (
acc: Accumulator,
stateTotal2022: number,
e12: TerritoryE12Rollup,
): TerritoryOverviewRow => ({
region: acc.region,
municipalityCount: acc.municipalityCount,
votesByYear: { ...acc.votesByYear },
@ -106,12 +218,14 @@ const finalizeRow = (acc: Accumulator, stateTotal2022: number): TerritoryOvervie
pctPropriaVotacao: pctOf(acc, stateTotal2022),
estimate2026: acc.estimate2026,
withAdvisorCount: acc.withAdvisorCount,
...e12,
})
const finalizeSubRow = (
label: string,
acc: Accumulator,
stateTotal2022: number,
e12: TerritoryE12Rollup,
): TerritoryOverviewSubRow => ({
label,
municipalityCount: acc.municipalityCount,
@ -120,8 +234,24 @@ const finalizeSubRow = (
estimate2026: acc.estimate2026,
withAdvisorCount: acc.withAdvisorCount,
pctPropriaVotacao: pctOf(acc, stateTotal2022),
...e12,
})
const inputsInRegion = (
inputs: ReadonlyArray<TerritoryMunicipalityInput>,
region: string,
): TerritoryMunicipalityInput[] => inputs.filter((input) => input.region === region)
const metropolitanoSalvadorInputs = (
inputs: ReadonlyArray<TerritoryMunicipalityInput>,
): TerritoryMunicipalityInput[] =>
inputs.filter((input) => input.region === METROPOLITANO_REGION && input.city === SALVADOR_CITY)
const metropolitanoDemaisInputs = (
inputs: ReadonlyArray<TerritoryMunicipalityInput>,
): TerritoryMunicipalityInput[] =>
inputs.filter((input) => input.region === METROPOLITANO_REGION && input.city !== SALVADOR_CITY)
/**
* 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
@ -141,10 +271,6 @@ export const computeTerritoryRollup = (
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)
@ -159,11 +285,23 @@ export const computeTerritoryRollup = (
const rows: TerritoryOverviewRow[] = []
for (const acc of byRegion.values()) {
const row = finalizeRow(acc, stateTotal2022)
const regionInputs = inputsInRegion(inputs, acc.region)
const e12 = computeTerritoryE12Rollup(regionInputs)
const row = finalizeRow(acc, stateTotal2022, e12)
if (acc.region === METROPOLITANO_REGION) {
row.subRows = [
finalizeSubRow(SALVADOR_SUB_LABEL, metropolitanoSalvador, stateTotal2022),
finalizeSubRow(RMS_DEMAIS_SUB_LABEL, metropolitanoDemais, stateTotal2022),
finalizeSubRow(
SALVADOR_SUB_LABEL,
metropolitanoSalvador,
stateTotal2022,
computeTerritoryE12Rollup(metropolitanoSalvadorInputs(inputs)),
),
finalizeSubRow(
RMS_DEMAIS_SUB_LABEL,
metropolitanoDemais,
stateTotal2022,
computeTerritoryE12Rollup(metropolitanoDemaisInputs(inputs)),
),
]
}
rows.push(row)
@ -172,6 +310,16 @@ export const computeTerritoryRollup = (
return rows
}
const goalCoverageSortValue = (row: TerritoryE12Rollup): number =>
row.goalCoverage.coverageRatio ?? -1
const classeSortValue = (row: TerritoryOverviewRow | TerritoryOverviewSubRow): number => {
const weight = row.territorialClass
? territorialClassSortWeight[row.territorialClass.class]
: null
return weight ?? -1
}
const rowSortValue = (row: TerritoryOverviewRow, key: TerritorySortKey): number | string => {
switch (key) {
case 'region':
@ -188,6 +336,12 @@ const rowSortValue = (row: TerritoryOverviewRow, key: TerritorySortKey): number
return row.estimate2026
case 'coverage':
return row.municipalityCount > 0 ? row.withAdvisorCount / row.municipalityCount : 0
case 'cobertura':
return goalCoverageSortValue(row)
case 'captura':
return row.captureRate ?? -1
case 'classe':
return classeSortValue(row)
}
}

View file

@ -1,13 +1,22 @@
import { describe, expect, it } from 'vitest'
import {
computeTerritoryE12Rollup,
computeTerritoryRollup,
filterTerritoryRows,
flattenTerritoryRows,
meanCaptureRateOfMunicipalities,
sortTerritoryRows,
type TerritoryMunicipalityInput,
} from '@/utilities/territoryOverview'
const emptyGoalCoverage = {
goal: 0,
committed: 0,
coverageRatio: null as number | null,
deficit: 0,
}
const input = (
overrides: Partial<TerritoryMunicipalityInput> &
Pick<TerritoryMunicipalityInput, 'slug' | 'region' | 'city'>,
@ -18,6 +27,9 @@ const input = (
validVotesByYear: { 2022: 0 },
estimate2026: 0,
advisorCount: 0,
ownVotes2022: 0,
fieldCeiling2022: 0,
goalCoverage: emptyGoalCoverage,
...overrides,
})
@ -31,6 +43,9 @@ const fixture: TerritoryMunicipalityInput[] = [
validVotesByYear: { 2022: 1000 },
estimate2026: 40,
advisorCount: 1,
ownVotes2022: 30,
fieldCeiling2022: 300,
goalCoverage: { goal: 100, committed: 20, coverageRatio: 0.2, deficit: 80 },
}),
input({
slug: 'irece',
@ -40,6 +55,9 @@ const fixture: TerritoryMunicipalityInput[] = [
validVotesByYear: { 2022: 2000 },
estimate2026: 35,
advisorCount: 0,
ownVotes2022: 25,
fieldCeiling2022: 100,
goalCoverage: { goal: 50, committed: 10, coverageRatio: 0.2, deficit: 40 },
}),
// Velho Chico (TI 02) — 1 município, com assessor
input({
@ -50,6 +68,8 @@ const fixture: TerritoryMunicipalityInput[] = [
validVotesByYear: { 2022: 5000 },
estimate2026: 150,
advisorCount: 2,
ownVotes2022: 120,
fieldCeiling2022: 1200,
}),
// Metropolitano de Salvador (TI 26) — Salvador (1 zona) + Camaçari (demais)
input({
@ -61,6 +81,8 @@ const fixture: TerritoryMunicipalityInput[] = [
validVotesByYear: { 2022: 40000 },
estimate2026: 1300,
advisorCount: 1,
ownVotes2022: 1200,
fieldCeiling2022: 12000,
}),
input({
slug: 'camacari',
@ -70,6 +92,8 @@ const fixture: TerritoryMunicipalityInput[] = [
validVotesByYear: { 2022: 8000 },
estimate2026: 250,
advisorCount: 0,
ownVotes2022: 220,
fieldCeiling2022: 2200,
}),
]
@ -194,3 +218,73 @@ describe('filterTerritoryRows', () => {
expect(metropolitano.subRows).toHaveLength(2)
})
})
describe('computeTerritoryE12Rollup (MAUP)', () => {
const rows = computeTerritoryRollup(fixture)
it('uses ratio of aggregates, not mean of ratios, when municipalities differ', () => {
const heterogeneous: TerritoryMunicipalityInput[] = [
input({
slug: 'a',
region: 'Irecê',
city: 'A',
ownVotes2022: 10,
fieldCeiling2022: 100,
goalCoverage: emptyGoalCoverage,
}),
input({
slug: 'b',
region: 'Irecê',
city: 'B',
ownVotes2022: 90,
fieldCeiling2022: 100,
goalCoverage: emptyGoalCoverage,
}),
]
const rollup = computeTerritoryE12Rollup(heterogeneous)
const mean = meanCaptureRateOfMunicipalities(heterogeneous)
expect(rollup.captureRate).toBeCloseTo(0.5, 6)
expect(mean).toBeCloseTo(0.5, 6)
// Same in this symmetric case — use skewed ceilings to diverge.
const skewed: TerritoryMunicipalityInput[] = [
input({
slug: 'low',
region: 'Irecê',
city: 'Low',
ownVotes2022: 10,
fieldCeiling2022: 1000,
goalCoverage: emptyGoalCoverage,
}),
input({
slug: 'high',
region: 'Irecê',
city: 'High',
ownVotes2022: 90,
fieldCeiling2022: 100,
goalCoverage: emptyGoalCoverage,
}),
]
const skewedRollup = computeTerritoryE12Rollup(skewed)
const skewedMean = meanCaptureRateOfMunicipalities(skewed)!
expect(skewedRollup.captureRate).toBeCloseTo(100 / 1100, 6)
expect(skewedMean).not.toBeCloseTo(skewedRollup.captureRate!, 4)
})
it('names the municipality with the largest goal deficit as critical', () => {
const irece = rows.find((row) => row.region === 'Irecê')!
expect(irece.criticalMunicipality).toEqual({
slug: 'ibipeba',
name: 'ibipeba',
deficit: 80,
})
expect(irece.goalCoverage.goal).toBe(150)
expect(irece.goalCoverage.committed).toBe(30)
})
it('computes capture stats on Metropolitano sub-rows', () => {
const metro = rows.find((row) => row.region === 'Metropolitano de Salvador')!
const salvador = metro.subRows![0]
expect(salvador.captureRate).not.toBeNull()
expect(salvador.medianCapture).not.toBeNull()
})
})