diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc
index 88ccafbd..35a4673d 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-29):** escala/DRY pós-E12 — F1 forçada: `src/lib/metropolitanoTerritoryPeers.ts` unifica constantes, filtros Salvador vs demais RMS e `catalogPeersForSlug` (rollup E17/E12, `slugsForMetropolitanoSubRow`, benchmark T4). F2 não entrou — `benchmarkBySlug` já memoiza por slug. Spec `metropolitanoTerritoryPeers.unit.spec.ts`. Sem migration/UI. Plano: [`escala-dry-pos-e12.md`](../../../docs/plans/escala-dry-pos-e12.md).
- **B17+ entregue em código (2026-07-29):** Escala/DRY pós-B17 — F1: `readCampaignColumnVisibility` antes do `Promise.all` e `loadLeadershipOptions` / `loadStateDeputyOptions` só quando a coluna de relação está visível (`resolveVisibleColumns`). F2: `CampaignListEmptyState` na prop `empty` de `CampaignTable` (e `SupporterList`) em demandas, organizações e apoiadores, para o botão "Colunas" sobreviver com zero linhas. Plano: [`escala-dry-pos-b17.md`](../../../docs/plans/escala-dry-pos-b17.md).
- **Fill-in remover coluna Tipo entregue em código (2026-07-29):** em `/campanha/municipios`, sumiram a coluna "Tipo", o filtro single-select no header (B16), o ramo mobile em `MunicipalityFilters`, a sort key `kind` e o param `?kind=` em `municipalityListUrl` / `buildMunicipalityListWhere` / `payloadSortFieldByKey` — bookmarks antigos ignoram `kind` no parse. `municipalityKindLabels` permanece para detalhe, mapa e dossiê. `MunicipalityHeaderFilter` ficou só com filtros multi (`singleRows`/`applyMunicipalityKindFilter` deletados). B17: 11 colunas staff; e2e do picker usa Território; B18 usa `?priority=alta`. Sem migration/action/Consent. Plano: [`remover-coluna-tipo-municipios.md`](../../../docs/plans/remover-coluna-tipo-municipios.md).
- **B18 entregue em código (2026-07-28):** filtros salvos na lista de Municípios — o recorte ativo vira um bookmark nomeado no `localStorage` (`teqo:campaign:municipality-saved-filters`, `MAX_ENTRIES = 12`) e reaparece como submenu recolhível sob "Municípios" no sidebar. **Duas premissas do plano de 24/07 morreram na auditoria.** (1) `SidebarMenuSub`/`SidebarMenuSubItem`/`SidebarMenuSubButton`/`SidebarMenuAction` **não existiam** — o plano afirmava que estavam no kit sem uso, mas o Pass 2 W4b pôs knip `exports` em ERROR e o kit foi podado; os quatro primitivos voltaram do upstream, e **o re-add expôs um bug pré-existente no `SidebarMenuButton`**: `data-active={false}` renderiza como `data-active="false"` e o variante `data-active:` do Tailwind casa por **presença** do atributo, então todo item **não** ativo estava recebendo o realce do ativo (`isActive || undefined` nos dois botões). (2) `municipalityUi.ts` não existe mais (Pass 2 W1 o dividiu) e o estado da URL cresceu para 14 params — absorvido serializando o estado inteiro menos `page`/`compare` em `buildMunicipalitySavedFilterHref` (`compare` é a lente do mapa do Início, não recorte desta lista). Também corrigido no plano: **B17 persiste em cookie**, não `localStorage`. **A restrição que manda no desenho é o layout:** a sidebar está no layout de `(app)`, então o casamento "URL atual == filtro salvo" **não pode** usar o serializador do domínio (a lição dos 21 kB do B14 ✓) — virou `src/lib/listQueryMatch.ts`, comparador puro de `URLSearchParams` insensível à ordem e ignorando `page`, o que de passagem resolve estar na página 3 de um filtro salvo (caso que o plano não previa). Medido contra árvore limpa: `/campanha` **byte-idêntico**, `/campanha/municipios` +2 kB. **Identidade é o href canônico, não um id:** salvar o mesmo recorte duas vezes é upsert — o Popover pré-preenche o nome atual e o botão lê "Renomear"/"Atualizar nome". O nome padrão e o gate "tem filtro salvável?" saem os dois de `formatMunicipalityActiveFiltersSummary`, que o `Limpar` já usava, então o `municipalityListHasSavableFilters` que o plano pedia não precisou existir. Sincronização das duas ilhas (barra grava, sidebar lê) por `useSyncExternalStore` com cache de snapshot invalidado na escrita e no evento de outra aba — a armadilha conhecida do loop de render. **Três defeitos de comportamento achados na verificação, e os dois primeiros são a mesma família de bug que este repo já pagou várias vezes** (estado lido do render que agendou o trabalho / hidratação confundida com ação): (1) abrir o grupo quando `activeHref` **aparece** reabria o submenu em **todo reload**, porque o store responde vazio até o cliente ler o `localStorage` e o match surgia da hidratação — o gatilho passou a ser a **URL mudar**, guardada num `useRef`, e só então a preferência recolhida sobrevive a um F5 para quem mais usa a feature; (2) apagar o último filtro derrubava o foco para `
`, porque o botão que o detinha desmontava — o sucessor é resolvido **antes** da remoção, e o fallback tem de ser o link de nav, já que esvaziar o grupo desmonta o próprio disclosure; (3) SC 2.5.3 — o `aria-label` "Salvar este filtro" **não continha** o rótulo visível "Salvar filtro", então "clique em Salvar filtro" por voz não casava nada; removido (o caso de renomear pode manter porque a palavra visível vem primeiro). Mais acessibilidade: `aria-describedby` ligando a recusa ao input, `aria-label` no `` do submenu, `aria-current="page"` nos links ativos. Apagar é **desfazível** (toast com "Desfazer" restaurando nome e posição alfabética), que é por que nunca pede confirmação; o logout limpa o storage ao lado de `clearRecentVisits()`. Sem migration, collection, action ou `Consent`; nada de rota `/vistas/[id]`, sync multi-device ou saved views genéricas nas outras listas. **Cuidado operacional novo:** `pnpm build` limpa o `.next`, e com ele o `.next/e2e` que o dev server do Playwright usa — rodar e2e logo depois de um build estoura o timeout do `setup` enquanto a rota recompila (o e2e ganhou orçamento de 20 s nas navegações, mesmo motivo do `campaignColumnPicker`). Plano as-built: [`filtros-salvos-municipios.md`](../../../docs/plans/filtros-salvos-municipios.md).
diff --git a/docs/plans/escala-dry-pos-e12.md b/docs/plans/escala-dry-pos-e12.md
index 793eaf9b..c2a97606 100644
--- a/docs/plans/escala-dry-pos-e12.md
+++ b/docs/plans/escala-dry-pos-e12.md
@@ -1,12 +1,14 @@
# Escala e DRY pós-E12 (camada TI)
-Status: rascunho
-Atualizado em: 2026-07-26
+Status: entregue (F1, 2026-07-29)
+Atualizado em: 2026-07-29
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: —
+**Revisão 2026-07-29:** F1 entregue por override explícito de produto (antes do 4º call site): `src/lib/metropolitanoTerritoryPeers.ts` concentra constantes, filtros de sub-linha e `catalogPeersForSlug`; `territoryOverview.ts`, `loadTerritoryOverview.ts` e `territoryIntraCaptureBenchmark.ts` migrados. **F2 out:** `benchmarkBySlug` em `territoryIntraCaptureBenchmark.ts` já memoiza por slug por processo; `loadMunicipalityGoalAccount` chama o benchmark uma vez por request — sem `cache()` adicional.
+
## Dados → decisão → apresentação
- **Vou apresentar dados?** Não (N/A).
@@ -15,7 +17,7 @@ Responsável: —
**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`.
+**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`. **E12+ (2026-07-29):** regras Metropolitano (constantes, sub-linhas, peers por slug) em `src/lib/metropolitanoTerritoryPeers.ts` — substitui as constantes que viviam em `territoryOverview.ts` e a lógica espelhada no benchmark T4.
## Objetivos
diff --git a/docs/roadmap.md b/docs/roadmap.md
index eb60b5e2..918e664d 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -145,7 +145,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
- **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)
- ~~**B8+** escala/DRY pós-B8 F2~~ — **✓ (entregue 2026-07-27)** as quatro fases do `/simplify` dos polígonos das ZE. **F1:** a política de escala de chão nasceu em `scripts/lib/topology.mjs` (primeiro módulo de `scripts/lib/` — **B5 F2** pousa nele) declarando tolerância de Visvalingam em **m² de chão** e derivando os dígitos de quantização do bbox de cada topologia; `SIMPLIFY_QUANTILE`/`QUANTIZE_DIGITS` morreram nos dois scripts. A auditoria corrigiu a alavanca do plano: quantização sozinha quase não corta (mesmo na grade estadual as zonas ficariam em 203 pontos/feature), o peso absoluto é que manda — as zonas caíram de **9.120 → 574 pontos de arco (480 → 30,2/feature) e 72.310 → 7.687 bytes**, `bahia-municipalities` saiu **byte-idêntico** e territórios mudou 1 byte (grade 102,730 → 102,822 m). Tolerância própria por malha, com o número medido no comentário: a tolerância municipal chegaria a 17,9/feature nas zonas mas comeria 5% da área de Salvador (306 → 291 km²), que é justamente a grandeza que prova que nenhum bairro sumiu. `MAX_TOPO_BYTES` ganhou ao lado `MAX_POINTS_PER_FEATURE = 60`. **F2:** `featureMapKey` passou a ser exercido sobre features **reais** das três malhas, cada uma no laço que já a varre inteira (417 `codarea` + 27 `code` + 19 `slug`) — o `/simplify` corrigiu a primeira escrita, que asseria a cidade dentro do laço das zonas e portanto cobria **uma** feature municipal 19 vezes; o teste de precedência sintético caiu e o comentário passou a declarar o invariante real (as três propriedades de chave são mutuamente exclusivas; `ibgeCode` na zona deliberadamente não é chave). **F3:** `.catch()` na metade das zonas — o mapa segue sem elas (com o motivo logado), com Salvador como polígono sem dado nem interação — e a ressalva de aproximação saiu do gate `!comparisonActive` para um `` condicionado a `bundle.hasZoneMunicipalities`, campo novo publicado pelo loader a partir de `kind === 'zona'`: o `/simplify` **rejeitou** o predicado `isZoneMapKey` que lia isso da forma da chave (7 dígitos ⇒ município), porque era uma segunda codificação da regra de `mapKeyForMunicipality`, e gatear em `zoneBreakdown` recriaria o acoplamento que o fill-in da lista (✓ 2026-07-28) deletou. **F4:** dois acessores órfãos deletados, a união de navegação de 3 estados colapsada em `string | undefined`, `MunicipalitiesByMapKey` virou `Record` (435 strings fora do payload RSC do Início), `polygonRingsOf` exportado e os helpers de geometria retipados em `PolygonalFeature`/`BahiaFeature`/`BahiaMeshFeature`. **Um item do plano foi rejeitado por medição:** `city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` **ficam** — entram nas linhas hasheadas por `canonicalEvidenceRows` e são colunas do fixture de proveniência da RA 02/2017, então não são código morto (só o `zoneNumber` do **artefato** TopoJSON saiu) · sem migration · [plano](plans/escala-dry-pos-b8f2.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)
+- **E12+** escala/DRY pós-E12 — **✓ (entregue 2026-07-29)** F1: `src/lib/metropolitanoTerritoryPeers.ts` (peers/sub-linhas Metropolitano unificados para rollup, classe agregada e benchmark T4); F2 adiada — `benchmarkBySlug` já cobre · ~0,25d · Impeccable A · [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/lib/metropolitanoTerritoryPeers.ts b/src/lib/metropolitanoTerritoryPeers.ts
new file mode 100644
index 00000000..bbc44498
--- /dev/null
+++ b/src/lib/metropolitanoTerritoryPeers.ts
@@ -0,0 +1,69 @@
+/**
+ * E12+ — Metropolitano de Salvador peer/sub-row rules (client-safe).
+ *
+ * The Metropolitano TI is always split: Salvador (19 zone municipalities) vs
+ * demais RMS municipalities. Same rule for territory rollups (E17/E12), aggregate
+ * class slugs, and intra-TI capture benchmark (T4).
+ */
+
+import {
+ getMunicipalityCatalogEntry,
+ municipalityCatalog,
+ type MunicipalityCatalogEntry,
+} from '@/lib/municipalityCatalog'
+
+export const METROPOLITANO_REGION = 'Metropolitano de Salvador'
+export const SALVADOR_CITY = 'Salvador'
+export const METROPOLITANO_SALVADOR_SUB_ROW_LABEL = 'Salvador (19 zonas)'
+export const METROPOLITANO_DEMAIS_SUB_ROW_LABEL = 'Demais municípios da RMS'
+
+export type MetropolitanoGeo = {
+ region: string
+ city: string
+}
+
+const isMetropolitanoRegion = (region: string): boolean => region === METROPOLITANO_REGION
+
+export const isSalvadorSubMunicipality = (geo: MetropolitanoGeo): boolean =>
+ isMetropolitanoRegion(geo.region) && geo.city === SALVADOR_CITY
+
+const isDemaisRmsSubMunicipality = (geo: MetropolitanoGeo): boolean =>
+ isMetropolitanoRegion(geo.region) && geo.city !== SALVADOR_CITY
+
+export const isSalvadorMetropolitanoSubRowLabel = (label: string): boolean =>
+ label === METROPOLITANO_SALVADOR_SUB_ROW_LABEL
+
+export const filterSalvadorSubgroup = (items: ReadonlyArray): T[] =>
+ items.filter(isSalvadorSubMunicipality)
+
+export const filterDemaisRmsSubgroup = (items: ReadonlyArray): T[] =>
+ items.filter(isDemaisRmsSubMunicipality)
+
+export const slugsForMetropolitanoSubRowLabel = (
+ inputs: ReadonlyArray,
+ label: string,
+): string[] => {
+ const filtered = isSalvadorMetropolitanoSubRowLabel(label)
+ ? filterSalvadorSubgroup(inputs)
+ : filterDemaisRmsSubgroup(inputs)
+ return filtered.map((input) => input.slug)
+}
+
+const peersForCatalogEntry = (
+ entry: MunicipalityCatalogEntry,
+): ReadonlyArray => {
+ if (!isMetropolitanoRegion(entry.region)) {
+ return municipalityCatalog.filter((row) => row.region === entry.region)
+ }
+
+ return isSalvadorSubMunicipality(entry)
+ ? municipalityCatalog.filter(isSalvadorSubMunicipality)
+ : municipalityCatalog.filter(isDemaisRmsSubMunicipality)
+}
+
+/** TI-wide peers, or Metropolitano Salvador vs demais RMS. */
+export const catalogPeersForSlug = (slug: string): ReadonlyArray => {
+ const entry = getMunicipalityCatalogEntry(slug)
+ if (!entry) return []
+ return peersForCatalogEntry(entry)
+}
diff --git a/src/utilities/territory/loadTerritoryOverview.ts b/src/utilities/territory/loadTerritoryOverview.ts
index 555be9bd..3e090898 100644
--- a/src/utilities/territory/loadTerritoryOverview.ts
+++ b/src/utilities/territory/loadTerritoryOverview.ts
@@ -4,6 +4,7 @@ import type { Payload } from 'payload'
import { cache } from 'react'
import { getMunicipalityFederalBaseline } from '@/lib/bahiaElectionAggregates'
+import { slugsForMetropolitanoSubRowLabel } from '@/lib/metropolitanoTerritoryPeers'
import { DEFAULT_VOTE_ESTIMATE_SCENARIO, type VoteEstimateScenario } from '@/lib/voteEstimate'
import type { CampaignUser, Municipality, User } from '@/payload-types'
import { loadMunicipalityGoalCoverageBundle } from '@/utilities/municipality/municipalityGoalAccount'
@@ -11,9 +12,6 @@ import { fieldCeiling, ownVotes2022 } from '@/utilities/municipality/municipalit
import { computeAggregateTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass'
import {
computeTerritoryRollup,
- METROPOLITANO_REGION,
- METROPOLITANO_SALVADOR_SUB_ROW_LABEL,
- SALVADOR_CITY,
type TerritoryMunicipalityInput,
type TerritoryOverviewRow,
} from '@/utilities/territory/territoryOverview'
@@ -30,20 +28,6 @@ type MunicipalityDoc = Pick<
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[],
@@ -55,7 +39,7 @@ const attachTerritorialClasses = (
const subRows = row.subRows?.map((subRow) => ({
...subRow,
territorialClass: computeAggregateTerritorialClass(
- slugsForMetropolitanoSubRow(inputs, subRow.label),
+ slugsForMetropolitanoSubRowLabel(inputs, subRow.label),
),
}))
return {
diff --git a/src/utilities/territory/territoryIntraCaptureBenchmark.ts b/src/utilities/territory/territoryIntraCaptureBenchmark.ts
index 3dd495ca..08694c9b 100644
--- a/src/utilities/territory/territoryIntraCaptureBenchmark.ts
+++ b/src/utilities/territory/territoryIntraCaptureBenchmark.ts
@@ -2,16 +2,13 @@
* 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.
+ * Peers share the same Território de Identidade (`catalogPeersForSlug`).
*/
import { getMunicipalityFederalBaseline } from '@/lib/bahiaElectionAggregates'
import { medianOf } from '@/lib/median'
-import { municipalityCatalog } from '@/lib/municipalityCatalog'
+import { catalogPeersForSlug } from '@/lib/metropolitanoTerritoryPeers'
import { captureRate } from '@/utilities/municipality/municipalityPotential'
-import { METROPOLITANO_REGION, SALVADOR_CITY } from '@/utilities/territory/territoryOverview'
type TerritoryCaptureBeacon = {
slug: string
@@ -33,23 +30,6 @@ export type MunicipalityIntraTerritoryCaptureBenchmark = {
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 = (
@@ -58,7 +38,7 @@ export const getMunicipalityIntraTerritoryCaptureBenchmark = (
const cached = benchmarkBySlug.get(slug)
if (cached) return cached
- const peers = peersForSlug(slug)
+ const peers = catalogPeersForSlug(slug)
const rates: Array<{ slug: string; name: string; rate: number }> = []
for (const peer of peers) {
diff --git a/src/utilities/territory/territoryOverview.ts b/src/utilities/territory/territoryOverview.ts
index e5fee548..7a19cbaa 100644
--- a/src/utilities/territory/territoryOverview.ts
+++ b/src/utilities/territory/territoryOverview.ts
@@ -12,18 +12,20 @@
*/
import { medianOf } from '@/lib/median'
+import {
+ filterDemaisRmsSubgroup,
+ filterSalvadorSubgroup,
+ isSalvadorSubMunicipality,
+ METROPOLITANO_DEMAIS_SUB_ROW_LABEL,
+ METROPOLITANO_REGION,
+ METROPOLITANO_SALVADOR_SUB_ROW_LABEL,
+} from '@/lib/metropolitanoTerritoryPeers'
import { territorialClassSortWeight } from '@/lib/territorialClassSortWeight'
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
import type { MunicipalityGoalCoverage } from '@/utilities/municipality/goalCoverage'
import { aggregateGoalCoverage } from '@/utilities/municipality/goalCoverage'
import type { MunicipalityTerritorialClassification } from '@/utilities/municipality/municipalityTerritorialClass'
-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 ELECTION_YEAR_2022 = '2022'
type TerritoryCriticalMunicipality = {
@@ -240,16 +242,6 @@ const inputsInRegion = (
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
@@ -270,7 +262,7 @@ export const computeTerritoryRollup = (
for (const input of inputs) {
if (input.region === METROPOLITANO_REGION) {
- if (input.city === SALVADOR_CITY) {
+ if (isSalvadorSubMunicipality(input)) {
accumulateInto(metropolitanoSalvador, input)
} else {
accumulateInto(metropolitanoDemais, input)
@@ -289,16 +281,16 @@ export const computeTerritoryRollup = (
if (acc.region === METROPOLITANO_REGION) {
row.subRows = [
finalizeSubRow(
- SALVADOR_SUB_LABEL,
+ METROPOLITANO_SALVADOR_SUB_ROW_LABEL,
metropolitanoSalvador,
stateTotal2022,
- computeTerritoryE12Rollup(metropolitanoSalvadorInputs(inputs)),
+ computeTerritoryE12Rollup(filterSalvadorSubgroup(regionInputs)),
),
finalizeSubRow(
- RMS_DEMAIS_SUB_LABEL,
+ METROPOLITANO_DEMAIS_SUB_ROW_LABEL,
metropolitanoDemais,
stateTotal2022,
- computeTerritoryE12Rollup(metropolitanoDemaisInputs(inputs)),
+ computeTerritoryE12Rollup(filterDemaisRmsSubgroup(regionInputs)),
),
]
}
diff --git a/tests/unit/metropolitanoTerritoryPeers.unit.spec.ts b/tests/unit/metropolitanoTerritoryPeers.unit.spec.ts
new file mode 100644
index 00000000..d55186b1
--- /dev/null
+++ b/tests/unit/metropolitanoTerritoryPeers.unit.spec.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ METROPOLITANO_DEMAIS_SUB_ROW_LABEL,
+ METROPOLITANO_REGION,
+ METROPOLITANO_SALVADOR_SUB_ROW_LABEL,
+ SALVADOR_CITY,
+ catalogPeersForSlug,
+ filterDemaisRmsSubgroup,
+ filterSalvadorSubgroup,
+ isSalvadorMetropolitanoSubRowLabel,
+} from '@/lib/metropolitanoTerritoryPeers'
+import { municipalityCatalog } from '@/lib/municipalityCatalog'
+
+describe('metropolitanoTerritoryPeers', () => {
+ it('Salvador zone peers are exactly the 19 Metropolitano Salvador entries', () => {
+ const salvadorZe = municipalityCatalog.find(
+ (row) =>
+ row.region === METROPOLITANO_REGION && row.city === SALVADOR_CITY && row.kind === 'zona',
+ )
+ expect(salvadorZe).toBeDefined()
+
+ const peers = catalogPeersForSlug(salvadorZe!.slug)
+ expect(peers).toHaveLength(19)
+ expect(
+ peers.every((row) => row.city === SALVADOR_CITY && row.region === METROPOLITANO_REGION),
+ ).toBe(true)
+ })
+
+ it('RMS peer outside Salvador excludes Salvador zones', () => {
+ const rmsPeer = municipalityCatalog.find(
+ (row) => row.region === METROPOLITANO_REGION && row.city !== SALVADOR_CITY,
+ )
+ expect(rmsPeer).toBeDefined()
+
+ const peers = catalogPeersForSlug(rmsPeer!.slug)
+ expect(peers.length).toBeGreaterThan(0)
+ expect(
+ peers.every((row) => row.region === METROPOLITANO_REGION && row.city !== SALVADOR_CITY),
+ ).toBe(true)
+ expect(peers.some((row) => row.city === SALVADOR_CITY)).toBe(false)
+ })
+
+ it('non-Metropolitano peers share the same identity territory', () => {
+ const irece = municipalityCatalog.find((row) => row.region === 'Irecê')
+ expect(irece).toBeDefined()
+
+ const peers = catalogPeersForSlug(irece!.slug)
+ expect(peers.length).toBeGreaterThan(1)
+ expect(peers.every((row) => row.region === 'Irecê')).toBe(true)
+ })
+
+ it('sub-row filters match catalog Salvador vs demais RMS', () => {
+ const salvadorSlugs = new Set(
+ filterSalvadorSubgroup(municipalityCatalog).map((row) => row.slug),
+ )
+ const demaisSlugs = new Set(filterDemaisRmsSubgroup(municipalityCatalog).map((row) => row.slug))
+
+ expect(salvadorSlugs.size).toBe(19)
+ expect(demaisSlugs.size).toBeGreaterThan(0)
+ for (const slug of salvadorSlugs) {
+ expect(demaisSlugs.has(slug)).toBe(false)
+ }
+ })
+
+ it('sub-row labels distinguish Salvador vs demais', () => {
+ expect(isSalvadorMetropolitanoSubRowLabel(METROPOLITANO_SALVADOR_SUB_ROW_LABEL)).toBe(true)
+ expect(isSalvadorMetropolitanoSubRowLabel(METROPOLITANO_DEMAIS_SUB_ROW_LABEL)).toBe(false)
+ })
+})
diff --git a/tests/unit/territoryOverview.unit.spec.ts b/tests/unit/territoryOverview.unit.spec.ts
index 7532906d..7de1db5e 100644
--- a/tests/unit/territoryOverview.unit.spec.ts
+++ b/tests/unit/territoryOverview.unit.spec.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
+import {
+ METROPOLITANO_DEMAIS_SUB_ROW_LABEL,
+ METROPOLITANO_SALVADOR_SUB_ROW_LABEL,
+} from '@/lib/metropolitanoTerritoryPeers'
import {
computeTerritoryE12Rollup,
computeTerritoryRollup,
@@ -134,8 +138,8 @@ describe('computeTerritoryRollup', () => {
expect(metro.subRows).toHaveLength(2)
const salvador = metro.subRows![0]
const demais = metro.subRows![1]
- expect(salvador.label).toBe('Salvador (19 zonas)')
- expect(demais.label).toBe('Demais municípios da RMS')
+ expect(salvador.label).toBe(METROPOLITANO_SALVADOR_SUB_ROW_LABEL)
+ expect(demais.label).toBe(METROPOLITANO_DEMAIS_SUB_ROW_LABEL)
expect(salvador.municipalityCount).toBe(1)
expect(demais.municipalityCount).toBe(1)
// Sub-rows sum to the Metropolitano total.
@@ -187,7 +191,7 @@ describe('flattenTerritoryRows', () => {
expect(flattened[metroIndex + 1]).toMatchObject({
variant: 'sub',
parentRegion: 'Metropolitano de Salvador',
- label: 'Salvador (19 zonas)',
+ label: METROPOLITANO_SALVADOR_SUB_ROW_LABEL,
})
})
})