From f06e573f1599fa03d5bd2ee18d370f9bf9e80e29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 26 Jul 2026 21:26:47 -0300 Subject: [PATCH 1/2] E12: camada TI com rollups MAUP e benchmark intra-TI. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Estende territórios com cobertura/captura/classe agregadas e compara captura do município aos pares do mesmo TI no card Conta da cadeira. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + AGENTS.md | 2 + docs/plans/camada-territorios-identidade.md | 84 ++++---- docs/plans/escala-dry-pos-e12.md | 48 +++++ docs/roadmap.md | 23 ++- .../[slug]/MunicipalityDetailTabs.tsx | 3 +- .../campanha/(app)/territorios/page.tsx | 2 +- .../MunicipalityGoalAccountCard.tsx | 36 ++++ .../municipality/TerritoryListColumns.tsx | 185 ++++++++++++++++- .../municipality/TerritorySortableHead.tsx | 19 +- src/lib/campaignIntelligenceConcepts.ts | 38 +++- src/lib/median.ts | 7 + src/lib/territorialClassSortWeight.ts | 14 ++ src/utilities/loadTerritoryOverview.ts | 69 ++++++- src/utilities/municipalityGoalAccount.ts | 7 + src/utilities/municipalityTerritorialClass.ts | 35 +--- .../territoryIntraCaptureBenchmark.ts | 99 +++++++++ src/utilities/territoryListLabels.ts | 19 ++ src/utilities/territoryListUrl.ts | 11 +- src/utilities/territoryOverview.ts | 194 ++++++++++++++++-- tests/unit/territoryOverview.unit.spec.ts | 94 +++++++++ 21 files changed, 879 insertions(+), 111 deletions(-) create mode 100644 docs/plans/escala-dry-pos-e12.md create mode 100644 src/lib/median.ts create mode 100644 src/lib/territorialClassSortWeight.ts create mode 100644 src/utilities/territoryIntraCaptureBenchmark.ts create mode 100644 src/utilities/territoryListLabels.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 2c51a0d0..03d33673 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -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/`) 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/` 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). diff --git a/AGENTS.md b/AGENTS.md index 439f8533..9cb57f25 100644 --- a/AGENTS.md +++ b/AGENTS.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). diff --git a/docs/plans/camada-territorios-identidade.md b/docs/plans/camada-territorios-identidade.md index 5afff0a2..a3645201 100644 --- a/docs/plans/camada-territorios-identidade.md +++ b/docs/plans/camada-territorios-identidade.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
(município→TI, frozen 2026)"] - Roll["computeTerritoryRollups
(Σ, 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
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` diff --git a/docs/plans/escala-dry-pos-e12.md b/docs/plans/escala-dry-pos-e12.md new file mode 100644 index 00000000..822e01a4 --- /dev/null +++ b/docs/plans/escala-dry-pos-e12.md @@ -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 | diff --git a/docs/roadmap.md b/docs/roadmap.md index 1c961a14..712b5e89 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,7 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-26 (**B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); ~~**B25**~~ ✓ link do território na lista de municípios (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6) -Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). +Atualizado em: 2026-07-26 (**E12 ✓** camada TI — rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no `MunicipalityGoalAccountCard`; [plano](plans/camada-territorios-identidade.md); **B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); ~~**B25**~~ ✓ link do território na lista de municípios (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6)Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -65,21 +64,22 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md]( O discovery literatura→persona→entrevista ([relatório aprovado](research/relatorio-entrevista-persona-campanha.md); compêndio com ~67 fontes em [docs/research/](research/)) fixou o kernel: a disputa de DF é conta de quociente fragmentada; o gargalo é converter lealdade de campo em voto nominal município a município via rede; % estadual absoluto é anti-métrica. A **sessão real com o Coordenador Geral (2026-07-23 — [CUSTOMER.md](CUSTOMER.md))** confirmou as apostas centrais (fogo amigo intra-PT como ameaça nº 1; canal = ZAP sem registro datado; "coluna da vergonha" validada — Salvador cobrado 10×) e calibrou as âncoras: a leitura relativa da mesa é **% da própria votação** (concentração da captura própria — critério rígido de prioridade dele), não % do eleitorado local; a restrição dominante é **"perna"/estrutura**, não dinheiro; piso projetado **150 mil** (2022: 129k). O produto deve entregar **inteligência, não planilha chique**: metas derivadas, leitura relativa, fila de decisão priorizada, sugestões dado→decisão com humano no loop, registro ex-ante auditável. Plano-mestre: [inteligencia-campanha.md](plans/inteligencia-campanha.md) (incl. gaps de dados G1–G11 e desenho canônico da fila). -| ID | Fatia | Plano | Entrega essencial | Classe | Appetite | Janela | Depende de | -| ------- | -------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | --------- | -------------------- | -| ~~E10~~ | ~~Classificação territorial relativa~~ | [detalhes](plans/classificacao-territorial-relativa.md) | **✓ (entregue 2026-07-25)** Reduto/Expansão/Manutenção/Marginal por LQ + importância + teto do campo; coluna, sort e filtro na lista | B | ~1,25d | 3 | E8 ✓ | -| ~~B13~~ | ~~Escala relativa no mapa~~ | [detalhes](plans/escala-relativa-mapa.md) | **✓ (entregue 2026-07-26)** Quantis (default) + LQ nas âncoras do E10 + posição no município (artefato v3); bolhas por válidos projetados coloridas pela classe | B | ~3d | 3 | E8 ✓, E10 ✓ | -| E14 | Níveis de envolvimento N0–N4 | [detalhes](plans/niveis-de-envolvimento.md) | `engagementLevel` com histerese e sinais de reversão; staff-only (vocabulário duplo) | B | ~1d | 3 | C12 | -| E11 | Motor de sugestões v1 | [detalhes](plans/motor-de-sugestoes.md) | 8 padrões (P1/P2/P3/P5/P6/P7/K-A/K-B), menu com estatuto, aceitar/descartar → `allocationDecision`, triagem 1–5 | C | ~3d | 3 | E8 ✓, E9 ✓, E10, C12 | -| E12 | Camada TI | [detalhes](plans/camada-territorios-identidade.md) | Rollups com salvaguardas MAUP, benchmark intra-TI (gatilhos T no motor = fase 2 de E11) | B | ~1,5d | 3 | E8 ✓ | -| E13 | Planejador de presença/giros | [detalhes](plans/planejador-de-giros.md) | Elegibilidade (5 condições ✓/—), fases construção/consolidação/ativação, compositor de giro, visita pedida×justificada | C | ~1,5d | 3 | E8 ✓, C12 | -| E15 | Backtest pós-eleição | [detalhes](plans/backtest-pos-eleicao.md) | Pledge (trajetória) vs. resultado por zona; calibração de limiares | A | ~1d | pós-04/10 | C12, TSE 2026 | +| ID | Fatia | Plano | Entrega essencial | Classe | Appetite | Janela | Depende de | +| ------- | -------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | --------- | -------------------- | +| ~~E10~~ | ~~Classificação territorial relativa~~ | [detalhes](plans/classificacao-territorial-relativa.md) | **✓ (entregue 2026-07-25)** Reduto/Expansão/Manutenção/Marginal por LQ + importância + teto do campo; coluna, sort e filtro na lista | B | ~1,25d | 3 | E8 ✓ | +| ~~B13~~ | ~~Escala relativa no mapa~~ | [detalhes](plans/escala-relativa-mapa.md) | **✓ (entregue 2026-07-26)** Quantis (default) + LQ nas âncoras do E10 + posição no município (artefato v3); bolhas por válidos projetados coloridas pela classe | B | ~3d | 3 | E8 ✓, E10 ✓ | +| E14 | Níveis de envolvimento N0–N4 | [detalhes](plans/niveis-de-envolvimento.md) | `engagementLevel` com histerese e sinais de reversão; staff-only (vocabulário duplo) | B | ~1d | 3 | C12 | +| E11 | Motor de sugestões v1 | [detalhes](plans/motor-de-sugestoes.md) | 8 padrões (P1/P2/P3/P5/P6/P7/K-A/K-B), menu com estatuto, aceitar/descartar → `allocationDecision`, triagem 1–5 | C | ~3d | 3 | E8 ✓, E9 ✓, E10, C12 | +| ~~E12~~ | ~~Camada TI~~ | [detalhes](plans/camada-territorios-identidade.md) | **✓ (entregue 2026-07-26)** Rollups MAUP + cobertura/classe/captura em `/campanha/territorios`; benchmark intra-TI no card Conta da cadeira; gatilhos T = E11 fase 2 | B | ~1,5d | 3 | E8 ✓ | +| E13 | Planejador de presença/giros | [detalhes](plans/planejador-de-giros.md) | Elegibilidade (5 condições ✓/—), fases construção/consolidação/ativação, compositor de giro, visita pedida×justificada | C | ~1,5d | 3 | E8 ✓, C12 | +| E15 | Backtest pós-eleição | [detalhes](plans/backtest-pos-eleicao.md) | Pledge (trajetória) vs. resultado por zona; calibração de limiares | A | ~1d | pós-04/10 | C12, TSE 2026 | ### Demais itens abertos - ~~**E4R** import único da planilha de projeção~~ — **entregue 2026-07-24** (`pnpm db:seed:projecao`; overwrite-always; [plano](plans/import-planilha-projecao.md)) - ~~**A11** posição em votos do município (rank absoluto + % da própria votação) + ordenação da lista por votação~~ — **entregue 2026-07-24** (helper `municipalityVoteRank`; baseline + coluna lista; default `?sort=votos` desc; header=`2022`; consome contrato B15) · [plano](plans/ranking-votos-municipio.md) - ~~**E17** tabela comparativa dos Territórios de Identidade no Início (pedido do candidato) · somas/razões apenas, Metropolitano decomposto (salvaguardas MAUP) · ~1d · Janela 1–2 · primeira fatia de E12 · [plano](plans/tabela-ti-inicio.md)~~ — **entregue 2026-07-24** (`territoryOverview.ts` + `loadTerritoryOverview.ts` + `TerritoryOverviewTable.tsx`; rollup puro client-safe, loader `overrideAccess: true`, Metropolitano decomposto, ordenação client-side; sem migration) +- ~~**E12** camada TI (rollups MAUP + benchmark intra-TI)~~ — **entregue 2026-07-26** (estende E17/B21: colunas Cobertura/Captura/Classe em `/campanha/territorios`, `loadTerritoryOverview` com bundle E8 + `computeAggregateTerritorialClass`, captura como razão dos agregados com decomposição no tooltip, T4 no `MunicipalityGoalAccountCard`; `territoryIntraCaptureBenchmark.ts`; conceitos E18; sem migration) · [plano](plans/camada-territorios-identidade.md) - ~~**C12** registro-fundação~~ — **entregue em código 2026-07-24** (versions, sinais, origem/custo derivado, decisões ex-ante; migration + testes) · [plano](plans/registro-fundacao.md) - ~~**C13** renomear "Plano de Ação" → "Atividade" (entidade, código, banco e rotas)~~ — **entregue 2026-07-25** (collection `activity`, 4 tabelas + 3 enums + 2 colunas + 39 índices + 14 FKs + 2 sequences renomeados por migration data-preserving escrita à mão `20260725_213000_rename_action_plan_to_activity`, que também alinhou os 8 índices fósseis `%plaza%` da remodelagem; rota `/campanha/atividades`, sidebar "Atividades", rótulos de campo "Tipo/Origem/Resultado da atividade"; guard de vocabulário table-driven em `codebaseConventions.unit.spec.ts` varrendo `src`+`tests`+`scripts`) · [plano](plans/renomear-plano-acao-para-atividade.md) - **A6** dobradinha 2026 automática quando o TSE publicar candidaturas · gatilho externo: pós-15/08 · camada de insight sobre o registro operacional `stateDeputy` (M4) · alimenta **E13** · [plano](plans/insight-dobradinha-2026.md) @@ -130,6 +130,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - ~~**B16+** escala/DRY pós-B16~~ — **absorvido pelo Pass 2 W1-D1 (2026-07-25)**: `useOptimistic`, facet por slugs, hrefs via serializador canônico, facets no mesmo `Promise.all` · [plano](plans/escala-dry-pos-b16.md) - **E10+** escala/DRY pós-E10 — quatro fases do `/simplify` da classificação territorial: **F1** a lista lê `municipality` 2× por request (`listQuery` + `loadMunicipalityScope`, mesmo `where`, select superset) — **fecha o "Adiado com gatilho" do A11**, cujo gatilho ("3º path derivado no loader") disparou com `classe` sendo o 4º (`votos`, `deficit`, `frescor`); **F2** `computeAllMunicipalityPotentials()` recomputa 435 linhas puras do artefato por request (memo de processo, como `bahiaElectionAggregates`/`municipalityVoteRank`); **F3** um `TooltipProvider` por tooltip (~30 na lista) anula o agrupamento de delay do Radix — hoist para o layout de `(app)`; **F4** os 4 multi-filtros do mobile saem da mesma `municipalityFilterDefinitions` que os single-select vizinhos já usam · ~0,75–1d eng · Impeccable A · sem migration · cortável · [plano](plans/escala-dry-pos-e10.md) - **B13+** escala/DRY pós-B13 — quatro fases do `/simplify` da escala relativa no mapa, as duas primeiras de **coerência** e não de custo: **F1** a razão do LQ é escrita duas vezes (painel cliente `:210` × classificador `server-only` `:108`, separação **deliberada** — o classificador arrasta o artefato de 600 KB) sem nenhum teste travando a concordância, que é a rede de segurança do **E15**; **F2** no readout do mapa a posição vale para a cidade inteira enquanto a classe é calculada sobre o escopo do usuário, então dois assessores veem classes diferentes para o mesmo polígono de Salvador — declarar a geografia, sem mudar o cálculo; **F3** `pnpm build:election-aggregates` varre a mesma fatia estadual do TSE 3× por ano (~225k docs extras) porque `loadFederalVotesByCityZoneAndCandidate` só precisa de `party` no `select` para ser superconjunto estrito dos outros dois; **F4** `competitiveRankByYear` soma ~57 dos ~63,6 KB que o B13 pôs no payload RSC, pagos mesmo por quem nunca abre a escala — tupla `[rank, candidates]` corta ~metade e responde ao "profiling antes de agir" que o ledger pediu em 2026-07-23 · ~0,5–0,75d eng · Impeccable A · sem migration · cortável · [plano](plans/escala-dry-pos-b13.md) +- **E12+** escala/DRY pós-E12 — duas fases condicionais do `/simplify` da camada TI: **F1** extrair peers/sub-linhas do Metropolitano para `lib/` quando surgir 4º call site ou regra mais rica (hoje constantes + benchmark intra-TI compartilham `territoryOverview.ts`); **F2** memo de peer-group no benchmark T4 só com evidência de duplicação no detalhe · ~0,25–0,5d eng · Impeccable A · sem migration · cortável · [plano](plans/escala-dry-pos-e12.md) - **O0+** escala/DRY pós-Onda 0 · [plano](plans/escala-dry-pos-onda0.md) - **RS+** auth read leve + shells de senha · [plano](plans/escala-dry-pos-reset-senha-perfil.md) - **C10 / C11** escala apoiadores/planos · C11 absorveu a extensão pós-C12 para picker de planos e paginação das demandas vinculadas (Fase 6 condicional; ≥100 planos ou ≥50 demandas/plano) · gatilhos: base nominal crescendo ([plano](plans/escala-dry-pos-c9.md)) / volume de planos medido ([plano](plans/escala-dry-pos-c7.md)) diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx b/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx index e6b47c03..8ce25e0c 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx @@ -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} /> ) } diff --git a/src/app/(campaign)/campanha/(app)/territorios/page.tsx b/src/app/(campaign)/campanha/(app)/territorios/page.tsx index 4ebeb18f..e6124236 100644 --- a/src/app/(campaign)/campanha/(app)/territorios/page.tsx +++ b/src/app/(campaign)/campanha/(app)/territorios/page.tsx @@ -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) diff --git a/src/components/campaign/municipality/MunicipalityGoalAccountCard.tsx b/src/components/campaign/municipality/MunicipalityGoalAccountCard.tsx index f8c225eb..d47627c9 100644 --- a/src/components/campaign/municipality/MunicipalityGoalAccountCard.tsx +++ b/src/components/campaign/municipality/MunicipalityGoalAccountCard.tsx @@ -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 {benchmarkRatioLabel} a mediana do seu território ( + {formatRatioAsPercentLabel(territoryCaptureBenchmark.medianCapture)}). + + ) : ( + <>Sem mediana de captura no território — faltam tetos de 2022 nos pares. + ) + return (
} /> + +

+ {benchmarkLead} + {territoryCaptureBenchmark.beacon ? ( + <> + {' '} + Referência no território:{' '} + + {territoryCaptureBenchmark.beacon.name} + {' '} + ({formatRatioAsPercentLabel(territoryCaptureBenchmark.beacon.captureRate)}). Leitura + para aprender mecanismo — não é nota. + + ) : null} +

+
{ + const territorialClass = row.territorialClass + if (!territorialClass) { + return — + } + const why = formatTerritorialClassWhy(territorialClass.factors) + if (territorialClass.class === 'sem_base') { + return ( + + + {why} + + ) + } + return ( + <> + + {territorialClassLabels[territorialClass.class]} + + {why} + + ) +} + +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 ( +
+ {parts.map((line) => ( +

{line}

+ ))} + {row.criticalMunicipality && row.criticalMunicipality.deficit > 0 ? ( + + Abrir município crítico + + ) : null} +

+ A captura do território é a soma dos votos próprios ÷ soma dos tetos — não a média das + capturas por município. +

+
+ ) +} + 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: , + head: ( + + ), cell: (row) => row.municipalityCount, cellClassName: 'text-right tabular-nums', }, { id: 'votes2022', defaultVisible: true, - head: , + head: ( + + ), cell: (row) => , cellClassName: 'text-right tabular-nums', }, { id: 'pct', defaultVisible: true, - head: , + head: ( + + ), 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: , + head: ( + + ), cell: (row) => formatElectionNumber(row.validVotes2022), cellClassName: 'text-right tabular-nums text-muted-foreground', }, { id: 'estimate2026', defaultVisible: true, - head: , + head: ( + + ), cell: (row) => formatElectionNumber(row.estimate2026), cellClassName: (row) => `text-right tabular-nums ${row.variant === 'sub' ? 'text-muted-foreground' : ''}`, }, + { + id: 'cobertura', + defaultVisible: true, + head: ( + + ), + cell: (row) => ( +
+ + {formatGoalCoverageRatioLabel(row.goalCoverage)} + + + {formatGoalCoverageDeficitShortLabel(row.goalCoverage)} + +
+ ), + cellClassName: 'text-right', + }, + { + id: 'captura', + defaultVisible: true, + head: ( + + ), + cell: (row) => formatRatioAsPercentLabel(row.captureRate), + cellClassName: 'text-right tabular-nums', + cellTooltip: (row) => captureTooltipContent(row), + }, + { + id: 'classe', + defaultVisible: true, + head: ( + + ), + cell: (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) => ( diff --git a/src/components/campaign/municipality/TerritorySortableHead.tsx b/src/components/campaign/municipality/TerritorySortableHead.tsx index 8007df44..dc8916b0 100644 --- a/src/components/campaign/municipality/TerritorySortableHead.tsx +++ b/src/components/campaign/municipality/TerritorySortableHead.tsx @@ -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 ( ) : null } + wrapSortControl={ + description + ? (control) => ( + + {control} + + ) + : undefined + } > - {children ?? territoryListSortLabels[sortKey]} + {description ? {label} : label} ) } diff --git a/src/lib/campaignIntelligenceConcepts.ts b/src/lib/campaignIntelligenceConcepts.ts index 02ebb727..9fc8c378 100644 --- a/src/lib/campaignIntelligenceConcepts.ts +++ b/src/lib/campaignIntelligenceConcepts.ts @@ -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): 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] +} diff --git a/src/lib/territorialClassSortWeight.ts b/src/lib/territorialClassSortWeight.ts new file mode 100644 index 00000000..44ea13fb --- /dev/null +++ b/src/lib/territorialClassSortWeight.ts @@ -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 +> diff --git a/src/utilities/loadTerritoryOverview.ts b/src/utilities/loadTerritoryOverview.ts index 02e8eb4a..084c426c 100644 --- a/src/utilities/loadTerritoryOverview.ts +++ b/src/utilities/loadTerritoryOverview.ts @@ -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, + 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, + 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 => { 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) }, ) diff --git a/src/utilities/municipalityGoalAccount.ts b/src/utilities/municipalityGoalAccount.ts index 2fb0558a..8e3ae908 100644 --- a/src/utilities/municipalityGoalAccount.ts +++ b/src/utilities/municipalityGoalAccount.ts @@ -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), } } diff --git a/src/utilities/municipalityTerritorialClass.ts b/src/utilities/municipalityTerritorialClass.ts index 57ba3238..f3612633 100644 --- a/src/utilities/municipalityTerritorialClass.ts +++ b/src/utilities/municipalityTerritorialClass.ts @@ -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 @@ -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 = { - reduto: 4, - expansao: 3, - manutencao: 2, - marginal: 1, - sem_base: null, -} +export { territorialClassSortWeight } diff --git a/src/utilities/territoryIntraCaptureBenchmark.ts b/src/utilities/territoryIntraCaptureBenchmark.ts new file mode 100644 index 00000000..ca733e84 --- /dev/null +++ b/src/utilities/territoryIntraCaptureBenchmark.ts @@ -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() + +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 +} diff --git a/src/utilities/territoryListLabels.ts b/src/utilities/territoryListLabels.ts new file mode 100644 index 00000000..65eda60d --- /dev/null +++ b/src/utilities/territoryListLabels.ts @@ -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> = { + 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.', +} diff --git a/src/utilities/territoryListUrl.ts b/src/utilities/territoryListUrl.ts index 4e955c2c..0e6559b3 100644 --- a/src/utilities/territoryListUrl.ts +++ b/src/utilities/territoryListUrl.ts @@ -33,6 +33,9 @@ const territoryListSortKeys: TerritoryListSortKey[] = [ 'validVotes2022', 'estimate2026', 'coverage', + 'cobertura', + 'captura', + 'classe', ] const territoryListSortKeySet = new Set(territoryListSortKeys) const territoryListSortDirSet = new Set(['asc', 'desc']) @@ -47,6 +50,9 @@ export const territoryListSortLabels: Record = { 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, diff --git a/src/utilities/territoryOverview.ts b/src/utilities/territoryOverview.ts index ff31ee65..e063e98a 100644 --- a/src/utilities/territoryOverview.ts +++ b/src/utilities/territoryOverview.ts @@ -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 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) @@ -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, +): 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, +): 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, + region: string, +): TerritoryMunicipalityInput[] => inputs.filter((input) => input.region === region) + +const metropolitanoSalvadorInputs = ( + inputs: ReadonlyArray, +): TerritoryMunicipalityInput[] => + inputs.filter((input) => input.region === METROPOLITANO_REGION && input.city === SALVADOR_CITY) + +const metropolitanoDemaisInputs = ( + inputs: ReadonlyArray, +): 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) } } diff --git a/tests/unit/territoryOverview.unit.spec.ts b/tests/unit/territoryOverview.unit.spec.ts index 615de022..b35e6137 100644 --- a/tests/unit/territoryOverview.unit.spec.ts +++ b/tests/unit/territoryOverview.unit.spec.ts @@ -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 & Pick, @@ -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() + }) +}) From a911c7191f2dcbd3d699d452a4e4d6e1003c371d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 26 Jul 2026 21:27:23 -0300 Subject: [PATCH 2/2] chore: format after E12 (pre-push gate). --- docs/plans/escala-dry-pos-e12.md | 12 ++++++------ src/utilities/municipalityTerritorialClass.ts | 2 +- src/utilities/territoryIntraCaptureBenchmark.ts | 5 +---- src/utilities/territoryOverview.ts | 4 +--- 4 files changed, 9 insertions(+), 14 deletions(-) diff --git a/docs/plans/escala-dry-pos-e12.md b/docs/plans/escala-dry-pos-e12.md index 822e01a4..793eaf9b 100644 --- a/docs/plans/escala-dry-pos-e12.md +++ b/docs/plans/escala-dry-pos-e12.md @@ -40,9 +40,9 @@ Responsável: — ## 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 | +| 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 | diff --git a/src/utilities/municipalityTerritorialClass.ts b/src/utilities/municipalityTerritorialClass.ts index f3612633..706f8539 100644 --- a/src/utilities/municipalityTerritorialClass.ts +++ b/src/utilities/municipalityTerritorialClass.ts @@ -5,8 +5,8 @@ import { type MunicipalityFederalBaseline, } from '@/lib/bahiaElectionAggregates' import { ELECTION_YEAR_2022 } from '@/lib/electionResults' -import { computeVoteRankByYear } from '@/lib/municipalityVoteRank' import { medianOf } from '@/lib/median' +import { computeVoteRankByYear } from '@/lib/municipalityVoteRank' import { TERRITORIAL_CLASS_ANCHORS } from '@/lib/territorialClassAnchors' import { territorialClassSortWeight } from '@/lib/territorialClassSortWeight' import { diff --git a/src/utilities/territoryIntraCaptureBenchmark.ts b/src/utilities/territoryIntraCaptureBenchmark.ts index ca733e84..beecc978 100644 --- a/src/utilities/territoryIntraCaptureBenchmark.ts +++ b/src/utilities/territoryIntraCaptureBenchmark.ts @@ -11,10 +11,7 @@ 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' +import { METROPOLITANO_REGION, SALVADOR_CITY } from '@/utilities/territoryOverview' type TerritoryCaptureBeacon = { slug: string diff --git a/src/utilities/territoryOverview.ts b/src/utilities/territoryOverview.ts index e063e98a..c220ca52 100644 --- a/src/utilities/territoryOverview.ts +++ b/src/utilities/territoryOverview.ts @@ -16,9 +16,7 @@ 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' +import type { MunicipalityTerritorialClassification } from '@/utilities/municipalityTerritorialClass' export const METROPOLITANO_REGION = 'Metropolitano de Salvador' export const SALVADOR_CITY = 'Salvador'