Merge branch 'cursor/3907847e'
This commit is contained in:
commit
f4e7b4f1e0
21 changed files with 874 additions and 111 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
48
docs/plans/escala-dry-pos-e12.md
Normal file
48
docs/plans/escala-dry-pos-e12.md
Normal 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
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
7
src/lib/median.ts
Normal 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]
|
||||
}
|
||||
14
src/lib/territorialClassSortWeight.ts
Normal file
14
src/lib/territorialClassSortWeight.ts
Normal 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
|
||||
>
|
||||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ import {
|
|||
type MunicipalityFederalBaseline,
|
||||
} from '@/lib/bahiaElectionAggregates'
|
||||
import { ELECTION_YEAR_2022 } from '@/lib/electionResults'
|
||||
import { medianOf } from '@/lib/median'
|
||||
import { computeVoteRankByYear } from '@/lib/municipalityVoteRank'
|
||||
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 }
|
||||
|
|
|
|||
96
src/utilities/territoryIntraCaptureBenchmark.ts
Normal file
96
src/utilities/territoryIntraCaptureBenchmark.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* 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
|
||||
}
|
||||
19
src/utilities/territoryListLabels.ts
Normal file
19
src/utilities/territoryListLabels.ts
Normal 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.',
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,24 +1,55 @@
|
|||
/**
|
||||
* 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 +60,9 @@ export type TerritoryMunicipalityInput = {
|
|||
validVotesByYear: Record<string, number>
|
||||
estimate2026: number
|
||||
advisorCount: number
|
||||
ownVotes2022: number
|
||||
fieldCeiling2022: number
|
||||
goalCoverage: MunicipalityGoalCoverage
|
||||
}
|
||||
|
||||
type TerritoryOverviewSubRow = {
|
||||
|
|
@ -39,7 +73,9 @@ type TerritoryOverviewSubRow = {
|
|||
estimate2026: number
|
||||
withAdvisorCount: number
|
||||
pctPropriaVotacao: number
|
||||
}
|
||||
} & TerritoryE12Rollup & {
|
||||
territorialClass?: MunicipalityTerritorialClassification | null
|
||||
}
|
||||
|
||||
export type TerritoryOverviewRow = {
|
||||
region: string
|
||||
|
|
@ -50,7 +86,9 @@ export type TerritoryOverviewRow = {
|
|||
estimate2026: number
|
||||
withAdvisorCount: number
|
||||
subRows?: TerritoryOverviewSubRow[]
|
||||
}
|
||||
} & TerritoryE12Rollup & {
|
||||
territorialClass?: MunicipalityTerritorialClassification | null
|
||||
}
|
||||
|
||||
export type TerritoryTableRow =
|
||||
| ({ variant: 'parent' } & Omit<TerritoryOverviewRow, 'subRows'>)
|
||||
|
|
@ -64,6 +102,9 @@ export type TerritorySortKey =
|
|||
| 'validVotes2022'
|
||||
| 'estimate2026'
|
||||
| 'coverage'
|
||||
| 'cobertura'
|
||||
| 'captura'
|
||||
| 'classe'
|
||||
|
||||
export type TerritorySortDir = 'asc' | 'desc'
|
||||
|
||||
|
|
@ -76,6 +117,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 +204,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 +216,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 +232,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 +269,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 +283,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 +308,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 +334,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue